@jupyter-lsp/jupyterlab-lsp
Version:
Language Server Protocol integration for JupyterLab
294 lines (263 loc) • 8.69 kB
text/typescript
// 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;
}
}
}