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