From 2c932270e2d26a999b2d0e694569dec2a6d379dd Mon Sep 17 00:00:00 2001 From: Andreas Gerlach Date: Thu, 18 Apr 2019 16:40:00 +0200 Subject: [PATCH] feat: new layout of signature help --- lib/main.js | 25 ++-- lib/provider-registry.js | 38 ++--- lib/signature-help-manager.js | 166 ++++++++++++--------- lib/signature-help-view.js | 134 +++++++++++++---- package.json | 2 +- styles/atom-ide-signature-help-marked.less | 2 +- styles/atom-ide-signature-help.less | 32 +++- typings/atom-ide-community.d.ts | 5 + typings/atom-ide.d.ts | 1 + 9 files changed, 267 insertions(+), 138 deletions(-) create mode 100644 typings/atom-ide-community.d.ts diff --git a/lib/main.js b/lib/main.js index 883f5a7..34dff43 100644 --- a/lib/main.js +++ b/lib/main.js @@ -1,8 +1,14 @@ +// @ts-check +/// 'use babel'; const { CompositeDisposable } = require('atom'); const SignatureHelpManager = require('./signature-help-manager'); +/** + * the Atom IDE signature help plugin + * @type {Object} + */ module.exports = { /** @@ -15,25 +21,21 @@ module.exports = { * @type {SignatureHelpManager} */ signatureHelpManager: null, - /** - * [renderer description] - * @type {[type]} + * a reference to the markdown rendering service + * @type {AtomIDE.MarkdownService} */ renderer: null, /** * called by Atom when activating an extension - * @param {[type]} state [description] + * @param {any} state the current state of atom */ activate(state) { - // Events subscribed to in atom's system can be easily cleaned up with a CompositeDisposable this.subscriptions = new CompositeDisposable(); - if (!this.signatureHelpManager) this.signatureHelpManager = new SignatureHelpManager(); this.subscriptions.add(this.signatureHelpManager); - require('atom-package-deps').install('atom-ide-signature-help').then(() => { this.signatureHelpManager.initialize(this.renderer); }); @@ -47,6 +49,7 @@ module.exports = { this.subscriptions.dispose(); } this.subscriptions = null; + this.signatureHelpManager = null; }, /** @@ -54,11 +57,13 @@ module.exports = { * @return {AtomIDE.SignatureHelpRegistry} [description] */ provideSignatureHelp() { - return (provider) => { - return this.signatureHelpManager.addProvider(provider); - } + return this.signatureHelpManager.signatureHelpRegistry; }, + /** + * retrieves a reference to the markdown rendering service that should be used + * @param {AtomIDE.MarkdownService} renderer the service for rendering markdown text + */ consumeMarkdownRenderer(renderer) { this.renderer = renderer; } diff --git a/lib/provider-registry.js b/lib/provider-registry.js index 8aa1c7c..528d669 100644 --- a/lib/provider-registry.js +++ b/lib/provider-registry.js @@ -4,18 +4,23 @@ const { Disposable, TextEditor } = require('atom'); +/** + * the registry of signature help providers + */ module.exports = class ProviderRegistry { + constructor() { /** - * [providers description] + * the initial empty provider list * @type {Array} */ this.providers = []; } /** - * [addProvider description] - * @param {AtomIDE.SignatureHelpProvider} provider [description] + * adds a signature help provider to the registry + * @param {AtomIDE.SignatureHelpProvider} provider the data tip provider to be added + * @return {Disposable} a disposable object to clean up the provider registration later */ addProvider(provider) { const index = this.providers.findIndex( @@ -31,7 +36,7 @@ module.exports = class ProviderRegistry { } /** - * [removeProvider description] + * removes a signature help provider from the registry * @param {AtomIDE.SignatureHelpProvider} provider [description] */ removeProvider(provider) { @@ -42,20 +47,19 @@ module.exports = class ProviderRegistry { } /** - * [getProviderForEditor description] - * @param {TextEditor} editor [description] - * @return {AtomIDE.SignatureHelpProvider} [description] + * looks for the first known provider to a given Atom text editor + * @param {TextEditor} editor the Atom text editor to be looked for + * @return {AtomIDE.SignatureHelpProvider | null} a signature help provider if found */ getProviderForEditor(editor) { const grammar = editor.getGrammar().scopeName; return this.findProvider(grammar); } - // TODO create an ordering or priority aware util to prefer instead. /** - * [getAllProvidersForEditor description] - * @param {TextEditor} editor [description] - * @return {Iterable} [description] + * looks for all known providers of a given Atom text editor + * @param {TextEditor} editor the Atom text editor to be looked for + * @return {Array} a list of signature help providers available for this editor */ getAllProvidersForEditor(editor) { const grammar = editor.getGrammar().scopeName; @@ -63,9 +67,9 @@ module.exports = class ProviderRegistry { } /** - * [findProvider description] - * @param {string} grammar [description] - * @return {AtomIDE.SignatureHelpProvider | null} [description] + * internal helper function to look for the first signature help provider for a specific grammar + * @param {String} grammar the grammar scope to be looked for + * @return {AtomIDE.SignatureHelpProvider | null} a signature help provider available for that grammar, or null if none */ findProvider(grammar) { for (const provider of this.findAllProviders(grammar)) { @@ -75,9 +79,9 @@ module.exports = class ProviderRegistry { } /** - * [findAllProviders description] - * @param {string} grammar [description] - * @return {IterableIterator} [description] + * internal helper to look for all signature help providers for a specific grammar + * @param {String} grammar the grammar scope to be looked for + * @return {Array} a list of all known signature help providers for that grammar */ *findAllProviders(grammar) { for (const provider of this.providers) { diff --git a/lib/signature-help-manager.js b/lib/signature-help-manager.js index 81c6dd6..541b67d 100644 --- a/lib/signature-help-manager.js +++ b/lib/signature-help-manager.js @@ -1,3 +1,5 @@ +// @ts-check +/// 'use babel'; const { CompositeDisposable, Disposable, Range, Point, TextEditor } = require('atom'); @@ -8,51 +10,55 @@ module.exports = class SignatureHelpManager { constructor() { /** - * [subscriptions description] + * holds a reference to disposable items from this data tip manager * @type {CompositeDisposable} */ this.subscriptions = new CompositeDisposable(); /** - * [providerRegistry description] + * holds a list of registered data tip providers * @type {ProviderRegistry} */ this.providerRegistry = new ProviderRegistry(); /** - * [watchedEditors description] + * holds a weak reference to all watched Atom text editors * @type {Array} */ this.watchedEditors = new WeakSet(); /** - * [editor description] + * holds a reference to the current watched Atom text editor * @type {TextEditor} */ this.editor = null; /** - * [editorView description] + * holds a reference to the current watched Atom text editor viewbuffer */ this.editorView = null; /** - * [editorSubscriptions description] + * holds a reference to all disposable items for the current watched Atom text editor * @type {CompositeDisposable} */ this.editorSubscriptions = null; /** - * [signatureHelpDisposables description] + * holds a reference to all disposable items for the current signature help * @type {CompositeDisposable} */ this.signatureHelpDisposables = null; /** - * [showSignatureHelpOnTyping description] + * config flag denoting if the signature help should be shown during typing automatically * @type {Boolean} */ this.showSignatureHelpOnTyping = false; /** - * [renderer description] - * @type {[type]} + * a reference to the markdown rendering service + * @type {AtomIDE.MarkdownService} */ this.renderer = null; } + /** + * initialization routine retrieving a reference to the markdown service + * @param {AtomIDE.MarkdownService} renderer the markdown rendering service reference + */ initialize(renderer) { this.renderer = renderer; @@ -60,17 +66,16 @@ module.exports = class SignatureHelpManager { atom.workspace.observeTextEditors(editor => { const disposable = this.watchEditor(editor); editor.onDidDestroy(() => disposable.dispose()); - }) - ); - - this.subscriptions.add( + }), atom.commands.add('atom-text-editor', { 'signature-help:show': (evt) => { const editor = evt.currentTarget.getModel(); - const provider = this.providerRegistry.getProviderForEditor(editor); - const position = editor.getLastCursor().getBufferPosition(); - if (provider) { - this.showDataTip(provider, editor, position); + if (atom.workspace.isTextEditor(editor)) { + const provider = this.providerRegistry.getProviderForEditor(editor); + const position = editor.getLastCursor().getBufferPosition(); + if (provider) { + this.showDataTip(provider, editor, position); + } } } }), @@ -84,6 +89,9 @@ module.exports = class SignatureHelpManager { ); } + /** + * dispose function to clean up any disposable references used + */ dispose() { if (this.signatureHelpDisposables) { this.signatureHelpDisposables.dispose(); @@ -101,19 +109,19 @@ module.exports = class SignatureHelpManager { this.subscriptions = null; } - /** - * [addProvider description] - * @param {AtomIDE.SignatureHelpProvider} provider [description] - * @returns {Disposable} + /** + * returns the provider registry as a consumable service + * @return {AtomIDE.SignatureHelpRegistry} [description] */ - addProvider (provider) { - return this.providerRegistry.addProvider(provider); + get signatureHelpRegistry() { + return (provider) => { + return this.providerRegistry.addProvider(provider); + } } /** - * [watchEditor description] - * @param {TextEditor} editor [description] - * @return {Disposable | null} [description] + * checks and setups an Atom Text editor instance for tracking cursor/mouse movements + * @param {TextEditor} editor a valid Atom Text editor instance */ watchEditor (editor) { if (this.watchedEditors.has(editor)) { return; } @@ -147,8 +155,9 @@ module.exports = class SignatureHelpManager { } /** - * [updateCurrentEditor description] - * @param {TextEditor} editor [description] + * updates the internal references to a specific Atom Text editor instance in case + * it has been decided to track this instance + * @param {TextEditor} editor the Atom Text editor instance to be tracked */ updateCurrentEditor (editor) { if (editor === this.editor) { return; } @@ -157,11 +166,6 @@ module.exports = class SignatureHelpManager { } this.editorSubscriptions = null; - if (this.signatureHelpDisposables) { - this.signatureHelpDisposables.dispose(); - } - this.signatureHelpDisposables = null; - // Stop tracking editor + buffer this.unmountDataTip(); this.editor = null; @@ -222,19 +226,28 @@ module.exports = class SignatureHelpManager { try { const signatureHelp = await provider.getSignatureHelp(editor, position); - if ((!signatureHelp) || (signatureHelp.signatures.length == 0)) { - this.unmountDataTip(); - } else { + if ((!signatureHelp) || (signatureHelp.signatures.length == 0)) { this.unmountDataTip(); } + else { const index = signatureHelp.activeSignature || 0; const signature = signatureHelp.signatures[index]; + const paramIndex = signatureHelp.activeParameter || 0; + const parameter = signature.parameters[paramIndex] || null; // clear last data tip this.unmountDataTip(); const grammar = editor.getGrammar().name.toLowerCase(); - const htmlString = this.makeHtmlFromSignature(signature, grammar); - const html = await this.renderer.render(htmlString, grammar); - const signatureHelpView = new SignatureHelpView({ htmlView: html }); + const snippetHtml = await this.getSnippetHtml(signature.label, grammar); + let doc = null; + if (parameter) { + doc = `${parameter.label}: ${parameter.documentation}`; + } else if (signature.documentation) { + doc = signature.documentation.kind ? signature.documentation.value : signature.documentation; + } else { + doc = ''; + } + const documentationHtml = await this.getDocumentationHtml(doc, grammar); + const signatureHelpView = new SignatureHelpView({ snippet: snippetHtml, html: documentationHtml }); this.signatureHelpDisposables = this.mountSignatureHelp(editor, position, signatureHelpView); } } catch (err) { @@ -243,11 +256,45 @@ module.exports = class SignatureHelpManager { } /** - * [mountSignatureHelp description] - * @param {TextEditor} editor [description] - * @param {Point} position [description] - * @param {SignatureHelpView} view [description] - * @return {CompositeDisposable} [description] + * converts a given code snippet into syntax formatted HTML + * @param {String} snippet the code snippet to be converted + * @param {String} grammarName the name of the grammar to be used for syntax highlighting + * @return {Promise} a promise object to track the asynchronous operation + */ + async getSnippetHtml(snippet, grammarName) { + if ((snippet !== undefined) && (snippet.length > 0)) { + const regExpLSPPrefix = /^\((method|property|parameter|alias)\)\W/; + const preElem = document.createElement('pre'); + const codeElem = document.createElement('code'); + + codeElem.classList.add(grammarName); + codeElem.innerText = snippet.replace(regExpLSPPrefix, ''); + preElem.appendChild(codeElem); + + return this.renderer.render(preElem.outerHTML, grammarName); + } + return null; + } + + /** + * convert the markdown documentation to HTML + * @param {String} markdownText the documentation text in markdown format to be converted + * @param {String} grammarName the default grammar used for embedded code samples + * @return {Promise} a promise object to track the asynchronous operation + */ + async getDocumentationHtml(markdownText, grammarName) { + if ((markdownText !== undefined) && (markdownText.length > 0)) { + return this.renderer.render(`

