diff --git a/src/vs/base/browser/ui/hover/hover.css b/src/vs/base/browser/ui/hover/hover.css index f3058d6c10709..b9b27186be9f1 100644 --- a/src/vs/base/browser/ui/hover/hover.css +++ b/src/vs/base/browser/ui/hover/hover.css @@ -3,6 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.resizable-hover { + z-index: 50; +} + .monaco-hover { cursor: default; position: absolute; @@ -28,7 +32,6 @@ } .monaco-hover .markdown-hover > .hover-contents:not(.code-hover-contents) { - max-width: 500px; word-wrap: break-word; } diff --git a/src/vs/editor/contrib/hover/browser/contentHover.ts b/src/vs/editor/contrib/hover/browser/contentHover.ts index d2281bbec91e4..515c4f64373fc 100644 --- a/src/vs/editor/contrib/hover/browser/contentHover.ts +++ b/src/vs/editor/contrib/hover/browser/contentHover.ts @@ -9,7 +9,7 @@ import { coalesce } from 'vs/base/common/arrays'; import { CancellationToken } from 'vs/base/common/cancellation'; import { KeyCode } from 'vs/base/common/keyCodes'; import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; -import { ContentWidgetPositionPreference, IActiveCodeEditor, ICodeEditor, IContentWidget, IContentWidgetPosition, IEditorMouseEvent, MouseTargetType } from 'vs/editor/browser/editorBrowser'; +import { ContentWidgetPositionPreference, IActiveCodeEditor, ICodeEditor, IEditorMouseEvent, MouseTargetType } from 'vs/editor/browser/editorBrowser'; import { ConfigurationChangedEvent, EditorOption } from 'vs/editor/common/config/editorOptions'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; @@ -18,19 +18,18 @@ import { ModelDecorationOptions } from 'vs/editor/common/model/textModel'; import { TokenizationRegistry } from 'vs/editor/common/languages'; import { HoverOperation, HoverStartMode, HoverStartSource, IHoverComputer } from 'vs/editor/contrib/hover/browser/hoverOperation'; import { HoverAnchor, HoverAnchorType, HoverParticipantRegistry, HoverRangeAnchor, IEditorHoverColorPickerWidget, IEditorHoverAction, IEditorHoverParticipant, IEditorHoverRenderContext, IEditorHoverStatusBar, IHoverPart } from 'vs/editor/contrib/hover/browser/hoverTypes'; -import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; -import { Context as SuggestContext } from 'vs/editor/contrib/suggest/browser/suggest'; import { AsyncIterableObject } from 'vs/base/common/async'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; - +import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { MultiplePersistedSizeResizableContentWidget } from 'vs/editor/contrib/hover/browser/resizableContentWidget'; const $ = dom.$; export class ContentHoverController extends Disposable { private readonly _participants: IEditorHoverParticipant[]; - private readonly _widget = this._register(this._instantiationService.createInstance(ContentHoverWidget, this._editor)); + private readonly _widget = this._register(this._instantiationService.createInstance(ResizableHoverWidget, this._editor)); private readonly _computer: ContentHoverComputer; private readonly _hoverOperation: HoverOperation; @@ -68,16 +67,22 @@ export class ContentHoverController extends Disposable { })); this._register(TokenizationRegistry.onDidChange(() => { if (this._widget.position && this._currentResult) { - this._widget.clear(); this._setCurrentResult(this._currentResult); // render again } })); } + get widget() { + return this._widget; + } + /** * Returns true if the hover shows now or will show. */ public maybeShowAt(mouseEvent: IEditorMouseEvent): boolean { + if (this._widget.isResizing) { + return true; + } const anchorCandidates: HoverAnchor[] = []; for (const participant of this._participants) { @@ -289,7 +294,7 @@ export class ContentHoverController extends Disposable { })); } - this._widget.showAt(fragment, new ContentHoverVisibleData( + this._widget.showAt(fragment, new HoverData( colorPicker, showAtPosition, showAtSecondaryPosition, @@ -384,6 +389,10 @@ export class ContentHoverController extends Disposable { public escape(): void { this._widget.escape(); } + + public clearPersistedSizes(): void { + this._widget?.clearPersistedSizes(); + } } class HoverResult { @@ -419,7 +428,7 @@ class FilteredHoverResult extends HoverResult { } } -class ContentHoverVisibleData { +class HoverData { public closestMouseDistance: number | undefined = undefined; @@ -437,32 +446,28 @@ class ContentHoverVisibleData { ) { } } -export class ContentHoverWidget extends Disposable implements IContentWidget { +const HORIZONTAL_SCROLLING_BY = 30; +const SCROLLBAR_WIDTH = 10; +const SASH_WIDTH_MINUS_BORDER = 3; - static readonly ID = 'editor.contrib.contentHoverWidget'; +export class ResizableHoverWidget extends MultiplePersistedSizeResizableContentWidget { - public readonly allowEditorOverflow = true; + public static ID = 'editor.contrib.resizableContentHoverWidget'; - private readonly _hoverVisibleKey = EditorContextKeys.hoverVisible.bindTo(this._contextKeyService); - private readonly _hoverFocusedKey = EditorContextKeys.hoverFocused.bindTo(this._contextKeyService); - private readonly _hover: HoverWidget = this._register(new HoverWidget()); - private readonly _focusTracker = this._register(dom.trackFocus(this.getDomNode())); - private readonly _horizontalScrollingBy: number = 30; - private _visibleData: ContentHoverVisibleData | null = null; + private _disposableStore = new DisposableStore(); + private _hoverData: HoverData | undefined; + private _positionPreference: ContentWidgetPositionPreference | undefined; - /** - * Returns `null` if the hover is not visible. - */ - public get position(): Position | null { - return this._visibleData?.showAtPosition ?? null; - } + private readonly _hoverWidget: HoverWidget = this._disposableStore.add(new HoverWidget()); + private readonly _hoverVisibleKey: IContextKey; + private readonly _hoverFocusedKey: IContextKey; public get isColorPickerVisible(): boolean { - return Boolean(this._visibleData?.colorPicker); + return Boolean(this._hoverData?.colorPicker); } public get isVisibleFromKeyboard(): boolean { - return (this._visibleData?.source === HoverStartSource.Keyboard); + return (this._hoverData?.source === HoverStartSource.Keyboard); } public get isVisible(): boolean { @@ -470,219 +475,375 @@ export class ContentHoverWidget extends Disposable implements IContentWidget { } constructor( - private readonly _editor: ICodeEditor, - @IContextKeyService private readonly _contextKeyService: IContextKeyService, + _editor: ICodeEditor, + @IContextKeyService _contextKeyService: IContextKeyService ) { - super(); + super(_editor); + this._hoverVisibleKey = EditorContextKeys.hoverVisible.bindTo(_contextKeyService); + this._hoverFocusedKey = EditorContextKeys.hoverFocused.bindTo(_contextKeyService); - this._register(this._editor.onDidLayoutChange(() => this._layout())); - this._register(this._editor.onDidChangeConfiguration((e: ConfigurationChangedEvent) => { + dom.append(this._resizableNode.domNode, this._hoverWidget.containerDomNode); + this._resizableNode.domNode.classList.add('resizable-hover'); + + this._disposableStore.add(this._editor.onDidLayoutChange(() => this._layout())); + this._disposableStore.add(this._editor.onDidChangeConfiguration((e: ConfigurationChangedEvent) => { if (e.hasChanged(EditorOption.fontInfo)) { this._updateFont(); } })); - - this._setVisibleData(null); - this._layout(); - this._editor.addContentWidget(this); - - this._register(this._focusTracker.onDidFocus(() => { + const focusTracker = this._disposableStore.add(dom.trackFocus(this._resizableNode.domNode)); + this._disposableStore.add(focusTracker.onDidFocus(() => { this._hoverFocusedKey.set(true); })); - this._register(this._focusTracker.onDidBlur(() => { + this._disposableStore.add(focusTracker.onDidBlur(() => { this._hoverFocusedKey.set(false); })); + this._setHoverData(undefined); + this._layout(); } public override dispose(): void { - this._editor.removeContentWidget(this); - if (this._visibleData) { - this._visibleData.disposables.dispose(); - } super.dispose(); + this._hoverData?.disposables.dispose(); + this._disposableStore.dispose(); + this._editor.removeContentWidget(this); } public getId(): string { - return ContentHoverWidget.ID; + return ResizableHoverWidget.ID; + } + + private _setDimensions(container: HTMLElement, width: number | string, height: number | string) { + const transformedWidth = typeof width === 'number' ? `${width}px` : width; + const transformedHeight = typeof height === 'number' ? `${height}px` : height; + container.style.width = transformedWidth; + container.style.height = transformedHeight; + } + + private _setContentsDomNodeDimensions(width: number | string, height: number | string) { + const contentsDomNode = this._hoverWidget.contentsDomNode; + return this._setDimensions(contentsDomNode, width, height); + } + + private _setContainerDomNodeDimensions(width: number | string, height: number | string) { + const containerDomNode = this._hoverWidget.containerDomNode; + return this._setDimensions(containerDomNode, width, height); + } + + private _setHoverWidgetDimensions(width: number | string, height: number | string) { + this._setContentsDomNodeDimensions(width, height); + this._setContainerDomNodeDimensions(width, height); + this._layoutContentWidget(); + } + + private _setContentsDomNodeMaxDimensions(width: number | string, height: number | string) { + const transformedWidth = typeof width === 'number' ? `${width}px` : width; + const transformedHeight = typeof height === 'number' ? `${height}px` : height; + const contentsDomNode = this._hoverWidget.contentsDomNode; + contentsDomNode.style.maxWidth = transformedWidth; + contentsDomNode.style.maxHeight = transformedHeight; } - public getDomNode(): HTMLElement { - return this._hover.containerDomNode; + private _hasHorizontalScrollbar(): boolean { + const scrollDimensions = this._hoverWidget.scrollbar.getScrollDimensions(); + const hasHorizontalScrollbar = scrollDimensions.scrollWidth > scrollDimensions.width; + return hasHorizontalScrollbar; } - public getPosition(): IContentWidgetPosition | null { - if (!this._visibleData) { - return null; + private _adjustContentsBottomPadding() { + const contentsDomNode = this._hoverWidget.contentsDomNode; + const extraBottomPadding = `${this._hoverWidget.scrollbar.options.horizontalScrollbarSize}px`; + if (contentsDomNode.style.paddingBottom !== extraBottomPadding) { + contentsDomNode.style.paddingBottom = extraBottomPadding; } - let preferAbove = this._visibleData.preferAbove; - if (!preferAbove && this._contextKeyService.getContextKeyValue(SuggestContext.Visible.key)) { - // Prefer rendering above if the suggest widget is visible - preferAbove = true; + } + + private _setAdjustedHoverWidgetDimensions(size: dom.Dimension): void { + this._setContentsDomNodeMaxDimensions('none', 'none'); + const width = size.width - 2 * SASH_WIDTH_MINUS_BORDER; + const height = size.height - 2 * SASH_WIDTH_MINUS_BORDER; + this._setHoverWidgetDimensions(width, height); + // measure if widget has horizontal scrollbar after setting the dimensions + if (this._hasHorizontalScrollbar()) { + this._adjustContentsBottomPadding(); + this._setContentsDomNodeDimensions(width, height - SCROLLBAR_WIDTH); } + } - // :before content can align left of the text content - const affinity = this._visibleData.isBeforeContent ? PositionAffinity.LeftOfInjectedText : undefined; + private _setResizableNodeMaxDimensions() { + const maxRenderingWidth = this._findMaximumRenderingWidth() ?? Infinity; + const maxRenderingHeight = this._findMaximumRenderingHeight() ?? Infinity; + this._resizableNode.maxSize = new dom.Dimension(maxRenderingWidth, maxRenderingHeight); + } - return { - position: this._visibleData.showAtPosition, - secondaryPosition: this._visibleData.showAtSecondaryPosition, - preference: ( - preferAbove - ? [ContentWidgetPositionPreference.ABOVE, ContentWidgetPositionPreference.BELOW] - : [ContentWidgetPositionPreference.BELOW, ContentWidgetPositionPreference.ABOVE] - ), - positionAffinity: affinity - }; + override _resize(size: dom.Dimension) { + this._setAdjustedHoverWidgetDimensions(size); + this._setResizableNodeMaxDimensions(); + this._hoverWidget.scrollbar.scanDomNode(); + this._editor.layoutContentWidget(this); + } + + private _findAvailableSpaceVertically(): number | undefined { + const position = this._hoverData?.showAtPosition; + if (!position) { + return; + } + return this._positionPreference === ContentWidgetPositionPreference.ABOVE ? this._availableVerticalSpaceAbove(position) : this._availableVerticalSpaceBelow(position); + } + + private _findAvailableSpaceHorizontally(): number | undefined { + return this._findMaximumRenderingWidth(); + } + + private _findMaximumRenderingHeight(): number | undefined { + const availableSpace = this._findAvailableSpaceVertically(); + if (!availableSpace) { + return; + } + let maximumHeight = 3 * SASH_WIDTH_MINUS_BORDER; + Array.from(this._hoverWidget.contentsDomNode.children).forEach((hoverPart) => { + maximumHeight += hoverPart.clientHeight; + }); + if (this._hasHorizontalScrollbar()) { + maximumHeight += SCROLLBAR_WIDTH; + } + return Math.min(availableSpace, maximumHeight); + } + + private _findMaximumRenderingWidth(): number | undefined { + if (!this._editor || !this._editor.hasModel()) { + return; + } + const editorBox = dom.getDomNodePagePosition(this._editor.getDomNode()); + const glyphMarginWidth = this._editor.getLayoutInfo().glyphMarginWidth; + const leftOfContainer = this._hoverWidget.containerDomNode.offsetLeft; + return editorBox.width + editorBox.left - leftOfContainer - glyphMarginWidth; } public isMouseGettingCloser(posx: number, posy: number): boolean { - if (!this._visibleData) { + if (!this._hoverData) { return false; } - if (typeof this._visibleData.initialMousePosX === 'undefined' || typeof this._visibleData.initialMousePosY === 'undefined') { - this._visibleData.initialMousePosX = posx; - this._visibleData.initialMousePosY = posy; + if (typeof this._hoverData.initialMousePosX === 'undefined' || typeof this._hoverData.initialMousePosY === 'undefined') { + this._hoverData.initialMousePosX = posx; + this._hoverData.initialMousePosY = posy; return false; } const widgetRect = dom.getDomNodePagePosition(this.getDomNode()); - if (typeof this._visibleData.closestMouseDistance === 'undefined') { - this._visibleData.closestMouseDistance = computeDistanceFromPointToRectangle(this._visibleData.initialMousePosX, this._visibleData.initialMousePosY, widgetRect.left, widgetRect.top, widgetRect.width, widgetRect.height); + if (typeof this._hoverData.closestMouseDistance === 'undefined') { + this._hoverData.closestMouseDistance = computeDistanceFromPointToRectangle(this._hoverData.initialMousePosX, this._hoverData.initialMousePosY, widgetRect.left, widgetRect.top, widgetRect.width, widgetRect.height); } const distance = computeDistanceFromPointToRectangle(posx, posy, widgetRect.left, widgetRect.top, widgetRect.width, widgetRect.height); - if (distance > this._visibleData.closestMouseDistance + 4 /* tolerance of 4 pixels */) { + if (distance > this._hoverData.closestMouseDistance + 4 /* tolerance of 4 pixels */) { // The mouse is getting farther away return false; } - this._visibleData.closestMouseDistance = Math.min(this._visibleData.closestMouseDistance, distance); + this._hoverData.closestMouseDistance = Math.min(this._hoverData.closestMouseDistance, distance); return true; } - private _setVisibleData(visibleData: ContentHoverVisibleData | null): void { - if (this._visibleData) { - this._visibleData.disposables.dispose(); - } - this._visibleData = visibleData; - this._hoverVisibleKey.set(!!this._visibleData); - this._hover.containerDomNode.classList.toggle('hidden', !this._visibleData); + private _setWidgetPosition(position: Position | undefined) { + this._position = position; + } + + private _setHoverData(hoverData: HoverData | undefined): void { + this._setWidgetPosition(hoverData?.showAtPosition); + this._hoverData?.disposables.dispose(); + this._hoverData = hoverData; + this._hoverVisibleKey.set(!!hoverData); + this._hoverWidget.containerDomNode.classList.toggle('hidden', !hoverData); } private _layout(): void { const height = Math.max(this._editor.getLayoutInfo().height / 4, 250); const { fontSize, lineHeight } = this._editor.getOption(EditorOption.fontInfo); - - this._hover.contentsDomNode.style.fontSize = `${fontSize}px`; - this._hover.contentsDomNode.style.lineHeight = `${lineHeight / fontSize}`; - this._hover.contentsDomNode.style.maxHeight = `${height}px`; - this._hover.contentsDomNode.style.maxWidth = `${Math.max(this._editor.getLayoutInfo().width * 0.66, 500)}px`; + const contentsDomNode = this._hoverWidget.contentsDomNode; + contentsDomNode.style.fontSize = `${fontSize}px`; + contentsDomNode.style.lineHeight = `${lineHeight / fontSize}`; + this._setContentsDomNodeMaxDimensions(Math.max(this._editor.getLayoutInfo().width * 0.66, 500), height); } private _updateFont(): void { - const codeClasses: HTMLElement[] = Array.prototype.slice.call(this._hover.contentsDomNode.getElementsByClassName('code')); + const codeClasses: HTMLElement[] = Array.prototype.slice.call(this._hoverWidget.contentsDomNode.getElementsByClassName('code')); codeClasses.forEach(node => this._editor.applyFontInfo(node)); } - public showAt(node: DocumentFragment, visibleData: ContentHoverVisibleData): void { - this._setVisibleData(visibleData); + private _updateContent(node: DocumentFragment): void { + const contentsDomNode = this._hoverWidget.contentsDomNode; + contentsDomNode.style.paddingBottom = ''; + contentsDomNode.textContent = ''; + contentsDomNode.appendChild(node); + } - this._hover.contentsDomNode.textContent = ''; - this._hover.contentsDomNode.appendChild(node); - this._hover.contentsDomNode.style.paddingBottom = ''; - this._updateFont(); + private _getWidgetHeight(): number { + const containerDomNode = this._hoverWidget.containerDomNode; + const persistedSize = this.findPersistedSize(); + return persistedSize ? persistedSize.height : containerDomNode.clientHeight + 2 * SASH_WIDTH_MINUS_BORDER; + } - this.onContentsChanged(); + private _layoutContentWidget(): void { + this._editor.layoutContentWidget(this); + this._hoverWidget.onContentsChanged(); + } + private _updateContentsDomNodeMaxDimensions() { + const persistedSize = this.findPersistedSize(); + const width = persistedSize ? 'none' : Math.max(this._editor.getLayoutInfo().width * 0.66, 500); + const height = persistedSize ? 'none' : Math.max(this._editor.getLayoutInfo().height / 4, 250); + this._setContentsDomNodeMaxDimensions(width, height); + } + + private _render(node: DocumentFragment, hoverData: HoverData) { + if (!this._hoverVisibleKey.get()) { + this._editor.addContentWidget(this); + } + this._setHoverData(hoverData); + this._updateFont(); + this._updateContent(node); + this._updateContentsDomNodeMaxDimensions(); + this.onContentsChanged(); // Simply force a synchronous render on the editor // such that the widget does not really render with left = '0px' this._editor.render(); + } + + private _setContentPosition(hoverData: HoverData, preference?: ContentWidgetPositionPreference) { + this._contentPosition = { + position: hoverData.showAtPosition, + secondaryPosition: hoverData.showAtSecondaryPosition, + positionAffinity: hoverData.isBeforeContent ? PositionAffinity.LeftOfInjectedText : undefined, + preference: [preference ?? ContentWidgetPositionPreference.ABOVE] + }; + } + + public showAt(node: DocumentFragment, hoverData: HoverData): void { + if (!this._editor || !this._editor.hasModel()) { + return; + } + this._setContentPosition(hoverData); + this._render(node, hoverData); + const widgetHeight = this._getWidgetHeight(); + const widgetPosition = hoverData.showAtPosition; + this._positionPreference = this._findPositionPreference(widgetHeight, widgetPosition) ?? ContentWidgetPositionPreference.ABOVE; + this._setContentPosition(hoverData, this._positionPreference); // See https://github.com/microsoft/vscode/issues/140339 // TODO: Doing a second layout of the hover after force rendering the editor this.onContentsChanged(); - - if (visibleData.stoleFocus) { - this._hover.containerDomNode.focus(); + if (hoverData.stoleFocus) { + this._hoverWidget.containerDomNode.focus(); } - visibleData.colorPicker?.layout(); + hoverData.colorPicker?.layout(); } public hide(): void { - if (this._visibleData) { - const stoleFocus = this._visibleData.stoleFocus; - this._setVisibleData(null); - this._hoverFocusedKey.set(false); - this._editor.layoutContentWidget(this); - if (stoleFocus) { - this._editor.focus(); - } + if (!this._hoverData) { + return; + } + this._setHoverData(undefined); + this._resizableNode.maxSize = new dom.Dimension(Infinity, Infinity); + this._resizableNode.clearSashHoverState(); + this._editor.removeContentWidget(this); + this._hoverFocusedKey.set(false); + this._editor.layoutContentWidget(this); + if (this._hoverData.stoleFocus) { + this._editor.focus(); } } - public onContentsChanged(): void { - this._editor.layoutContentWidget(this); - this._hover.onContentsChanged(); - - const scrollDimensions = this._hover.scrollbar.getScrollDimensions(); - const hasHorizontalScrollbar = (scrollDimensions.scrollWidth > scrollDimensions.width); - if (hasHorizontalScrollbar) { - // There is just a horizontal scrollbar - const extraBottomPadding = `${this._hover.scrollbar.options.horizontalScrollbarSize}px`; - if (this._hover.contentsDomNode.style.paddingBottom !== extraBottomPadding) { - this._hover.contentsDomNode.style.paddingBottom = extraBottomPadding; - this._editor.layoutContentWidget(this); - this._hover.onContentsChanged(); - } + private _setPersistedHoverDimensionsOrRenderNormally(): void { + let width: number | string; + let height: number | string; + const persistedSize = this.findPersistedSize(); + // Suppose a persisted size is defined + if (persistedSize) { + const totalBorderWidth = 2 * SASH_WIDTH_MINUS_BORDER; + width = Math.min(this._findAvailableSpaceHorizontally() ?? Infinity, persistedSize.width - totalBorderWidth); + height = Math.min(this._findAvailableSpaceVertically() ?? Infinity, persistedSize.height - totalBorderWidth); + } else { + // Added because otherwise the initial size of the hover content is smaller than should be + const layoutInfo = this._editor.getLayoutInfo(); + this._resizableNode.layout(layoutInfo.height, layoutInfo.width); + width = 'auto'; + height = 'auto'; } + this._setHoverWidgetDimensions(width, height); } - public clear(): void { - this._hover.contentsDomNode.textContent = ''; + private _setContainerAbsolutePosition(top: number, left: number): void { + const containerDomNode = this._hoverWidget.containerDomNode; + containerDomNode.style.top = top + 'px'; + containerDomNode.style.left = left + 'px'; + } + + private _adjustHoverHeightForScrollbar(height: number) { + const containerDomNode = this._hoverWidget.containerDomNode; + const contentsDomNode = this._hoverWidget.contentsDomNode; + const maxRenderingHeight = this._findMaximumRenderingHeight() ?? Infinity; + this._setContainerDomNodeDimensions(containerDomNode.clientWidth, Math.min(maxRenderingHeight, height)); + this._setContentsDomNodeDimensions(contentsDomNode.clientWidth, Math.min(maxRenderingHeight, height - SCROLLBAR_WIDTH)); + } + + public onContentsChanged(): void { + this._setPersistedHoverDimensionsOrRenderNormally(); + const containerDomNode = this._hoverWidget.containerDomNode; + const clientHeight = containerDomNode.clientHeight; + const clientWidth = containerDomNode.clientWidth; + const totalBorderWidth = 2 * SASH_WIDTH_MINUS_BORDER; + this._resizableNode.layout(clientHeight + totalBorderWidth, clientWidth + totalBorderWidth); + this._setContainerAbsolutePosition(SASH_WIDTH_MINUS_BORDER - 1, SASH_WIDTH_MINUS_BORDER - 1); + if (this._hasHorizontalScrollbar()) { + this._adjustContentsBottomPadding(); + this._adjustHoverHeightForScrollbar(clientHeight); + } + this._layoutContentWidget(); } public focus(): void { - this._hover.containerDomNode.focus(); + this._hoverWidget.containerDomNode.focus(); } public scrollUp(): void { - const scrollTop = this._hover.scrollbar.getScrollPosition().scrollTop; + const scrollTop = this._hoverWidget.scrollbar.getScrollPosition().scrollTop; const fontInfo = this._editor.getOption(EditorOption.fontInfo); - this._hover.scrollbar.setScrollPosition({ scrollTop: scrollTop - fontInfo.lineHeight }); + this._hoverWidget.scrollbar.setScrollPosition({ scrollTop: scrollTop - fontInfo.lineHeight }); } public scrollDown(): void { - const scrollTop = this._hover.scrollbar.getScrollPosition().scrollTop; + const scrollTop = this._hoverWidget.scrollbar.getScrollPosition().scrollTop; const fontInfo = this._editor.getOption(EditorOption.fontInfo); - this._hover.scrollbar.setScrollPosition({ scrollTop: scrollTop + fontInfo.lineHeight }); + this._hoverWidget.scrollbar.setScrollPosition({ scrollTop: scrollTop + fontInfo.lineHeight }); } public scrollLeft(): void { - const scrollLeft = this._hover.scrollbar.getScrollPosition().scrollLeft; - this._hover.scrollbar.setScrollPosition({ scrollLeft: scrollLeft - this._horizontalScrollingBy }); + const scrollLeft = this._hoverWidget.scrollbar.getScrollPosition().scrollLeft; + this._hoverWidget.scrollbar.setScrollPosition({ scrollLeft: scrollLeft - HORIZONTAL_SCROLLING_BY }); } public scrollRight(): void { - const scrollLeft = this._hover.scrollbar.getScrollPosition().scrollLeft; - this._hover.scrollbar.setScrollPosition({ scrollLeft: scrollLeft + this._horizontalScrollingBy }); + const scrollLeft = this._hoverWidget.scrollbar.getScrollPosition().scrollLeft; + this._hoverWidget.scrollbar.setScrollPosition({ scrollLeft: scrollLeft + HORIZONTAL_SCROLLING_BY }); } public pageUp(): void { - const scrollTop = this._hover.scrollbar.getScrollPosition().scrollTop; - const scrollHeight = this._hover.scrollbar.getScrollDimensions().height; - this._hover.scrollbar.setScrollPosition({ scrollTop: scrollTop - scrollHeight }); + const scrollTop = this._hoverWidget.scrollbar.getScrollPosition().scrollTop; + const scrollHeight = this._hoverWidget.scrollbar.getScrollDimensions().height; + this._hoverWidget.scrollbar.setScrollPosition({ scrollTop: scrollTop - scrollHeight }); } public pageDown(): void { - const scrollTop = this._hover.scrollbar.getScrollPosition().scrollTop; - const scrollHeight = this._hover.scrollbar.getScrollDimensions().height; - this._hover.scrollbar.setScrollPosition({ scrollTop: scrollTop + scrollHeight }); + const scrollTop = this._hoverWidget.scrollbar.getScrollPosition().scrollTop; + const scrollHeight = this._hoverWidget.scrollbar.getScrollDimensions().height; + this._hoverWidget.scrollbar.setScrollPosition({ scrollTop: scrollTop + scrollHeight }); } public goToTop(): void { - this._hover.scrollbar.setScrollPosition({ scrollTop: 0 }); + this._hoverWidget.scrollbar.setScrollPosition({ scrollTop: 0 }); } public goToBottom(): void { - this._hover.scrollbar.setScrollPosition({ scrollTop: this._hover.scrollbar.getScrollDimensions().scrollHeight }); + this._hoverWidget.scrollbar.setScrollPosition({ scrollTop: this._hoverWidget.scrollbar.getScrollDimensions().scrollHeight }); } public escape(): void { diff --git a/src/vs/editor/contrib/hover/browser/hover.ts b/src/vs/editor/contrib/hover/browser/hover.ts index 05773a2dcf263..cc90e8fabacd2 100644 --- a/src/vs/editor/contrib/hover/browser/hover.ts +++ b/src/vs/editor/contrib/hover/browser/hover.ts @@ -15,9 +15,8 @@ import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { ILanguageService } from 'vs/editor/common/languages/language'; import { GotoDefinitionAtPositionEditorContribution } from 'vs/editor/contrib/gotoSymbol/browser/link/goToDefinitionAtPosition'; import { HoverStartMode, HoverStartSource } from 'vs/editor/contrib/hover/browser/hoverOperation'; -import { ContentHoverWidget, ContentHoverController } from 'vs/editor/contrib/hover/browser/contentHover'; +import { ResizableHoverWidget, ContentHoverController } from 'vs/editor/contrib/hover/browser/contentHover'; import { MarginHoverWidget } from 'vs/editor/contrib/hover/browser/marginHover'; -import * as nls from 'vs/nls'; import { AccessibilitySupport } from 'vs/platform/accessibility/common/accessibility'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; @@ -30,6 +29,7 @@ import { MarkerHoverParticipant } from 'vs/editor/contrib/hover/browser/markerHo import { InlineSuggestionHintsContentWidget } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ResultKind } from 'vs/platform/keybinding/common/keybindingResolver'; +import * as nls from 'vs/nls'; import 'vs/css!./hover'; export class ModesHoverController implements IEditorContribution { @@ -108,7 +108,7 @@ export class ModesHoverController implements IEditorContribution { const target = mouseEvent.target; - if (target.type === MouseTargetType.CONTENT_WIDGET && target.detail === ContentHoverWidget.ID) { + if (target.type === MouseTargetType.CONTENT_WIDGET && target.detail === ResizableHoverWidget.ID) { this._hoverClicked = true; // mouse down on top of content hover widget return; @@ -146,7 +146,7 @@ export class ModesHoverController implements IEditorContribution { return; } - if (this._isHoverSticky && target.type === MouseTargetType.CONTENT_WIDGET && target.detail === ContentHoverWidget.ID) { + if (this._isHoverSticky && target.type === MouseTargetType.CONTENT_WIDGET && target.detail === ResizableHoverWidget.ID) { // mouse moved on top of content hover widget return; } @@ -157,15 +157,15 @@ export class ModesHoverController implements IEditorContribution { } if ( - !this._isHoverSticky && target.type === MouseTargetType.CONTENT_WIDGET && target.detail === ContentHoverWidget.ID + !this._isHoverSticky && target.type === MouseTargetType.CONTENT_WIDGET && target.detail === ResizableHoverWidget.ID && this._contentWidget?.isColorPickerVisible() ) { // though the hover is not sticky, the color picker needs to. return; } - if (this._isHoverSticky && target.type === MouseTargetType.OVERLAY_WIDGET && target.detail === MarginHoverWidget.ID) { - // mouse moved on top of overlay hover widget + if (target.type === MouseTargetType.OVERLAY_WIDGET && target.detail === MarginHoverWidget.ID) { + // mouse moved on top of overlay margin hover widget return; } @@ -222,7 +222,9 @@ export class ModesHoverController implements IEditorContribution { this._hoverClicked = false; this._glyphWidget?.hide(); - this._contentWidget?.hide(); + if (!this._contentWidget?.widget.isResizing) { + this._contentWidget?.hide(); + } } private _getOrCreateContentWidget(): ContentHoverController { @@ -284,6 +286,10 @@ export class ModesHoverController implements IEditorContribution { return this._contentWidget?.isVisible(); } + public clearPersistedSizes(): void { + this._contentWidget?.clearPersistedSizes(); + } + public dispose(): void { this._unhookEvents(); this._toUnhook.dispose(); @@ -669,6 +675,31 @@ class EscapeFocusHoverAction extends EditorAction { } } +class ClearPersistedHoverSizes extends EditorAction { + + constructor() { + super({ + id: 'editor.action.clearPersistedHoverSizes', + label: nls.localize({ + key: 'clearPersistedHoverSizes', + comment: [ + 'Action that allows to clear the persisted hover sizes.' + ] + }, "Clear Persisted Hover Sizes"), + alias: 'Clear Persisted Hover Sizes', + precondition: undefined + }); + } + + public run(_accessor: ServicesAccessor, editor: ICodeEditor): void { + const controller = ModesHoverController.get(editor); + if (!controller) { + return; + } + controller.clearPersistedSizes(); + } +} + registerEditorContribution(ModesHoverController.ID, ModesHoverController, EditorContributionInstantiation.BeforeFirstInteraction); registerEditorAction(ShowOrFocusHoverAction); registerEditorAction(ShowDefinitionPreviewHoverAction); @@ -681,6 +712,7 @@ registerEditorAction(PageDownHoverAction); registerEditorAction(GoToTopHoverAction); registerEditorAction(GoToBottomHoverAction); registerEditorAction(EscapeFocusHoverAction); +registerEditorAction(ClearPersistedHoverSizes); HoverParticipantRegistry.register(MarkdownHoverParticipant); HoverParticipantRegistry.register(MarkerHoverParticipant); diff --git a/src/vs/editor/contrib/hover/browser/resizableContentWidget.ts b/src/vs/editor/contrib/hover/browser/resizableContentWidget.ts new file mode 100644 index 0000000000000..c053517ef7c6d --- /dev/null +++ b/src/vs/editor/contrib/hover/browser/resizableContentWidget.ts @@ -0,0 +1,341 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ResizableHTMLElement } from 'vs/base/browser/ui/resizable/resizable'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser'; +import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { clamp } from 'vs/base/common/numbers'; +import { ResourceMap } from 'vs/base/common/map'; +import { IPosition, Position } from 'vs/editor/common/core/position'; +import * as dom from 'vs/base/browser/dom'; + +const HEADER_HEIGHT = 30; +const MIN_HEIGHT = 24; + +abstract class ResizableContentWidget extends Disposable implements IContentWidget { + + readonly allowEditorOverflow: boolean = true; + readonly suppressMouseDown: boolean = false; + + protected readonly _resizableNode = this._register(new ResizableHTMLElement()); + protected _contentPosition: IContentWidgetPosition | null = null; + + private _isResizing: boolean = false; + + constructor( + protected readonly _editor: ICodeEditor, + _initialSize: dom.IDimension = new dom.Dimension(10, 10) + ) { + super(); + this._resizableNode.domNode.style.position = 'absolute'; + this._resizableNode.minSize = new dom.Dimension(10, 10); + this._resizableNode.enableSashes(true, true, true, true); + this._resizableNode.layout(_initialSize.height, _initialSize.width); + this._register(this._resizableNode.onDidResize(e => { + if (e.done) { + this._isResizing = false; + } + })); + this._register(this._resizableNode.onDidWillResize(() => { + this._isResizing = true; + })); + } + + get isResizing() { + return this._isResizing; + } + + abstract getId(): string; + + getDomNode(): HTMLElement { + return this._resizableNode.domNode; + } + + getPosition(): IContentWidgetPosition | null { + return this._contentPosition; + } + + protected _availableVerticalSpaceAbove(position: IPosition): number | undefined { + const editorDomNode = this._editor.getDomNode(); + const mouseBox = this._editor.getScrolledVisiblePosition(position); + if (!editorDomNode || !mouseBox) { + return; + } + const editorBox = dom.getDomNodePagePosition(editorDomNode); + return editorBox.top + mouseBox.top - HEADER_HEIGHT; + } + + protected _availableVerticalSpaceBelow(position: IPosition): number | undefined { + const editorDomNode = this._editor.getDomNode(); + const mouseBox = this._editor.getScrolledVisiblePosition(position); + if (!editorDomNode || !mouseBox) { + return; + } + const editorBox = dom.getDomNodePagePosition(editorDomNode); + const bodyBox = dom.getClientArea(document.body); + const mouseBottom = editorBox.top + mouseBox.top + mouseBox.height; + return bodyBox.height - mouseBottom; + } + + protected _findPositionPreference(widgetHeight: number, showAtPosition: IPosition): ContentWidgetPositionPreference | undefined { + const maxHeightBelow = Math.min(this._availableVerticalSpaceBelow(showAtPosition) ?? Infinity, widgetHeight); + const maxHeightAbove = Math.min(this._availableVerticalSpaceAbove(showAtPosition) ?? Infinity, widgetHeight); + const maxHeight = Math.min(Math.max(maxHeightAbove, maxHeightBelow), widgetHeight); + const height = clamp(widgetHeight, MIN_HEIGHT, maxHeight); + let renderingAbove: ContentWidgetPositionPreference; + if (this._editor.getOption(EditorOption.hover).above) { + renderingAbove = height <= maxHeightAbove ? ContentWidgetPositionPreference.ABOVE : ContentWidgetPositionPreference.BELOW; + } else { + renderingAbove = height <= maxHeightBelow ? ContentWidgetPositionPreference.BELOW : ContentWidgetPositionPreference.ABOVE; + } + if (renderingAbove === ContentWidgetPositionPreference.ABOVE) { + this._resizableNode.enableSashes(true, true, false, false); + } else { + this._resizableNode.enableSashes(false, true, true, false); + } + return renderingAbove; + } + + _resize(dimension: dom.Dimension): void { + this._resizableNode.layout(dimension.height, dimension.width); + } + + beforeOnDidWillResize() { + return; + } + + afterOnDidResize() { + return; + } +} + +/** + * Class which is used in the single size persisting mechanism for resizable widgets. + */ +class PersistedWidgetSize { + + constructor( + private readonly _key: string, + private readonly _service: IStorageService + ) { } + + restore(): dom.Dimension | undefined { + const raw = this._service.get(this._key, StorageScope.PROFILE) ?? ''; + try { + const obj = JSON.parse(raw); + if (dom.Dimension.is(obj)) { + return dom.Dimension.lift(obj); + } + } catch { + // ignore + } + return undefined; + } + + store(size: dom.Dimension) { + this._service.store(this._key, JSON.stringify(size), StorageScope.PROFILE, StorageTarget.MACHINE); + } + + reset(): void { + this._service.remove(this._key, StorageScope.PROFILE); + } +} + +/** + * Class which is used in the single size persisting mechanism for resizable widgets. + */ +class ResizeState { + constructor( + readonly persistedSize: dom.Dimension | undefined, + readonly currentSize: dom.Dimension, + public persistHeight = false, + public persistWidth = false, + ) { } +} + +export class SingleSizePersistingOptions { + constructor( + public readonly key: string, + public readonly defaultSize: dom.Dimension, + @IStorageService public readonly storageService: IStorageService + ) { } +} + +/** + * Abstract class which defines a resizable widgets for which one single global size is persisted. + */ +export abstract class SinglePersistedSizeResizableContentWidget extends ResizableContentWidget { + + private readonly _persistedWidgetSize: PersistedWidgetSize | undefined; + private readonly _persistingOptions: SingleSizePersistingOptions; + private readonly _disposables = new DisposableStore(); + + constructor( + _editor: ICodeEditor, + _persistingOptions: SingleSizePersistingOptions, + _initialSize?: dom.IDimension + ) { + super(_editor, _initialSize); + this._persistingOptions = _persistingOptions; + this._persistedWidgetSize = new PersistedWidgetSize(this._persistingOptions.key, this._persistingOptions.storageService); + let state: ResizeState | undefined; + this._disposables.add(this._resizableNode.onDidWillResize(() => { + this.beforeOnDidWillResize(); + state = new ResizeState(this._persistedWidgetSize!.restore(), this._resizableNode.size); + })); + this._disposables.add(this._resizableNode.onDidResize(e => { + this._resize(new dom.Dimension(e.dimension.width, e.dimension.height)); + if (!e.done) { + return; + } + if (state) { + state.persistHeight = state.persistHeight || !!e.north || !!e.south; + state.persistWidth = state.persistWidth || !!e.east || !!e.west; + const fontInfo = this._editor.getOption(EditorOption.fontInfo); + const itemHeight = clamp(this._editor.getOption(EditorOption.suggestLineHeight) || fontInfo.lineHeight, 8, 1000); + const threshold = Math.round(itemHeight / 2); + let { width, height } = this._resizableNode.size; + if (!state.persistHeight || Math.abs(state.currentSize.height - height) <= threshold) { + height = state.persistedSize?.height ?? this._persistingOptions.defaultSize.height; + } + if (!state.persistWidth || Math.abs(state.currentSize.width - width) <= threshold) { + width = state.persistedSize?.width ?? this._persistingOptions.defaultSize.width; + } + this._persistedWidgetSize!.store(new dom.Dimension(width, height)); + } + state = undefined; + this.afterOnDidResize(); + })); + } + + findPersistedSize(): dom.Dimension | undefined { + return this._persistedWidgetSize?.restore(); + } + + clearPersistedSize(): void { + this._persistedWidgetSize?.reset(); + } + + override dispose(): void { + super.dispose(); + this._disposables.dispose(); + } +} + +/** + * Abstract class which defines a resizable widgets for which a size is persisted on a per token basis. The persisted sizes are updated as the the model changes. + */ +export abstract class MultiplePersistedSizeResizableContentWidget extends ResizableContentWidget { + + private readonly _persistedWidgetSizes: ResourceMap> = new ResourceMap>(); + private readonly _disposables = new DisposableStore(); + protected _position: Position | undefined; + + constructor( + _editor: ICodeEditor, + _initialSize?: dom.IDimension + ) { + super(_editor, _initialSize); + this._disposables.add(this._editor.onDidChangeModelContent((e) => { + const uri = this._editor.getModel()?.uri; + if (!uri || !this._persistedWidgetSizes.has(uri)) { + return; + } + const persistedSizesForUri = this._persistedWidgetSizes.get(uri)!; + const updatedPersistedSizesForUri = new Map(); + for (const change of e.changes) { + const changeOffset = change.rangeOffset; + const rangeLength = change.rangeLength; + const endOffset = changeOffset + rangeLength; + const textLength = change.text.length; + for (const key of persistedSizesForUri.keys()) { + const parsedKey = JSON.parse(key); + const tokenOffset = parsedKey[0]; + const tokenLength = parsedKey[1]; + if (endOffset < tokenOffset) { + const oldSize = persistedSizesForUri.get(key)!; + const newKey: [number, number] = [tokenOffset - rangeLength + textLength, tokenLength]; + updatedPersistedSizesForUri.set(JSON.stringify(newKey), oldSize); + } else if (changeOffset >= tokenOffset + tokenLength) { + updatedPersistedSizesForUri.set(key, persistedSizesForUri.get(key)!); + } + } + } + this._persistedWidgetSizes.set(uri, updatedPersistedSizesForUri); + })); + this._disposables.add(this._resizableNode.onDidWillResize(() => { + this.beforeOnDidWillResize(); + })); + this._disposables.add(this._resizableNode.onDidResize(e => { + const height = e.dimension.height; + const width = e.dimension.width; + this._resize(new dom.Dimension(width, height)); + if (e.done) { + if (!this._editor.hasModel()) { + return; + } + const editorModel = this._editor.getModel(); + const uri = editorModel.uri; + if (!uri || !this._position) { + return; + } + const wordPosition = editorModel.getWordAtPosition(this._position); + if (!wordPosition) { + return; + } + const persistedSize = new dom.Dimension(width, height); + const offset = editorModel.getOffsetAt({ lineNumber: this._position.lineNumber, column: wordPosition.startColumn }); + const length = wordPosition.word.length; + if (!this._persistedWidgetSizes.get(uri)) { + const persistedWidgetSizesForUri = new Map([]); + persistedWidgetSizesForUri.set(JSON.stringify([offset, length]), persistedSize); + this._persistedWidgetSizes.set(uri, persistedWidgetSizesForUri); + } else { + const persistedWidgetSizesForUri = this._persistedWidgetSizes.get(uri)!; + persistedWidgetSizesForUri.set(JSON.stringify([offset, length]), persistedSize); + } + } + this.afterOnDidResize(); + })); + } + + set position(position: Position | undefined) { + this._position = position; + } + + get position() { + return this._position; + } + + findPersistedSize(): dom.Dimension | undefined { + if (!this._position || !this._editor.hasModel()) { + return; + } + const editorModel = this._editor.getModel(); + const wordPosition = editorModel.getWordAtPosition(this._position); + if (!wordPosition) { + return; + } + const offset = editorModel.getOffsetAt({ lineNumber: this._position.lineNumber, column: wordPosition.startColumn }); + const length = wordPosition.word.length; + const uri = editorModel.uri; + const persistedSizesForUri = this._persistedWidgetSizes.get(uri); + if (!persistedSizesForUri) { + return; + } + return persistedSizesForUri.get(JSON.stringify([offset, length])); + } + + clearPersistedSizes(): void { + this._persistedWidgetSizes.clear(); + } + + override dispose(): void { + super.dispose(); + this._disposables.dispose(); + } +}