From def8d55d90d2f53daebe22f207c4e70d65177f30 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Wed, 28 Oct 2020 10:37:20 +0000 Subject: [PATCH] Server: Add tooltips with help for Python functions --- spyder_notebook/server/package.json | 1 + spyder_notebook/server/src/commands.ts | 25 ++++++ spyder_notebook/server/src/index.ts | 1 + spyder_notebook/server/src/tooltip.ts | 111 +++++++++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 spyder_notebook/server/src/tooltip.ts diff --git a/spyder_notebook/server/package.json b/spyder_notebook/server/package.json index 52fbf17a..8a7e2110 100644 --- a/spyder_notebook/server/package.json +++ b/spyder_notebook/server/package.json @@ -19,6 +19,7 @@ "@jupyterlab/services": "^4.2.0", "@jupyterlab/theme-light-extension": "^1.2.1", "@jupyterlab/theme-dark-extension": "^1.2.1", + "@jupyterlab/tooltip": "^1.2.1", "@phosphor/commands": "^1.7.0", "@phosphor/widgets": "^1.9.0", "es6-promise": "~4.2.6" diff --git a/spyder_notebook/server/src/commands.ts b/spyder_notebook/server/src/commands.ts index db421852..e5f73ecb 100644 --- a/spyder_notebook/server/src/commands.ts +++ b/spyder_notebook/server/src/commands.ts @@ -13,6 +13,8 @@ import { NotebookSearchProvider } from '@jupyterlab/documentsearch'; +import { dismissTooltip, invokeTooltip } from './tooltip'; + /** * The map of command ids used by the notebook. */ @@ -21,6 +23,8 @@ const cmdIds = { select: 'completer:select', invokeNotebook: 'completer:invoke-notebook', selectNotebook: 'completer:select-notebook', + dismissTooltip: 'tooltip:dismiss', + invokeTooltip: 'tooltip:invoke', startSearch: 'documentsearch:start-search', findNext: 'documentsearch:find-next', findPrevious: 'documentsearch:find-previous', @@ -361,6 +365,17 @@ export const SetupCommands = ( execute: () => nbWidget.context.session.selectKernel() }); + // Tooltip commands + commands.addCommand(cmdIds.dismissTooltip, { + label: 'Dismiss Tooltip', + execute: () => dismissTooltip() + }); + + commands.addCommand(cmdIds.invokeTooltip, { + label: 'Invoke Tooltip', + execute: () => invokeTooltip(nbWidget) + }); + // Add other commands. commands.addCommand(cmdIds.invoke, { label: 'Completer: Invoke', @@ -554,6 +569,16 @@ export const SetupCommands = ( keys: ['Enter'], command: cmdIds.selectNotebook }, + { + selector: 'body.jp-mod-tooltip .jp-Notebook', + keys: ['Escape'], + command: cmdIds.dismissTooltip + }, + { + selector: '.jp-Notebook.jp-mod-editMode .jp-InputArea-editor:not(.jp-mod-has-primary-selection):not(.jp-mod-in-leading-whitespace)', + keys: ['Shift Tab'], + command: cmdIds.invokeTooltip + }, { selector: '.jp-Notebook', keys: ['Shift Enter'], diff --git a/spyder_notebook/server/src/index.ts b/spyder_notebook/server/src/index.ts index acff388e..2e763f76 100755 --- a/spyder_notebook/server/src/index.ts +++ b/spyder_notebook/server/src/index.ts @@ -12,6 +12,7 @@ import '@jupyterlab/application/style/index.css'; import '@jupyterlab/codemirror/style/index.css'; import '@jupyterlab/completer/style/index.css'; import '@jupyterlab/notebook/style/index.css'; +import '@jupyterlab/tooltip/style/index.css'; if (PageConfig.getOption('darkTheme') == 'true') { require('@jupyterlab/theme-dark-extension/style/index.css'); diff --git a/spyder_notebook/server/src/tooltip.ts b/spyder_notebook/server/src/tooltip.ts new file mode 100644 index 00000000..e458786e --- /dev/null +++ b/spyder_notebook/server/src/tooltip.ts @@ -0,0 +1,111 @@ +// Copyright (c) Jupyter Development Team, Spyder Project Contributors. +// Distributed under the terms of the Modified BSD License. + +// Adapted from jupyterlab/packages/tooltip-extension/src/index.ts + +import { CodeEditor } from '@jupyterlab/codeeditor'; +import { Text } from '@jupyterlab/coreutils'; +import { NotebookPanel } from '@jupyterlab/notebook'; +import { Kernel, KernelMessage } from '@jupyterlab/services'; +import { Tooltip } from '@jupyterlab/tooltip'; +import { Widget } from '@phosphor/widgets'; +import { JSONObject } from '@phosphor/coreutils'; + +let tooltip: Tooltip | null = null; + +export function dismissTooltip() { + if (tooltip) { + tooltip.dispose(); + tooltip = null; + } +} + +export function invokeTooltip(nbWidget: NotebookPanel) { + const detail: 0 | 1 = 0; + const parent = nbWidget.context; + const anchor = nbWidget.content; + const editor = anchor.activeCell.editor; + const kernel = parent.session.kernel; + const rendermime = anchor.rendermime; + + // If some components necessary for rendering don't exist, stop + if (!editor || !kernel || !rendermime) { + return; + } + + if (tooltip) { + tooltip.dispose(); + tooltip = null; + } + + return fetchTooltip({ detail, editor, kernel }) + .then(bundle => { + tooltip = new Tooltip({ anchor, bundle, editor, rendermime }); + Widget.attach(tooltip, document.body); + }) + .catch(() => { + /* Fails silently. */ + }); +} + +// A counter for outstanding requests. +let pending = 0; + +interface IFetchOptions { + /** + * The detail level requested from the API. + * + * #### Notes + * The only acceptable values are 0 and 1. The default value is 0. + * @see http://jupyter-client.readthedocs.io/en/latest/messaging.html#introspection + */ + detail?: 0 | 1; + + /** + * The referent editor for the tooltip. + */ + editor: CodeEditor.IEditor; + + /** + * The kernel against which the API request will be made. + */ + kernel: Kernel.IKernelConnection; +} + +/** + * Fetch a tooltip's content from the API server. + */ +function fetchTooltip(options: IFetchOptions): Promise { + let { detail, editor, kernel } = options; + let code = editor.model.value.text; + let position = editor.getCursorPosition(); + let offset = Text.jsIndexToCharIndex(editor.getOffsetAt(position), code); + + // Clear hints if the new text value is empty or kernel is unavailable. + if (!code || !kernel) { + return Promise.reject(void 0); + } + + let contents: KernelMessage.IInspectRequestMsg['content'] = { + code, + cursor_pos: offset, + detail_level: detail || 0 + }; + let current = ++pending; + + return kernel.requestInspect(contents).then(msg => { + let value = msg.content; + + // If a newer request is pending, bail. + if (current !== pending) { + return Promise.reject(void 0) as Promise; + } + + // If request fails or returns negative results, bail. + if (value.status !== 'ok' || !value.found) { + return Promise.reject(void 0) as Promise; + } + + return Promise.resolve(value.data); + }); +}