${markdownText}

`, grammarName); + } + return null; + } + + /** + * mounts / displays a signature help view component at a specific position in a given Atom Text editor + * @param {TextEditor} editor the Atom Text editor instance to host the data tip view + * @param {Point} position the position on which to show the signature help view + * @param {SignatureHelpView} view the signature help component to display + * @return {CompositeDisposable} a composite object to release references at a later stage */ mountSignatureHelp(editor, position, view) { let disposables = new CompositeDisposable(); @@ -262,10 +309,10 @@ module.exports = class SignatureHelpManager { item: view.element, }); - view.element.style.display = 'block'; // move box above the current editing line setTimeout(() => { view.element.style.bottom = editor.getLineHeightInPixels() + view.element.getBoundingClientRect().height + 'px'; + view.element.style.visibility = 'visible'; }, 100); disposables.add( @@ -278,7 +325,7 @@ module.exports = class SignatureHelpManager { } /** - * [unmountDataTip description] + * unmounts / hides the most recent data tip view component */ unmountDataTip () { if (this.signatureHelpDisposables) { @@ -286,29 +333,4 @@ module.exports = class SignatureHelpManager { } this.signatureHelpDisposables = null; } - - /** - * [makeHtmlFromMarkedStrings description] - * @param {AtomIDE.Signature} markedStrings [description] - * @param {String} grammarName [description] - * @return {String} [description] - */ - makeHtmlFromSignature(signature, grammarName) { - let result = []; - - const preElem = document.createElement('pre'); - const codeElem = document.createElement('code'); - codeElem.classList.add(grammarName); - codeElem.innerText = signature.label; - preElem.appendChild(codeElem); - - result.push(preElem.outerHTML); - - if (signature.documentation) { - const markdownDoc = signature.documentation.kind ? signature.documentation.value : signature.documentation; - result.push(`

