From 85fb8cb6fd694d9f55eac35ab158ba5dbb6d7707 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 3 Apr 2024 06:29:25 -0700 Subject: [PATCH] Create IHoverDelegate2.setUpdatableHover --- src/vs/base/browser/ui/hover/hover.ts | 5 + .../base/browser/ui/hover/hoverDelegate2.ts | 1 + .../browser/ui/hover/updatableHoverWidget.ts | 3 +- .../services/hoverService/hoverService.ts | 142 +++++++++++++++++- .../hoverService/updatableHoverWidget.ts | 109 ++++++++++++++ 5 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 src/vs/editor/browser/services/hoverService/updatableHoverWidget.ts diff --git a/src/vs/base/browser/ui/hover/hover.ts b/src/vs/base/browser/ui/hover/hover.ts index ab7a98e71b9..a0f9422ce05 100644 --- a/src/vs/base/browser/ui/hover/hover.ts +++ b/src/vs/base/browser/ui/hover/hover.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; import type { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; import type { CancellationToken } from 'vs/base/common/cancellation'; import type { IMarkdownString } from 'vs/base/common/htmlContent'; @@ -39,6 +40,9 @@ export interface IHoverDelegate2 { * simultaneously. #188822 */ showAndFocusLastHover(): void; + + // TODO: Change hoverDelegate arg to exclude the actual delegate and instead use the new options + setupUpdatableHover(hoverDelegate: IHoverDelegate, htmlElement: HTMLElement, content: IUpdatableHoverContentOrFactory, options?: IUpdatableHoverOptions): IUpdatableHover; } export interface IHoverWidget extends IDisposable { @@ -237,6 +241,7 @@ export interface IUpdatableHoverTooltipMarkdownString { } export type IUpdatableHoverContent = string | IUpdatableHoverTooltipMarkdownString | HTMLElement | undefined; +export type IUpdatableHoverContentOrFactory = IUpdatableHoverContent | (() => IUpdatableHoverContent); export interface IUpdatableHoverOptions { actions?: IHoverAction[]; diff --git a/src/vs/base/browser/ui/hover/hoverDelegate2.ts b/src/vs/base/browser/ui/hover/hoverDelegate2.ts index d3f00307179..90c71d65a1d 100644 --- a/src/vs/base/browser/ui/hover/hoverDelegate2.ts +++ b/src/vs/base/browser/ui/hover/hoverDelegate2.ts @@ -9,6 +9,7 @@ let baseHoverDelegate: IHoverDelegate2 = { showHover: () => undefined, hideHover: () => undefined, showAndFocusLastHover: () => undefined, + setupUpdatableHover: () => null!, }; /** diff --git a/src/vs/base/browser/ui/hover/updatableHoverWidget.ts b/src/vs/base/browser/ui/hover/updatableHoverWidget.ts index 93532dbb0ea..4fe7e136d6b 100644 --- a/src/vs/base/browser/ui/hover/updatableHoverWidget.ts +++ b/src/vs/base/browser/ui/hover/updatableHoverWidget.ts @@ -13,9 +13,8 @@ import { stripIcons } from 'vs/base/common/iconLabels'; import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { isFunction, isString } from 'vs/base/common/types'; import { localize } from 'vs/nls'; -import type { IHoverWidget, IUpdatableHover, IUpdatableHoverContent, IUpdatableHoverOptions, IUpdatableHoverTooltipMarkdownString } from 'vs/base/browser/ui/hover/hover'; +import type { IHoverWidget, IUpdatableHover, IUpdatableHoverContent, IUpdatableHoverContentOrFactory, IUpdatableHoverOptions, IUpdatableHoverTooltipMarkdownString } from 'vs/base/browser/ui/hover/hover'; -type IUpdatableHoverContentOrFactory = IUpdatableHoverContent | (() => IUpdatableHoverContent); type IUpdatableHoverResolvedContent = IMarkdownString | string | HTMLElement | undefined; export function setupNativeHover(htmlElement: HTMLElement, tooltip: string | IUpdatableHoverTooltipMarkdownString | undefined): void { diff --git a/src/vs/editor/browser/services/hoverService/hoverService.ts b/src/vs/editor/browser/services/hoverService/hoverService.ts index 1bbe95a134e..646c56ef320 100644 --- a/src/vs/editor/browser/services/hoverService/hoverService.ts +++ b/src/vs/editor/browser/services/hoverService/hoverService.ts @@ -20,7 +20,10 @@ import { IAccessibilityService } from 'vs/platform/accessibility/common/accessib import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; import { mainWindow } from 'vs/base/browser/window'; import { ContextViewHandler } from 'vs/platform/contextview/browser/contextViewService'; -import type { IHoverOptions, IHoverWidget } from 'vs/base/browser/ui/hover/hover'; +import type { IHoverOptions, IHoverWidget, IUpdatableHover, IUpdatableHoverContentOrFactory, IUpdatableHoverOptions } from 'vs/base/browser/ui/hover/hover'; +import type { IHoverDelegate, IHoverDelegateTarget } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { UpdatableHoverWidget } from 'vs/editor/browser/services/hoverService/updatableHoverWidget'; +import { TimeoutTimer } from 'vs/base/common/async'; export class HoverService extends Disposable implements IHoverService { declare readonly _serviceBrand: undefined; @@ -183,6 +186,135 @@ export class HoverService extends Disposable implements IHoverService { } } } + + setupUpdatableHover(hoverDelegate: IHoverDelegate, htmlElement: HTMLElement, content: IUpdatableHoverContentOrFactory, options?: IUpdatableHoverOptions | undefined): IUpdatableHover { + + htmlElement.setAttribute('custom-hover', 'true'); + + if (htmlElement.title !== '') { + console.warn('HTML element already has a title attribute, which will conflict with the custom hover. Please remove the title attribute.'); + console.trace('Stack trace:', htmlElement.title); + htmlElement.title = ''; + } + + let hoverPreparation: IDisposable | undefined; + let hoverWidget: UpdatableHoverWidget | undefined; + + const hideHover = (disposeWidget: boolean, disposePreparation: boolean) => { + const hadHover = hoverWidget !== undefined; + if (disposeWidget) { + hoverWidget?.dispose(); + hoverWidget = undefined; + } + if (disposePreparation) { + hoverPreparation?.dispose(); + hoverPreparation = undefined; + } + if (hadHover) { + hoverDelegate.onDidHideHover?.(); + hoverWidget = undefined; + } + }; + + const triggerShowHover = (delay: number, focus?: boolean, target?: IHoverDelegateTarget) => { + return new TimeoutTimer(async () => { + if (!hoverWidget || hoverWidget.isDisposed) { + hoverWidget = new UpdatableHoverWidget(hoverDelegate, target || htmlElement, delay > 0); + await hoverWidget.update(typeof content === 'function' ? content() : content, focus, options); + } + }, delay); + }; + + let isMouseDown = false; + const mouseDownEmitter = addDisposableListener(htmlElement, EventType.MOUSE_DOWN, () => { + isMouseDown = true; + hideHover(true, true); + }, true); + const mouseUpEmitter = addDisposableListener(htmlElement, EventType.MOUSE_UP, () => { + isMouseDown = false; + }, true); + const mouseLeaveEmitter = addDisposableListener(htmlElement, EventType.MOUSE_LEAVE, (e: MouseEvent) => { + isMouseDown = false; + hideHover(false, (e).fromElement === htmlElement); + }, true); + + const onMouseOver = (e: MouseEvent) => { + if (hoverPreparation) { + return; + } + + const toDispose: DisposableStore = new DisposableStore(); + + const target: IHoverDelegateTarget = { + targetElements: [htmlElement], + dispose: () => { } + }; + if (hoverDelegate.placement === undefined || hoverDelegate.placement === 'mouse') { + // track the mouse position + const onMouseMove = (e: MouseEvent) => { + target.x = e.x + 10; + if ((e.target instanceof HTMLElement) && getHoverTargetElement(e.target, htmlElement) !== htmlElement) { + hideHover(true, true); + } + }; + toDispose.add(addDisposableListener(htmlElement, EventType.MOUSE_MOVE, onMouseMove, true)); + } + + hoverPreparation = toDispose; + + if ((e.target instanceof HTMLElement) && getHoverTargetElement(e.target as HTMLElement, htmlElement) !== htmlElement) { + return; // Do not show hover when the mouse is over another hover target + } + + toDispose.add(triggerShowHover(hoverDelegate.delay, false, target)); + }; + const mouseOverDomEmitter = addDisposableListener(htmlElement, EventType.MOUSE_OVER, onMouseOver, true); + + const onFocus = () => { + if (isMouseDown || hoverPreparation) { + return; + } + const target: IHoverDelegateTarget = { + targetElements: [htmlElement], + dispose: () => { } + }; + const toDispose: DisposableStore = new DisposableStore(); + const onBlur = () => hideHover(true, true); + toDispose.add(addDisposableListener(htmlElement, EventType.BLUR, onBlur, true)); + toDispose.add(triggerShowHover(hoverDelegate.delay, false, target)); + hoverPreparation = toDispose; + }; + + // Do not show hover when focusing an input or textarea + let focusDomEmitter: undefined | IDisposable; + const tagName = htmlElement.tagName.toLowerCase(); + if (tagName !== 'input' && tagName !== 'textarea') { + focusDomEmitter = addDisposableListener(htmlElement, EventType.FOCUS, onFocus, true); + } + + const hover: IUpdatableHover = { + show: focus => { + hideHover(false, true); // terminate a ongoing mouse over preparation + triggerShowHover(0, focus); // show hover immediately + }, + hide: () => { + hideHover(true, true); + }, + update: async (newContent, hoverOptions) => { + content = newContent; + await hoverWidget?.update(content, undefined, hoverOptions); + }, + dispose: () => { + mouseOverDomEmitter.dispose(); + mouseLeaveEmitter.dispose(); + mouseDownEmitter.dispose(); + mouseUpEmitter.dispose(); + focusDomEmitter?.dispose(); + hideHover(true, true); + } + }; + return hover; + } } function getHoverOptionsIdentity(options: IHoverOptions | undefined): IHoverOptions | number | string | undefined { @@ -227,6 +359,14 @@ class HoverContextViewDelegate implements IDelegate { } } +function getHoverTargetElement(element: HTMLElement, stopElement?: HTMLElement): HTMLElement { + stopElement = stopElement ?? getWindow(element).document.body; + while (!element.hasAttribute('custom-hover') && element !== stopElement) { + element = element.parentElement!; + } + return element; +} + registerSingleton(IHoverService, HoverService, InstantiationType.Delayed); registerThemingParticipant((theme, collector) => { diff --git a/src/vs/editor/browser/services/hoverService/updatableHoverWidget.ts b/src/vs/editor/browser/services/hoverService/updatableHoverWidget.ts new file mode 100644 index 00000000000..869e493c1f9 --- /dev/null +++ b/src/vs/editor/browser/services/hoverService/updatableHoverWidget.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IHoverWidget, IUpdatableHoverContent, IUpdatableHoverOptions } from 'vs/base/browser/ui/hover/hover'; +import type { IHoverDelegate, IHoverDelegateOptions, IHoverDelegateTarget } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; +import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { isMarkdownString, type IMarkdownString } from 'vs/base/common/htmlContent'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { isFunction, isString } from 'vs/base/common/types'; +import { localize } from 'vs/nls'; + +type IUpdatableHoverResolvedContent = IMarkdownString | string | HTMLElement | undefined; + +export class UpdatableHoverWidget implements IDisposable { + + private _hoverWidget: IHoverWidget | undefined; + private _cancellationTokenSource: CancellationTokenSource | undefined; + + constructor(private hoverDelegate: IHoverDelegate, private target: IHoverDelegateTarget | HTMLElement, private fadeInAnimation: boolean) { + } + + async update(content: IUpdatableHoverContent, focus?: boolean, options?: IUpdatableHoverOptions): Promise { + if (this._cancellationTokenSource) { + // there's an computation ongoing, cancel it + this._cancellationTokenSource.dispose(true); + this._cancellationTokenSource = undefined; + } + if (this.isDisposed) { + return; + } + + let resolvedContent; + if (content === undefined || isString(content) || content instanceof HTMLElement) { + resolvedContent = content; + } else if (!isFunction(content.markdown)) { + resolvedContent = content.markdown ?? content.markdownNotSupportedFallback; + } else { + // compute the content, potentially long-running + + // show 'Loading' if no hover is up yet + if (!this._hoverWidget) { + this.show(localize('iconLabel.loading', "Loading..."), focus); + } + + // compute the content + this._cancellationTokenSource = new CancellationTokenSource(); + const token = this._cancellationTokenSource.token; + resolvedContent = await content.markdown(token); + if (resolvedContent === undefined) { + resolvedContent = content.markdownNotSupportedFallback; + } + + if (this.isDisposed || token.isCancellationRequested) { + // either the widget has been closed in the meantime + // or there has been a new call to `update` + return; + } + } + + this.show(resolvedContent, focus, options); + } + + private show(content: IUpdatableHoverResolvedContent, focus?: boolean, options?: IUpdatableHoverOptions): void { + const oldHoverWidget = this._hoverWidget; + + if (this.hasContent(content)) { + const hoverOptions: IHoverDelegateOptions = { + content, + target: this.target, + appearance: { + showPointer: this.hoverDelegate.placement === 'element', + skipFadeInAnimation: !this.fadeInAnimation || !!oldHoverWidget, // do not fade in if the hover is already showing + }, + position: { + hoverPosition: HoverPosition.BELOW, + }, + ...options + }; + + this._hoverWidget = this.hoverDelegate.showHover(hoverOptions, focus); + } + oldHoverWidget?.dispose(); + } + + private hasContent(content: IUpdatableHoverResolvedContent): content is NonNullable { + if (!content) { + return false; + } + + if (isMarkdownString(content)) { + return !!content.value; + } + + return true; + } + + get isDisposed() { + return this._hoverWidget?.isDisposed; + } + + dispose(): void { + this._hoverWidget?.dispose(); + this._cancellationTokenSource?.dispose(true); + this._cancellationTokenSource = undefined; + } +}