UNPKG

@jupyter-lsp/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

294 lines (263 loc) 8.69 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. // (Parts of the FreeTooltip code are copy-paste from Tooltip, ideally this would be PRed be merged) import { HoverBox } from '@jupyterlab/apputils'; import { CodeEditor } from '@jupyterlab/codeeditor'; import { IDocumentWidget } from '@jupyterlab/docregistry'; import { IEditorPosition, isEqual, WidgetLSPAdapter } from '@jupyterlab/lsp'; import { IRenderMime, MimeModel, IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { Tooltip } from '@jupyterlab/tooltip'; import { Widget } from '@lumino/widgets'; import * as lsProtocol from 'vscode-languageserver-protocol'; import { PositionConverter } from '../converter'; const MIN_HEIGHT = 20; const MAX_HEIGHT = 250; const CLASS_NAME = 'lsp-tooltip'; interface IFreeTooltipOptions extends Tooltip.IOptions { /** * Position at which the tooltip should be placed, or null (default) to use the current cursor position. */ position: CodeEditor.IPosition | undefined; /** * HoverBox privilege. */ privilege?: 'above' | 'below' | 'forceAbove' | 'forceBelow'; /** * Alignment with respect to the current token. */ alignment?: 'start' | 'end' | undefined; /** * default: true; ESC will always hide */ hideOnKeyPress?: boolean; } type Bundle = { 'text/plain': string } | { 'text/markdown': string }; /** * Tooltip which can be placed at any character, not only at the current position (derived from getCursorPosition) */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore export class FreeTooltip extends Tooltip { constructor(protected options: IFreeTooltipOptions) { super(options); this._setGeometry(); } setBundle(bundle: Bundle) { const model = new MimeModel({ data: bundle }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const content: IRenderMime.IRenderer = this._content; content .renderModel(model) .then(() => this._setGeometry()) .catch(console.warn); } handleEvent(event: Event): void { if (this.isHidden || this.isDisposed) { return; } const { node } = this; const target = event.target as HTMLElement; switch (event.type) { case 'keydown': { const keyCode = (event as KeyboardEvent).keyCode; // ESC or Backspace cancel anyways if ( node.contains(target) || (!this.options.hideOnKeyPress && keyCode != 27 && keyCode != 8) ) { return; } this.dispose(); break; } default: super.handleEvent(event); break; } } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore private _setGeometry(): void { // Find the start of the current token for hover box placement. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const editor = this._editor as CodeEditor.IEditor; const cursor: CodeEditor.IPosition = this.options.position == null ? editor.getCursorPosition() : this.options.position; let position: CodeEditor.IPosition | undefined; if (this.options.alignment) { const end = editor.getOffsetAt(cursor); const line = editor.getLine(cursor.line); if (!line) { return; } switch (this.options.alignment) { case 'start': { const tokens = line.substring(0, end).split(/\W+/); const last = tokens[tokens.length - 1]; const start = last ? end - last.length : end; position = editor.getPositionAt(start); break; } case 'end': { const tokens = line.substring(0, end).split(/\W+/); const last = tokens[tokens.length - 1]; const start = last ? end - last.length : end; position = editor.getPositionAt(start); break; } } } else { position = cursor; } if (!position) { return; } const anchor = editor.getCoordinateForPosition(position) as ClientRect; const style = window.getComputedStyle(this.node); const paddingLeft = parseInt(style.paddingLeft!, 10) || 0; // When the editor is attached to the main area, contain the hover box // to the full area available (rather than to the editor itself); the available // area excludes the toolbar, hence the first Widget child between MainAreaWidget // and editor is preferred. const host = (editor.host.closest('.jp-MainAreaWidget > .lm-Widget') as HTMLElement) || editor.host; // Calculate the geometry of the tooltip. HoverBox.setGeometry({ anchor, host: host, maxHeight: MAX_HEIGHT, minHeight: MIN_HEIGHT, node: this.node, offset: { horizontal: -1 * paddingLeft }, privilege: this.options.privilege || 'below', style: style, outOfViewDisplay: { left: 'stick-inside', right: 'stick-outside', top: 'stick-outside', bottom: 'stick-inside' } }); } setPosition(position: CodeEditor.IPosition) { this.options.position = position; this._setGeometry(); } } export namespace EditorTooltip { export interface IOptions { id?: string; markup: lsProtocol.MarkupContent; ceEditor: CodeEditor.IEditor; position: IEditorPosition; adapter: WidgetLSPAdapter<IDocumentWidget>; className?: string; tooltip?: Partial<IFreeTooltipOptions>; } } function markupToBundle(markup: lsProtocol.MarkupContent): Bundle { return markup.kind === 'plaintext' ? { 'text/plain': markup.value } : { 'text/markdown': markup.value }; } export class EditorTooltipManager { private currentTooltip: FreeTooltip | null = null; private currentOptions: EditorTooltip.IOptions | null; constructor(private rendermimeRegistry: IRenderMimeRegistry) {} create(options: EditorTooltip.IOptions): FreeTooltip { this.remove(); this.currentOptions = options; let { markup, position, adapter } = options; let widget = adapter.widget; const bundle = markupToBundle(markup); const tooltip = new FreeTooltip({ ...(options.tooltip || {}), anchor: widget.content, bundle: bundle, editor: options.ceEditor, rendermime: this.rendermimeRegistry, position: PositionConverter.cm_to_ce(position) }); tooltip.addClass(CLASS_NAME); if (options.className) { tooltip.addClass(options.className); } Widget.attach(tooltip, document.body); this.currentTooltip = tooltip; return tooltip; } showOrCreate(options: EditorTooltip.IOptions): FreeTooltip { const samePosition = this.currentOptions && isEqual(this.currentOptions.position, options.position); const sameMarkup = this.currentOptions && this.currentOptions.markup.value === options.markup.value && this.currentOptions.markup.kind === options.markup.kind; if ( this.currentTooltip !== null && !this.currentTooltip.isDisposed && this.currentOptions && this.currentOptions.adapter === options.adapter && (samePosition || sameMarkup) && this.currentOptions.ceEditor === options.ceEditor && this.currentOptions.id === options.id ) { // we only allow either position or markup change, because if both changed, // then we may get into problematic race condition in sizing after bundle update. if (!sameMarkup) { this.currentOptions.markup = options.markup; this.currentTooltip.setBundle(markupToBundle(options.markup)); } if (!samePosition) { // setting geometry only works when visible this.currentTooltip.setPosition( PositionConverter.cm_to_ce(options.position) ); } this.show(); return this.currentTooltip; } else { this.remove(); return this.create(options); } } get position(): IEditorPosition { return this.currentOptions!.position; } isShown(id?: string): boolean { if (id && this.currentOptions && this.currentOptions?.id !== id) { return false; } return ( this.currentTooltip !== null && !this.currentTooltip.isDisposed && this.currentTooltip.isVisible ); } hide() { if (this.currentTooltip !== null) { this.currentTooltip.hide(); } } show() { if (this.currentTooltip !== null) { this.currentTooltip.show(); } } remove() { if (this.currentTooltip !== null) { this.currentTooltip.dispose(); this.currentTooltip = null as any; } } }