${markdownDoc}

`); - } - - return result.join(''); - } } diff --git a/lib/signature-help-view.js b/lib/signature-help-view.js index 704a6bd..0a7bdae 100644 --- a/lib/signature-help-view.js +++ b/lib/signature-help-view.js @@ -5,12 +5,19 @@ const ReactDOMServer = require('react-dom/server'); const etch = require('etch'); const createDOMPurify = require('dompurify'); /** - * [domPurify description] + * a reference to the DOMpurify function to make safe HTML strings * @type {DOMPurify} */ const domPurify = createDOMPurify(); +/** + * an etch component that can host already prepared HTML text + */ class HtmlView { + /** + * creates the HTML view component and hands over the HTML to embedd + * @param {String} html the HTML string to embedd into the HTML view component + */ constructor({ html }) { this.rootElement = document.createElement('div'); this.rootElement.className = "signature-marked-container"; @@ -20,73 +27,142 @@ class HtmlView { } } + /** + * cleanup the HTML view component + */ destroy() { this.rootElement.removeEventListener("wheel", this.onMouseWheel); } + /** + * returns the root element of the HTML view component + * @return {HTMLElement} the root element wrapping the HTML content + */ get element() { return this.rootElement; } + /** + * handles the mouse wheel event to enable scrolling over long text + * @param {MouseWheelEvent} evt the mouse wheel event being triggered + */ onMouseWheel(evt) { evt.stopPropagation(); } } +/** + * an etch component that hosts a code snippet incl. syntax highlighting + */ +class SnippetView { + /** + * creates a snippet view component handing in the snippet + * @param {String} snippet the code snippet to be embedded + */ + constructor({ snippet }) { + this.rootElement = document.createElement('div'); + this.rootElement.className = "signature-container"; + if (snippet) { + this.rootElement.innerHTML = domPurify.sanitize(snippet); + } + } + + /** + * returns the root element of the snippet view component + * @return {HTMLElement} the root element wrapping the HTML content + */ + get element() { + return this.rootElement; + } +} + +/** + * an etch component that can host an externally given React component + */ class ReactView { + /** + * creates a React view component handing over the React code to be rendered + * @param {String} component the React component to be rendered + */ constructor({ component }) { - this.rootElement = document.createElement('span') + this.rootElement = document.createElement('span'); if (component) { - this.rootElement.innerHTML = domPurify.sanitize(ReactDOMServer.renderToStaticMarkup(component())) + this.rootElement.innerHTML = domPurify.sanitize(ReactDOMServer.renderToStaticMarkup(component())); } } + /** + * returns the root element of the React view component + * @return {HTMLElement} the root element wrapping the HTML content + */ get element() { - return this.rootElement + return this.rootElement; } } +/** + * an etch component for a signature help + */ module.exports = class SignatureHelpView { - // Required: Define an ordinary constructor to initialize your component. - constructor(properties) { + /** + * creates a data tip view component + * @param {any} properties the properties of this data tip view + * @param {Array} children potential child nodes of this data tip view + */ + constructor(properties, children) { this.properties = properties; + this.children = children || []; + this.updateChildren(); etch.initialize(this); } + /** + * renders the data tip view component + * @return {JSX.Element} the data tip view element + */ render() { - if (this.properties.reactView) { - return ( -
- -
- ); - } - else if (this.properties.htmlView) { - return ( -
- -
- ); - } - else { - return ( -
- { this.children } -
- ); - } + return ( +
+ {this.children} +
+ ); } + /** + * updates the internal state of the data tip view + */ update(props, children) { // perform custom update logic here... // then call `etch.update`, which is async and returns a promise + this.properties = props; + this.children = children || []; + this.updateChildren(); return etch.update(this) } // Optional: Destroy the component. Async/await syntax is pretty but optional. + /** + * clean up the data tip view + * @return {Promise} a promise object to keep track of the asynchronous operation + */ async destroy() { - // call etch.destroy to remove the element and destroy child components await etch.destroy(this) - // then perform custom teardown logic here... + } + + /** + * internal helper function to figure out the structure of the signature help view + * to be rendered + */ + updateChildren() { + const { component, snippet, html} = this.properties; + if (component) { + this.children.push(); + } + if (snippet) { + this.children.push(); + } + if (html) { + this.children.push(); + } } } diff --git a/package.json b/package.json index 7291c44..34bc65d 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "consumedServices": { "markdown-renderer": { "versions": { - "0.1.0": "consumeMarkdownRenderer" + "1.0.0": "consumeMarkdownRenderer" } } } diff --git a/styles/atom-ide-signature-help-marked.less b/styles/atom-ide-signature-help-marked.less index 755ca66..05bfc6d 100644 --- a/styles/atom-ide-signature-help-marked.less +++ b/styles/atom-ide-signature-help-marked.less @@ -9,7 +9,7 @@ color: @syntax-text-color; font-family: var(--editor-font-family); font-size: var(--editor-font-size); - max-height: 480px; + max-height: 400px; max-width: 64em; overflow: auto; white-space: normal; diff --git a/styles/atom-ide-signature-help.less b/styles/atom-ide-signature-help.less index d3d3ea1..fb45095 100644 --- a/styles/atom-ide-signature-help.less +++ b/styles/atom-ide-signature-help.less @@ -13,6 +13,8 @@ max-height: 480px; max-width: 64em; overflow: none; + display: block; + visibility: hidden; p { margin-left: 8px; @@ -30,16 +32,23 @@ } .signature-container { - background-color: @syntax-background-color; - display: flex; - position: relative; - max-height: 480px; - max-width: 64em; - transition: background-color 0.15s ease; - padding: 8px; - white-space: pre-wrap; + color: @syntax-text-color; font-family: var(--editor-font-family); font-size: var(--editor-font-size); + max-height: 480px; + max-width: 64em; + overflow: auto; + white-space: normal; + + // Avoid excess internal padding from markdown blocks. + :first-child { + margin-top: 0; + } + + :last-child { + margin-bottom: 0; + } + &:hover { background-color: mix(@syntax-background-color, @syntax-selection-color, 50%); } @@ -47,6 +56,13 @@ &:not(:last-of-type) { border-bottom: 1px solid fade(@syntax-cursor-color, 10%); } + + pre { + font-family: var(--editor-font-family); + font-size: var(--editor-font-size); + margin-bottom: 8px; + border-radius: 0; + } } .signature-content { diff --git a/typings/atom-ide-community.d.ts b/typings/atom-ide-community.d.ts new file mode 100644 index 0000000..c84cb16 --- /dev/null +++ b/typings/atom-ide-community.d.ts @@ -0,0 +1,5 @@ +declare module 'atom-ide' { + export interface MarkdownService { + render (markdownText: string, grammar: string) => Promise; + } +} diff --git a/typings/atom-ide.d.ts b/typings/atom-ide.d.ts index 3fe0b11..e8e687e 100644 --- a/typings/atom-ide.d.ts +++ b/typings/atom-ide.d.ts @@ -1,5 +1,6 @@ // @ts-check /// +/// import * as AtomIDE from 'atom-ide'; export = AtomIDE;