adobe/brackets

View on GitHub
src/features/ParameterHintsManager.js

Summary

Maintainability
B
5 hrs
Test Coverage
/*
 * Copyright (c) 2019 - present Adobe. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 *
 */

/* eslint max-len: ["error", { "code": 200 }]*/
define(function (require, exports, module) {
    "use strict";

    var _ = require("thirdparty/lodash");

    var Commands = require("command/Commands"),
        AppInit = require("utils/AppInit"),
        CommandManager = require("command/CommandManager"),
        EditorManager = require("editor/EditorManager"),
        Menus = require("command/Menus"),
        KeyEvent = require("utils/KeyEvent"),
        Strings = require("strings"),
        ProviderRegistrationHandler = require("features/PriorityBasedRegistration").RegistrationHandler;


    /** @const {string} Show Function Hint command ID */
    var SHOW_PARAMETER_HINT_CMD_ID = "showParameterHint", // string must MATCH string in native code (brackets_extensions)
        hintContainerHTML = require("text!htmlContent/parameter-hint-template.html"),
        KeyboardPrefs = {
            "showParameterHint": [
                {
                    "key": "Ctrl-Shift-Space"
                },
                {
                    "key": "Ctrl-Shift-Space",
                    "platform": "mac"
                }
            ]
        };

    var $hintContainer, // function hint container
        $hintContent, // function hint content holder
        hintState = {},
        lastChar = null,
        sessionEditor = null,
        keyDownEditor = null;

    // Constants
    var POINTER_TOP_OFFSET = 4, // Size of margin + border of hint.
        POSITION_BELOW_OFFSET = 4; // Amount to adjust to top position when the preview bubble is below the text

    // keep jslint from complaining about handleCursorActivity being used before
    // it was defined.
    var handleCursorActivity;

    var _providerRegistrationHandler = new ProviderRegistrationHandler(),
        registerHintProvider = _providerRegistrationHandler.registerProvider.bind(_providerRegistrationHandler),
        removeHintProvider = _providerRegistrationHandler.removeProvider.bind(_providerRegistrationHandler);

    /**
     * Position a function hint.
     *
     * @param {number} xpos
     * @param {number} ypos
     * @param {number} ybot
     */
    function positionHint(xpos, ypos, ybot) {
        var hintWidth = $hintContainer.width(),
            hintHeight = $hintContainer.height(),
            top = ypos - hintHeight - POINTER_TOP_OFFSET,
            left = xpos,
            $editorHolder = $("#editor-holder"),
            editorLeft;

        if ($editorHolder.offset() === undefined) {
            // this happens in jasmine tests that run
            // without a windowed document.
            return;
        }

        editorLeft = $editorHolder.offset().left;
        left = Math.max(left, editorLeft);
        left = Math.min(left, editorLeft + $editorHolder.width() - hintWidth);

        if (top < 0) {
            $hintContainer.removeClass("preview-bubble-above");
            $hintContainer.addClass("preview-bubble-below");
            top = ybot + POSITION_BELOW_OFFSET;
            $hintContainer.offset({
                left: left,
                top: top
            });
        } else {
            $hintContainer.removeClass("preview-bubble-below");
            $hintContainer.addClass("preview-bubble-above");
            $hintContainer.offset({
                left: left,
                top: top - POINTER_TOP_OFFSET
            });
        }
    }

    /**
     * Format the given parameter array. Handles separators between
     * parameters, syntax for optional parameters, and the order of the
     * parameter type and parameter name.
     *
     * @param {!Array.<{name: string, type: string, isOptional: boolean}>} params -
     * array of parameter descriptors
     * @param {function(string)=} appendSeparators - callback function to append separators.
     * The separator is passed to the callback.
     * @param {function(string, number)=} appendParameter - callback function to append parameter.
     * The formatted parameter type and name is passed to the callback along with the
     * current index of the parameter.
     * @param {boolean=} typesOnly - only show parameter types. The
     * default behavior is to include both parameter names and types.
     * @return {string} - formatted parameter hint
     */
    function _formatParameterHint(params, appendSeparators, appendParameter, typesOnly) {
        var result = "",
            pendingOptional = false;

        appendParameter("(", "", -1);
        params.forEach(function (value, i) {
            var param = value.label || value.type,
                documentation = value.documentation,
                separators = "";

            if (value.isOptional) {
                // if an optional param is following by an optional parameter, then
                // terminate the bracket. Otherwise enclose a required parameter
                // in the same bracket.
                if (pendingOptional) {
                    separators += "]";
                }

                pendingOptional = true;
            }

            if (i > 0) {
                separators += ", ";
            }

            if (value.isOptional) {
                separators += "[";
            }

            if (appendSeparators) {
                appendSeparators(separators);
            }

            result += separators;

            if (!typesOnly && value.name) {
                param += " " + value.name;
            }

            if (appendParameter) {
                appendParameter(param, documentation, i);
            }

            result += param;

        });

        if (pendingOptional) {
            if (appendSeparators) {
                appendSeparators("]");
            }

            result += "]";
        }
        appendParameter(")", "", -1);

        return result;
    }

    /**
     *  Bold the parameter at the caret.
     *
     *  @param {{inFunctionCall: boolean, functionCallPos: {line: number, ch: number}}} functionInfo -
     *  tells if the caret is in a function call and the position
     *  of the function call.
     */
    function formatHint(hints) {
        $hintContent.empty();
        $hintContent.addClass("brackets-hints");

        function appendSeparators(separators) {
            $hintContent.append(separators);
        }

        function appendParameter(param, documentation, index) {
            if (hints.currentIndex === index) {
                $hintContent.append($("<span>")
                    .append(_.escape(param))
                    .addClass("current-parameter"));
            } else {
                $hintContent.append($("<span>")
                    .append(_.escape(param))
                    .addClass("parameter"));
            }
        }

        if (hints.parameters.length > 0) {
            _formatParameterHint(hints.parameters, appendSeparators, appendParameter);
        } else {
            $hintContent.append(_.escape(Strings.NO_ARGUMENTS));
        }
    }

    /**
     * Dismiss the function hint.
     *
     */
    function dismissHint(editor) {
        if (hintState.visible) {
            $hintContainer.hide();
            $hintContent.empty();
            hintState = {};

            if (editor) {
                editor.off("cursorActivity.ParameterHinting", handleCursorActivity);
                sessionEditor = null;
            } else if (sessionEditor) {
                sessionEditor.off("cursorActivity.ParameterHinting", handleCursorActivity);
                sessionEditor = null;
            }
        }
    }

    /**
     * Pop up a function hint on the line above the caret position.
     *
     * @param {object=} editor - current Active Editor
     * @param {boolean} True if hints are invoked through cursor activity.
     * @return {jQuery.Promise} - The promise will not complete until the
     *      hint has completed. Returns null, if the function hint is already
     *      displayed or there is no function hint at the cursor.
     *
     */
    function popUpHint(editor, explicit, onCursorActivity) {
        var request = null;
        var $deferredPopUp = $.Deferred();
        var sessionProvider = null;

        dismissHint(editor);
        // Find a suitable provider, if any
        var language = editor.getLanguageForSelection(),
            enabledProviders = _providerRegistrationHandler.getProvidersForLanguageId(language.getId());

        enabledProviders.some(function (item, index) {
            if (item.provider.hasParameterHints(editor, lastChar)) {
                sessionProvider = item.provider;
                return true;
            }
        });

        if (sessionProvider) {
            request = sessionProvider.getParameterHints(explicit, onCursorActivity);
        }

        if (request) {
            request.done(function (parameterHint) {
                var cm = editor._codeMirror,
                    pos = parameterHint.functionCallPos || editor.getCursorPos();

                pos = cm.charCoords(pos);
                formatHint(parameterHint);

                $hintContainer.show();
                positionHint(pos.left, pos.top, pos.bottom);
                hintState.visible = true;

                sessionEditor = editor;
                editor.on("cursorActivity.ParameterHinting", handleCursorActivity);
                $deferredPopUp.resolveWith(null);
            }).fail(function () {
                hintState = {};
            });
        }

        return $deferredPopUp;
    }

    /**
     *  Show the parameter the cursor is on in bold when the cursor moves.
     *  Dismiss the pop up when the cursor moves off the function.
     */
    handleCursorActivity = function (event, editor) {
        if (editor) {
            popUpHint(editor, false, true);
        } else {
            dismissHint();
        }
    };

    /**
     * Install function hint listeners.
     *
     * @param {Editor} editor - editor context on which to listen for
     *      changes
     */
    function installListeners(editor) {
        editor.on("keydown.ParameterHinting", function (event, editor, domEvent) {
                if (domEvent.keyCode === KeyEvent.DOM_VK_ESCAPE) {
                    dismissHint(editor);
                }
            }).on("scroll.ParameterHinting", function () {
                dismissHint(editor);
            })
            .on("editorChange.ParameterHinting", _handleChange)
            .on("keypress.ParameterHinting", _handleKeypressEvent);
    }

    /**
     * Clean up after installListeners()
     * @param {!Editor} editor
     */
    function uninstallListeners(editor) {
        editor.off(".ParameterHinting");
    }

    function _handleKeypressEvent(jqEvent, editor, event) {
        keyDownEditor = editor;
        // Last inserted character, used later by handleChange
        lastChar = String.fromCharCode(event.charCode);
    }

    /**
     * Start a new implicit hinting session, or update the existing hint list.
     * Called by the editor after handleKeyEvent, which is responsible for setting
     * the lastChar.
     *
     * @param {Event} event
     * @param {Editor} editor
     * @param {{from: Pos, to: Pos, text: Array, origin: string}} changeList
     */
    function _handleChange(event, editor, changeList) {
        if (lastChar && (lastChar === '(' || lastChar === ',') && editor === keyDownEditor) {
            keyDownEditor = null;
            popUpHint(editor);
        }
    }

    function activeEditorChangeHandler(event, current, previous) {

        if (previous) {
            //Removing all old Handlers
            previous.document
                .off("languageChanged.ParameterHinting");
            uninstallListeners(previous);
        }

        if (current) {
            current.document
                .on("languageChanged.ParameterHinting", function () {
                    // If current doc's language changed, reset our state by treating it as if the user switched to a
                    // different document altogether
                    uninstallListeners(current);
                    installListeners(current);
                });
            installListeners(current);
        }
    }

    /**
     * Show a parameter hint in its own pop-up.
     *
     */
    function handleShowParameterHint() {
        var editor = EditorManager.getActiveEditor();
        // Pop up function hint
        popUpHint(editor, true, false);
    }

    AppInit.appReady(function () {
        CommandManager.register(Strings.CMD_SHOW_PARAMETER_HINT, SHOW_PARAMETER_HINT_CMD_ID, handleShowParameterHint);

        // Add the menu items
        var menu = Menus.getMenu(Menus.AppMenuBar.EDIT_MENU);
        if (menu) {
            menu.addMenuItem(SHOW_PARAMETER_HINT_CMD_ID, KeyboardPrefs.showParameterHint, Menus.AFTER, Commands.SHOW_CODE_HINTS);
        }
        // Create the function hint container
        $hintContainer = $(hintContainerHTML).appendTo($("body"));
        $hintContent = $hintContainer.find(".function-hint-content-new");
        activeEditorChangeHandler(null, EditorManager.getActiveEditor(), null);

        EditorManager.on("activeEditorChange", activeEditorChangeHandler);

        CommandManager.on("beforeExecuteCommand", function (event, commandId) {
            if (commandId !== SHOW_PARAMETER_HINT_CMD_ID &&
                commandId !== Commands.SHOW_CODE_HINTS) {
                dismissHint();
            }
        });
    });

    exports.registerHintProvider = registerHintProvider;
    exports.removeHintProvider = removeHintProvider;
});