Path: blob/main/src/vs/editor/contrib/suggest/browser/suggestWidgetDetails.ts
5303 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as dom from '../../../../base/browser/dom.js';6import { ResizableHTMLElement } from '../../../../base/browser/ui/resizable/resizable.js';7import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js';8import { Codicon } from '../../../../base/common/codicons.js';9import { Emitter, Event } from '../../../../base/common/event.js';10import { MarkdownString } from '../../../../base/common/htmlContent.js';11import { DisposableStore } from '../../../../base/common/lifecycle.js';12import { ThemeIcon } from '../../../../base/common/themables.js';13import * as nls from '../../../../nls.js';14import { isHighContrast } from '../../../../platform/theme/common/theme.js';15import { IThemeService } from '../../../../platform/theme/common/themeService.js';16import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../browser/editorBrowser.js';17import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js';18import { EditorOption } from '../../../common/config/editorOptions.js';19import { CompletionItem } from './suggest.js';2021export function canExpandCompletionItem(item: CompletionItem | undefined): boolean {22return !!item && Boolean(item.completion.documentation || item.completion.detail && item.completion.detail !== item.completion.label);23}2425export class SuggestDetailsWidget {2627readonly domNode: HTMLDivElement;2829private readonly _onDidClose = new Emitter<void>();30readonly onDidClose: Event<void> = this._onDidClose.event;3132private readonly _onDidChangeContents = new Emitter<this>();33readonly onDidChangeContents: Event<this> = this._onDidChangeContents.event;3435private readonly _close: HTMLElement;36private readonly _scrollbar: DomScrollableElement;37private readonly _body: HTMLElement;38private readonly _header: HTMLElement;39private readonly _type: HTMLElement;40private readonly _docs: HTMLElement;41private readonly _disposables = new DisposableStore();4243private readonly _renderDisposeable = new DisposableStore();44private _size = new dom.Dimension(330, 0);4546constructor(47private readonly _editor: ICodeEditor,48@IThemeService private readonly _themeService: IThemeService,49@IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService,50) {51this.domNode = dom.$('.suggest-details');52this.domNode.classList.add('no-docs');535455this._body = dom.$('.body');5657this._scrollbar = new DomScrollableElement(this._body, {58alwaysConsumeMouseWheel: true,59});60dom.append(this.domNode, this._scrollbar.getDomNode());61this._disposables.add(this._scrollbar);6263this._header = dom.append(this._body, dom.$('.header'));64this._close = dom.append(this._header, dom.$('span' + ThemeIcon.asCSSSelector(Codicon.close)));65this._close.title = nls.localize('details.close', "Close");66this._close.role = 'button';67this._close.tabIndex = -1;68this._type = dom.append(this._header, dom.$('p.type'));6970this._docs = dom.append(this._body, dom.$('p.docs'));7172this._configureFont();7374this._disposables.add(this._editor.onDidChangeConfiguration(e => {75if (e.hasChanged(EditorOption.fontInfo)) {76this._configureFont();77}78}));79}8081dispose(): void {82this._disposables.dispose();83this._renderDisposeable.dispose();84this._onDidClose.dispose();85this._onDidChangeContents.dispose();86}8788private _configureFont(): void {89const options = this._editor.getOptions();90const fontInfo = options.get(EditorOption.fontInfo);91const fontFamily = fontInfo.getMassagedFontFamily();92const fontSize = options.get(EditorOption.suggestFontSize) || fontInfo.fontSize;93const lineHeight = options.get(EditorOption.suggestLineHeight) || fontInfo.lineHeight;94const fontWeight = fontInfo.fontWeight;95const fontSizePx = `${fontSize}px`;96const lineHeightPx = `${lineHeight}px`;9798this.domNode.style.fontSize = fontSizePx;99this.domNode.style.lineHeight = `${lineHeight / fontSize}`;100this.domNode.style.fontWeight = fontWeight;101this.domNode.style.fontFeatureSettings = fontInfo.fontFeatureSettings;102this._type.style.fontFamily = fontFamily;103this._close.style.height = lineHeightPx;104this._close.style.width = lineHeightPx;105}106107getLayoutInfo() {108const lineHeight = this._editor.getOption(EditorOption.suggestLineHeight) || this._editor.getOption(EditorOption.fontInfo).lineHeight;109const borderWidth = isHighContrast(this._themeService.getColorTheme().type) ? 2 : 1;110const borderHeight = borderWidth * 2;111return {112lineHeight,113borderWidth,114borderHeight,115verticalPadding: 22,116horizontalPadding: 14117};118}119120121renderLoading(): void {122this._type.textContent = nls.localize('loading', "Loading...");123this._docs.textContent = '';124this.domNode.classList.remove('no-docs', 'no-type');125this.layout(this.size.width, this.getLayoutInfo().lineHeight * 2);126this._onDidChangeContents.fire(this);127}128129renderItem(item: CompletionItem, explainMode: boolean): void {130this._renderDisposeable.clear();131132let { detail, documentation } = item.completion;133134if (explainMode) {135let md = '';136md += `score: ${item.score[0]}\n`;137md += `prefix: ${item.word ?? '(no prefix)'}\n`;138md += `word: ${item.completion.filterText ? item.completion.filterText + ' (filterText)' : item.textLabel}\n`;139md += `distance: ${item.distance} (localityBonus-setting)\n`;140md += `index: ${item.idx}, based on ${item.completion.sortText && `sortText: "${item.completion.sortText}"` || 'label'}\n`;141md += `commit_chars: ${item.completion.commitCharacters?.join('')}\n`;142documentation = new MarkdownString().appendCodeblock('empty', md);143detail = `Provider: ${item.provider._debugDisplayName}`;144}145146if (!explainMode && !canExpandCompletionItem(item)) {147this.clearContents();148return;149}150151this.domNode.classList.remove('no-docs', 'no-type');152153// --- details154155if (detail) {156const cappedDetail = detail.length > 100000 ? `${detail.substr(0, 100000)}…` : detail;157this._type.textContent = cappedDetail;158this._type.title = cappedDetail;159dom.show(this._type);160this._type.classList.toggle('auto-wrap', !/\r?\n^\s+/gmi.test(cappedDetail));161} else {162dom.clearNode(this._type);163this._type.title = '';164dom.hide(this._type);165this.domNode.classList.add('no-type');166}167168// --- documentation169dom.clearNode(this._docs);170if (typeof documentation === 'string') {171this._docs.classList.remove('markdown-docs');172this._docs.textContent = documentation;173174} else if (documentation) {175this._docs.classList.add('markdown-docs');176dom.clearNode(this._docs);177const renderedContents = this._markdownRendererService.render(documentation, {178context: this._editor,179asyncRenderCallback: () => {180this.layout(this._size.width, this._type.clientHeight + this._docs.clientHeight);181this._onDidChangeContents.fire(this);182}183});184this._docs.appendChild(renderedContents.element);185this._renderDisposeable.add(renderedContents);186}187188this.domNode.classList.toggle('detail-and-doc', !!detail && !!documentation);189190this.domNode.style.userSelect = 'text';191this.domNode.tabIndex = -1;192193this._close.onmousedown = e => {194e.preventDefault();195e.stopPropagation();196};197this._close.onclick = e => {198e.preventDefault();199e.stopPropagation();200this._onDidClose.fire();201};202203this._body.scrollTop = 0;204205this.layout(this._size.width, this._type.clientHeight + this._docs.clientHeight);206this._onDidChangeContents.fire(this);207}208209clearContents() {210this.domNode.classList.add('no-docs');211this._type.textContent = '';212this._docs.textContent = '';213}214215get isEmpty(): boolean {216return this.domNode.classList.contains('no-docs');217}218219get size() {220return this._size;221}222223layout(width: number, height: number): void {224const newSize = new dom.Dimension(width, height);225if (!dom.Dimension.equals(newSize, this._size)) {226this._size = newSize;227dom.size(this.domNode, width, height);228}229this._scrollbar.scanDomNode();230}231232scrollDown(much = 8): void {233this._body.scrollTop += much;234}235236scrollUp(much = 8): void {237this._body.scrollTop -= much;238}239240scrollTop(): void {241this._body.scrollTop = 0;242}243244scrollBottom(): void {245this._body.scrollTop = this._body.scrollHeight;246}247248pageDown(): void {249this.scrollDown(80);250}251252pageUp(): void {253this.scrollUp(80);254}255256focus() {257this.domNode.focus();258}259}260261interface TopLeftPosition {262top: number;263left: number;264}265266export class SuggestDetailsOverlay implements IOverlayWidget {267268readonly allowEditorOverflow = true;269270private readonly _disposables = new DisposableStore();271private readonly _resizable: ResizableHTMLElement;272273private _added: boolean = false;274private _anchorBox?: dom.IDomNodePagePosition;275private _preferAlignAtTop: boolean = true;276private _userSize?: dom.Dimension;277private _topLeft?: TopLeftPosition;278279constructor(280readonly widget: SuggestDetailsWidget,281private readonly _editor: ICodeEditor282) {283284this._resizable = new ResizableHTMLElement();285this._resizable.domNode.classList.add('suggest-details-container');286this._resizable.domNode.appendChild(widget.domNode);287this._resizable.enableSashes(false, true, true, false);288289let topLeftNow: TopLeftPosition | undefined;290let sizeNow: dom.Dimension | undefined;291let deltaTop: number = 0;292let deltaLeft: number = 0;293this._disposables.add(this._resizable.onDidWillResize(() => {294topLeftNow = this._topLeft;295sizeNow = this._resizable.size;296}));297298this._disposables.add(this._resizable.onDidResize(e => {299if (topLeftNow && sizeNow) {300this.widget.layout(e.dimension.width, e.dimension.height);301302let updateTopLeft = false;303if (e.west) {304deltaLeft = sizeNow.width - e.dimension.width;305updateTopLeft = true;306}307if (e.north) {308deltaTop = sizeNow.height - e.dimension.height;309updateTopLeft = true;310}311if (updateTopLeft) {312this._applyTopLeft({313top: topLeftNow.top + deltaTop,314left: topLeftNow.left + deltaLeft,315});316}317}318if (e.done) {319topLeftNow = undefined;320sizeNow = undefined;321deltaTop = 0;322deltaLeft = 0;323this._userSize = e.dimension;324}325}));326327this._disposables.add(this.widget.onDidChangeContents(() => {328if (this._anchorBox) {329this._placeAtAnchor(this._anchorBox, this._userSize ?? this.widget.size, this._preferAlignAtTop);330}331}));332}333334dispose(): void {335this._resizable.dispose();336this._disposables.dispose();337this.hide();338}339340getId(): string {341return 'suggest.details';342}343344getDomNode(): HTMLElement {345return this._resizable.domNode;346}347348getPosition(): IOverlayWidgetPosition | null {349return this._topLeft ? { preference: this._topLeft } : null;350}351352show(): void {353if (!this._added) {354this._editor.addOverlayWidget(this);355this._added = true;356}357}358359hide(sessionEnded: boolean = false): void {360this._resizable.clearSashHoverState();361362if (this._added) {363this._editor.removeOverlayWidget(this);364this._added = false;365this._anchorBox = undefined;366this._topLeft = undefined;367}368if (sessionEnded) {369this._userSize = undefined;370this.widget.clearContents();371}372}373374placeAtAnchor(anchor: HTMLElement, preferAlignAtTop: boolean) {375const anchorBox = anchor.getBoundingClientRect();376this._anchorBox = anchorBox;377this._preferAlignAtTop = preferAlignAtTop;378this._placeAtAnchor(this._anchorBox, this._userSize ?? this.widget.size, preferAlignAtTop);379}380381_placeAtAnchor(anchorBox: dom.IDomNodePagePosition, size: dom.Dimension, preferAlignAtTop: boolean) {382const bodyBox = dom.getClientArea(this.getDomNode().ownerDocument.body);383384const info = this.widget.getLayoutInfo();385386const defaultMinSize = new dom.Dimension(220, 2 * info.lineHeight);387const defaultTop = anchorBox.top;388389type Placement = { top: number; left: number; fit: number; maxSizeTop: dom.Dimension; maxSizeBottom: dom.Dimension; minSize: dom.Dimension };390391// EAST392const eastPlacement: Placement = (function () {393const width = bodyBox.width - (anchorBox.left + anchorBox.width + info.borderWidth + info.horizontalPadding);394const left = -info.borderWidth + anchorBox.left + anchorBox.width;395const maxSizeTop = new dom.Dimension(width, bodyBox.height - anchorBox.top - info.borderHeight - info.verticalPadding);396const maxSizeBottom = maxSizeTop.with(undefined, anchorBox.top + anchorBox.height - info.borderHeight - info.verticalPadding);397return { top: defaultTop, left, fit: width - size.width, maxSizeTop, maxSizeBottom, minSize: defaultMinSize.with(Math.min(width, defaultMinSize.width)) };398})();399400// WEST401const westPlacement: Placement = (function () {402const width = anchorBox.left - info.borderWidth - info.horizontalPadding;403const left = Math.max(info.horizontalPadding, anchorBox.left - size.width - info.borderWidth);404const maxSizeTop = new dom.Dimension(width, bodyBox.height - anchorBox.top - info.borderHeight - info.verticalPadding);405const maxSizeBottom = maxSizeTop.with(undefined, anchorBox.top + anchorBox.height - info.borderHeight - info.verticalPadding);406return { top: defaultTop, left, fit: width - size.width, maxSizeTop, maxSizeBottom, minSize: defaultMinSize.with(Math.min(width, defaultMinSize.width)) };407})();408409// SOUTH410const southPlacement: Placement = (function () {411const left = anchorBox.left;412const top = -info.borderWidth + anchorBox.top + anchorBox.height;413const maxSizeBottom = new dom.Dimension(anchorBox.width - info.borderHeight, bodyBox.height - anchorBox.top - anchorBox.height - info.verticalPadding);414return { top, left, fit: maxSizeBottom.height - size.height, maxSizeBottom, maxSizeTop: maxSizeBottom, minSize: defaultMinSize.with(maxSizeBottom.width) };415})();416417// NORTH418const northPlacement: Placement = (function () {419const left = anchorBox.left;420const maxSizeTop = new dom.Dimension(anchorBox.width - info.borderHeight, anchorBox.top - info.verticalPadding);421const top = Math.max(info.verticalPadding, anchorBox.top - size.height);422return { top, left, fit: maxSizeTop.height - size.height, maxSizeTop, maxSizeBottom: maxSizeTop, minSize: defaultMinSize.with(maxSizeTop.width) };423})();424425// take first placement that fits or the first with "least bad" fit426// when the suggest widget is rendering above the cursor (preferAlignAtTop=false), prefer NORTH over SOUTH427const verticalPlacement = preferAlignAtTop ? southPlacement : northPlacement;428const placements = [eastPlacement, westPlacement, verticalPlacement];429const placement = placements.find(p => p.fit >= 0) ?? placements.sort((a, b) => b.fit - a.fit)[0];430431// top/bottom placement432const bottom = anchorBox.top + anchorBox.height - info.borderHeight;433let alignAtTop: boolean;434let height = size.height;435const maxHeight = Math.max(placement.maxSizeTop.height, placement.maxSizeBottom.height);436if (height > maxHeight) {437height = maxHeight;438}439let maxSize: dom.Dimension;440if (preferAlignAtTop) {441if (height <= placement.maxSizeTop.height) {442alignAtTop = true;443maxSize = placement.maxSizeTop;444} else {445alignAtTop = false;446maxSize = placement.maxSizeBottom;447}448} else {449if (height <= placement.maxSizeBottom.height) {450alignAtTop = false;451maxSize = placement.maxSizeBottom;452} else {453alignAtTop = true;454maxSize = placement.maxSizeTop;455}456}457458let { top, left } = placement;459if (placement === northPlacement) {460// For NORTH placement, position the details above the anchor461top = anchorBox.top - height + info.borderWidth;462} else if (!alignAtTop && height > anchorBox.height) {463top = bottom - height;464}465const editorDomNode = this._editor.getDomNode();466if (editorDomNode) {467// get bounding rectangle of the suggest widget relative to the editor468const editorBoundingBox = editorDomNode.getBoundingClientRect();469top -= editorBoundingBox.top;470left -= editorBoundingBox.left;471}472this._applyTopLeft({ left, top });473474// enableSashes(north, east, south, west)475// For NORTH placement: enable north sash (resize upward from top), disable south (can't resize into the anchor)476// Also enable west sash for horizontal resizing, consistent with SOUTH placement477// For SOUTH placement and EAST/WEST placements: use existing logic based on alignAtTop478if (placement === northPlacement) {479this._resizable.enableSashes(true, false, false, true);480} else {481this._resizable.enableSashes(!alignAtTop, placement === eastPlacement, alignAtTop, placement !== eastPlacement);482}483484this._resizable.minSize = placement.minSize;485this._resizable.maxSize = maxSize;486this._resizable.layout(height, Math.min(maxSize.width, size.width));487this.widget.layout(this._resizable.size.width, this._resizable.size.height);488}489490private _applyTopLeft(topLeft: TopLeftPosition): void {491this._topLeft = topLeft;492this._editor.layoutOverlayWidget(this);493}494}495496497