Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/contrib/suggest/browser/suggestWidgetDetails.ts
4797 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 '../../../../base/browser/dom.js';
7
import { ResizableHTMLElement } from '../../../../base/browser/ui/resizable/resizable.js';
8
import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js';
9
import { Codicon } from '../../../../base/common/codicons.js';
10
import { Emitter, Event } from '../../../../base/common/event.js';
11
import { MarkdownString } from '../../../../base/common/htmlContent.js';
12
import { DisposableStore } from '../../../../base/common/lifecycle.js';
13
import { ThemeIcon } from '../../../../base/common/themables.js';
14
import * as nls from '../../../../nls.js';
15
import { isHighContrast } from '../../../../platform/theme/common/theme.js';
16
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
17
import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../browser/editorBrowser.js';
18
import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js';
19
import { EditorOption } from '../../../common/config/editorOptions.js';
20
import { CompletionItem } from './suggest.js';
21
22
export function canExpandCompletionItem(item: CompletionItem | undefined): boolean {
23
return !!item && Boolean(item.completion.documentation || item.completion.detail && item.completion.detail !== item.completion.label);
24
}
25
26
export class SuggestDetailsWidget {
27
28
readonly domNode: HTMLDivElement;
29
30
private readonly _onDidClose = new Emitter<void>();
31
readonly onDidClose: Event<void> = this._onDidClose.event;
32
33
private readonly _onDidChangeContents = new Emitter<this>();
34
readonly onDidChangeContents: Event<this> = this._onDidChangeContents.event;
35
36
private readonly _close: HTMLElement;
37
private readonly _scrollbar: DomScrollableElement;
38
private readonly _body: HTMLElement;
39
private readonly _header: HTMLElement;
40
private readonly _type: HTMLElement;
41
private readonly _docs: HTMLElement;
42
private readonly _disposables = new DisposableStore();
43
44
private readonly _renderDisposeable = new DisposableStore();
45
private _size = new dom.Dimension(330, 0);
46
47
constructor(
48
private readonly _editor: ICodeEditor,
49
@IThemeService private readonly _themeService: IThemeService,
50
@IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService,
51
) {
52
this.domNode = dom.$('.suggest-details');
53
this.domNode.classList.add('no-docs');
54
55
56
this._body = dom.$('.body');
57
58
this._scrollbar = new DomScrollableElement(this._body, {
59
alwaysConsumeMouseWheel: true,
60
});
61
dom.append(this.domNode, this._scrollbar.getDomNode());
62
this._disposables.add(this._scrollbar);
63
64
this._header = dom.append(this._body, dom.$('.header'));
65
this._close = dom.append(this._header, dom.$('span' + ThemeIcon.asCSSSelector(Codicon.close)));
66
this._close.title = nls.localize('details.close', "Close");
67
this._close.role = 'button';
68
this._close.tabIndex = -1;
69
this._type = dom.append(this._header, dom.$('p.type'));
70
71
this._docs = dom.append(this._body, dom.$('p.docs'));
72
73
this._configureFont();
74
75
this._disposables.add(this._editor.onDidChangeConfiguration(e => {
76
if (e.hasChanged(EditorOption.fontInfo)) {
77
this._configureFont();
78
}
79
}));
80
}
81
82
dispose(): void {
83
this._disposables.dispose();
84
this._renderDisposeable.dispose();
85
}
86
87
private _configureFont(): void {
88
const options = this._editor.getOptions();
89
const fontInfo = options.get(EditorOption.fontInfo);
90
const fontFamily = fontInfo.getMassagedFontFamily();
91
const fontSize = options.get(EditorOption.suggestFontSize) || fontInfo.fontSize;
92
const lineHeight = options.get(EditorOption.suggestLineHeight) || fontInfo.lineHeight;
93
const fontWeight = fontInfo.fontWeight;
94
const fontSizePx = `${fontSize}px`;
95
const lineHeightPx = `${lineHeight}px`;
96
97
this.domNode.style.fontSize = fontSizePx;
98
this.domNode.style.lineHeight = `${lineHeight / fontSize}`;
99
this.domNode.style.fontWeight = fontWeight;
100
this.domNode.style.fontFeatureSettings = fontInfo.fontFeatureSettings;
101
this._type.style.fontFamily = fontFamily;
102
this._close.style.height = lineHeightPx;
103
this._close.style.width = lineHeightPx;
104
}
105
106
getLayoutInfo() {
107
const lineHeight = this._editor.getOption(EditorOption.suggestLineHeight) || this._editor.getOption(EditorOption.fontInfo).lineHeight;
108
const borderWidth = isHighContrast(this._themeService.getColorTheme().type) ? 2 : 1;
109
const borderHeight = borderWidth * 2;
110
return {
111
lineHeight,
112
borderWidth,
113
borderHeight,
114
verticalPadding: 22,
115
horizontalPadding: 14
116
};
117
}
118
119
120
renderLoading(): void {
121
this._type.textContent = nls.localize('loading', "Loading...");
122
this._docs.textContent = '';
123
this.domNode.classList.remove('no-docs', 'no-type');
124
this.layout(this.size.width, this.getLayoutInfo().lineHeight * 2);
125
this._onDidChangeContents.fire(this);
126
}
127
128
renderItem(item: CompletionItem, explainMode: boolean): void {
129
this._renderDisposeable.clear();
130
131
let { detail, documentation } = item.completion;
132
133
if (explainMode) {
134
let md = '';
135
md += `score: ${item.score[0]}\n`;
136
md += `prefix: ${item.word ?? '(no prefix)'}\n`;
137
md += `word: ${item.completion.filterText ? item.completion.filterText + ' (filterText)' : item.textLabel}\n`;
138
md += `distance: ${item.distance} (localityBonus-setting)\n`;
139
md += `index: ${item.idx}, based on ${item.completion.sortText && `sortText: "${item.completion.sortText}"` || 'label'}\n`;
140
md += `commit_chars: ${item.completion.commitCharacters?.join('')}\n`;
141
documentation = new MarkdownString().appendCodeblock('empty', md);
142
detail = `Provider: ${item.provider._debugDisplayName}`;
143
}
144
145
if (!explainMode && !canExpandCompletionItem(item)) {
146
this.clearContents();
147
return;
148
}
149
150
this.domNode.classList.remove('no-docs', 'no-type');
151
152
// --- details
153
154
if (detail) {
155
const cappedDetail = detail.length > 100000 ? `${detail.substr(0, 100000)}…` : detail;
156
this._type.textContent = cappedDetail;
157
this._type.title = cappedDetail;
158
dom.show(this._type);
159
this._type.classList.toggle('auto-wrap', !/\r?\n^\s+/gmi.test(cappedDetail));
160
} else {
161
dom.clearNode(this._type);
162
this._type.title = '';
163
dom.hide(this._type);
164
this.domNode.classList.add('no-type');
165
}
166
167
// --- documentation
168
dom.clearNode(this._docs);
169
if (typeof documentation === 'string') {
170
this._docs.classList.remove('markdown-docs');
171
this._docs.textContent = documentation;
172
173
} else if (documentation) {
174
this._docs.classList.add('markdown-docs');
175
dom.clearNode(this._docs);
176
const renderedContents = this._markdownRendererService.render(documentation, {
177
context: this._editor,
178
asyncRenderCallback: () => {
179
this.layout(this._size.width, this._type.clientHeight + this._docs.clientHeight);
180
this._onDidChangeContents.fire(this);
181
}
182
});
183
this._docs.appendChild(renderedContents.element);
184
this._renderDisposeable.add(renderedContents);
185
}
186
187
this.domNode.classList.toggle('detail-and-doc', !!detail && !!documentation);
188
189
this.domNode.style.userSelect = 'text';
190
this.domNode.tabIndex = -1;
191
192
this._close.onmousedown = e => {
193
e.preventDefault();
194
e.stopPropagation();
195
};
196
this._close.onclick = e => {
197
e.preventDefault();
198
e.stopPropagation();
199
this._onDidClose.fire();
200
};
201
202
this._body.scrollTop = 0;
203
204
this.layout(this._size.width, this._type.clientHeight + this._docs.clientHeight);
205
this._onDidChangeContents.fire(this);
206
}
207
208
clearContents() {
209
this.domNode.classList.add('no-docs');
210
this._type.textContent = '';
211
this._docs.textContent = '';
212
}
213
214
get isEmpty(): boolean {
215
return this.domNode.classList.contains('no-docs');
216
}
217
218
get size() {
219
return this._size;
220
}
221
222
layout(width: number, height: number): void {
223
const newSize = new dom.Dimension(width, height);
224
if (!dom.Dimension.equals(newSize, this._size)) {
225
this._size = newSize;
226
dom.size(this.domNode, width, height);
227
}
228
this._scrollbar.scanDomNode();
229
}
230
231
scrollDown(much = 8): void {
232
this._body.scrollTop += much;
233
}
234
235
scrollUp(much = 8): void {
236
this._body.scrollTop -= much;
237
}
238
239
scrollTop(): void {
240
this._body.scrollTop = 0;
241
}
242
243
scrollBottom(): void {
244
this._body.scrollTop = this._body.scrollHeight;
245
}
246
247
pageDown(): void {
248
this.scrollDown(80);
249
}
250
251
pageUp(): void {
252
this.scrollUp(80);
253
}
254
255
focus() {
256
this.domNode.focus();
257
}
258
}
259
260
interface TopLeftPosition {
261
top: number;
262
left: number;
263
}
264
265
export class SuggestDetailsOverlay implements IOverlayWidget {
266
267
readonly allowEditorOverflow = true;
268
269
private readonly _disposables = new DisposableStore();
270
private readonly _resizable: ResizableHTMLElement;
271
272
private _added: boolean = false;
273
private _anchorBox?: dom.IDomNodePagePosition;
274
private _preferAlignAtTop: boolean = true;
275
private _userSize?: dom.Dimension;
276
private _topLeft?: TopLeftPosition;
277
278
constructor(
279
readonly widget: SuggestDetailsWidget,
280
private readonly _editor: ICodeEditor
281
) {
282
283
this._resizable = new ResizableHTMLElement();
284
this._resizable.domNode.classList.add('suggest-details-container');
285
this._resizable.domNode.appendChild(widget.domNode);
286
this._resizable.enableSashes(false, true, true, false);
287
288
let topLeftNow: TopLeftPosition | undefined;
289
let sizeNow: dom.Dimension | undefined;
290
let deltaTop: number = 0;
291
let deltaLeft: number = 0;
292
this._disposables.add(this._resizable.onDidWillResize(() => {
293
topLeftNow = this._topLeft;
294
sizeNow = this._resizable.size;
295
}));
296
297
this._disposables.add(this._resizable.onDidResize(e => {
298
if (topLeftNow && sizeNow) {
299
this.widget.layout(e.dimension.width, e.dimension.height);
300
301
let updateTopLeft = false;
302
if (e.west) {
303
deltaLeft = sizeNow.width - e.dimension.width;
304
updateTopLeft = true;
305
}
306
if (e.north) {
307
deltaTop = sizeNow.height - e.dimension.height;
308
updateTopLeft = true;
309
}
310
if (updateTopLeft) {
311
this._applyTopLeft({
312
top: topLeftNow.top + deltaTop,
313
left: topLeftNow.left + deltaLeft,
314
});
315
}
316
}
317
if (e.done) {
318
topLeftNow = undefined;
319
sizeNow = undefined;
320
deltaTop = 0;
321
deltaLeft = 0;
322
this._userSize = e.dimension;
323
}
324
}));
325
326
this._disposables.add(this.widget.onDidChangeContents(() => {
327
if (this._anchorBox) {
328
this._placeAtAnchor(this._anchorBox, this._userSize ?? this.widget.size, this._preferAlignAtTop);
329
}
330
}));
331
}
332
333
dispose(): void {
334
this._resizable.dispose();
335
this._disposables.dispose();
336
this.hide();
337
}
338
339
getId(): string {
340
return 'suggest.details';
341
}
342
343
getDomNode(): HTMLElement {
344
return this._resizable.domNode;
345
}
346
347
getPosition(): IOverlayWidgetPosition | null {
348
return this._topLeft ? { preference: this._topLeft } : null;
349
}
350
351
show(): void {
352
if (!this._added) {
353
this._editor.addOverlayWidget(this);
354
this._added = true;
355
}
356
}
357
358
hide(sessionEnded: boolean = false): void {
359
this._resizable.clearSashHoverState();
360
361
if (this._added) {
362
this._editor.removeOverlayWidget(this);
363
this._added = false;
364
this._anchorBox = undefined;
365
this._topLeft = undefined;
366
}
367
if (sessionEnded) {
368
this._userSize = undefined;
369
this.widget.clearContents();
370
}
371
}
372
373
placeAtAnchor(anchor: HTMLElement, preferAlignAtTop: boolean) {
374
const anchorBox = anchor.getBoundingClientRect();
375
this._anchorBox = anchorBox;
376
this._preferAlignAtTop = preferAlignAtTop;
377
this._placeAtAnchor(this._anchorBox, this._userSize ?? this.widget.size, preferAlignAtTop);
378
}
379
380
_placeAtAnchor(anchorBox: dom.IDomNodePagePosition, size: dom.Dimension, preferAlignAtTop: boolean) {
381
const bodyBox = dom.getClientArea(this.getDomNode().ownerDocument.body);
382
383
const info = this.widget.getLayoutInfo();
384
385
const defaultMinSize = new dom.Dimension(220, 2 * info.lineHeight);
386
const defaultTop = anchorBox.top;
387
388
type Placement = { top: number; left: number; fit: number; maxSizeTop: dom.Dimension; maxSizeBottom: dom.Dimension; minSize: dom.Dimension };
389
390
// EAST
391
const eastPlacement: Placement = (function () {
392
const width = bodyBox.width - (anchorBox.left + anchorBox.width + info.borderWidth + info.horizontalPadding);
393
const left = -info.borderWidth + anchorBox.left + anchorBox.width;
394
const maxSizeTop = new dom.Dimension(width, bodyBox.height - anchorBox.top - info.borderHeight - info.verticalPadding);
395
const maxSizeBottom = maxSizeTop.with(undefined, anchorBox.top + anchorBox.height - info.borderHeight - info.verticalPadding);
396
return { top: defaultTop, left, fit: width - size.width, maxSizeTop, maxSizeBottom, minSize: defaultMinSize.with(Math.min(width, defaultMinSize.width)) };
397
})();
398
399
// WEST
400
const westPlacement: Placement = (function () {
401
const width = anchorBox.left - info.borderWidth - info.horizontalPadding;
402
const left = Math.max(info.horizontalPadding, anchorBox.left - size.width - info.borderWidth);
403
const maxSizeTop = new dom.Dimension(width, bodyBox.height - anchorBox.top - info.borderHeight - info.verticalPadding);
404
const maxSizeBottom = maxSizeTop.with(undefined, anchorBox.top + anchorBox.height - info.borderHeight - info.verticalPadding);
405
return { top: defaultTop, left, fit: width - size.width, maxSizeTop, maxSizeBottom, minSize: defaultMinSize.with(Math.min(width, defaultMinSize.width)) };
406
})();
407
408
// SOUTH
409
const southPlacement: Placement = (function () {
410
const left = anchorBox.left;
411
const top = -info.borderWidth + anchorBox.top + anchorBox.height;
412
const maxSizeBottom = new dom.Dimension(anchorBox.width - info.borderHeight, bodyBox.height - anchorBox.top - anchorBox.height - info.verticalPadding);
413
return { top, left, fit: maxSizeBottom.height - size.height, maxSizeBottom, maxSizeTop: maxSizeBottom, minSize: defaultMinSize.with(maxSizeBottom.width) };
414
})();
415
416
// NORTH
417
const northPlacement: Placement = (function () {
418
const left = anchorBox.left;
419
const maxSizeTop = new dom.Dimension(anchorBox.width - info.borderHeight, anchorBox.top - info.verticalPadding);
420
const top = Math.max(info.verticalPadding, anchorBox.top - size.height);
421
return { top, left, fit: maxSizeTop.height - size.height, maxSizeTop, maxSizeBottom: maxSizeTop, minSize: defaultMinSize.with(maxSizeTop.width) };
422
})();
423
424
// take first placement that fits or the first with "least bad" fit
425
// when the suggest widget is rendering above the cursor (preferAlignAtTop=false), prefer NORTH over SOUTH
426
const verticalPlacement = preferAlignAtTop ? southPlacement : northPlacement;
427
const placements = [eastPlacement, westPlacement, verticalPlacement];
428
const placement = placements.find(p => p.fit >= 0) ?? placements.sort((a, b) => b.fit - a.fit)[0];
429
430
// top/bottom placement
431
const bottom = anchorBox.top + anchorBox.height - info.borderHeight;
432
let alignAtTop: boolean;
433
let height = size.height;
434
const maxHeight = Math.max(placement.maxSizeTop.height, placement.maxSizeBottom.height);
435
if (height > maxHeight) {
436
height = maxHeight;
437
}
438
let maxSize: dom.Dimension;
439
if (preferAlignAtTop) {
440
if (height <= placement.maxSizeTop.height) {
441
alignAtTop = true;
442
maxSize = placement.maxSizeTop;
443
} else {
444
alignAtTop = false;
445
maxSize = placement.maxSizeBottom;
446
}
447
} else {
448
if (height <= placement.maxSizeBottom.height) {
449
alignAtTop = false;
450
maxSize = placement.maxSizeBottom;
451
} else {
452
alignAtTop = true;
453
maxSize = placement.maxSizeTop;
454
}
455
}
456
457
let { top, left } = placement;
458
if (placement === northPlacement) {
459
// For NORTH placement, position the details above the anchor
460
top = anchorBox.top - height + info.borderWidth;
461
} else if (!alignAtTop && height > anchorBox.height) {
462
top = bottom - height;
463
}
464
const editorDomNode = this._editor.getDomNode();
465
if (editorDomNode) {
466
// get bounding rectangle of the suggest widget relative to the editor
467
const editorBoundingBox = editorDomNode.getBoundingClientRect();
468
top -= editorBoundingBox.top;
469
left -= editorBoundingBox.left;
470
}
471
this._applyTopLeft({ left, top });
472
473
// enableSashes(north, east, south, west)
474
// For NORTH placement: enable north sash (resize upward from top), disable south (can't resize into the anchor)
475
// Also enable west sash for horizontal resizing, consistent with SOUTH placement
476
// For SOUTH placement and EAST/WEST placements: use existing logic based on alignAtTop
477
if (placement === northPlacement) {
478
this._resizable.enableSashes(true, false, false, true);
479
} else {
480
this._resizable.enableSashes(!alignAtTop, placement === eastPlacement, alignAtTop, placement !== eastPlacement);
481
}
482
483
this._resizable.minSize = placement.minSize;
484
this._resizable.maxSize = maxSize;
485
this._resizable.layout(height, Math.min(maxSize.width, size.width));
486
this.widget.layout(this._resizable.size.width, this._resizable.size.height);
487
}
488
489
private _applyTopLeft(topLeft: TopLeftPosition): void {
490
this._topLeft = topLeft;
491
this._editor.layoutOverlayWidget(this);
492
}
493
}
494
495