michael@0: /* vim: set ft=javascript ts=2 et sw=2 tw=80: */ michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: const {Cc, Ci, Cu} = require("chrome"); michael@0: const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; michael@0: michael@0: loader.lazyImporter(this, "Services", "resource://gre/modules/Services.jsm"); michael@0: loader.lazyImporter(this, "gDevTools", "resource:///modules/devtools/gDevTools.jsm"); michael@0: michael@0: /** michael@0: * Autocomplete popup UI implementation. michael@0: * michael@0: * @constructor michael@0: * @param nsIDOMDocument aDocument michael@0: * The document you want the popup attached to. michael@0: * @param Object aOptions michael@0: * An object consiting any of the following options: michael@0: * - panelId {String} The id for the popup panel. michael@0: * - listBoxId {String} The id for the richlistbox inside the panel. michael@0: * - position {String} The position for the popup panel. michael@0: * - theme {String} String related to the theme of the popup. michael@0: * - autoSelect {Boolean} Boolean to allow the first entry of the popup michael@0: * panel to be automatically selected when the popup shows. michael@0: * - direction {String} The direction of the text in the panel. rtl or ltr michael@0: * - onSelect {String} The select event handler for the richlistbox michael@0: * - onClick {String} The click event handler for the richlistbox. michael@0: * - onKeypress {String} The keypress event handler for the richlistitems. michael@0: */ michael@0: function AutocompletePopup(aDocument, aOptions = {}) michael@0: { michael@0: this._document = aDocument; michael@0: michael@0: this.autoSelect = aOptions.autoSelect || false; michael@0: this.position = aOptions.position || "after_start"; michael@0: this.direction = aOptions.direction || "ltr"; michael@0: michael@0: this.onSelect = aOptions.onSelect; michael@0: this.onClick = aOptions.onClick; michael@0: this.onKeypress = aOptions.onKeypress; michael@0: michael@0: let id = aOptions.panelId || "devtools_autoCompletePopup"; michael@0: let theme = aOptions.theme || "dark"; michael@0: // If theme is auto, use the devtools.theme pref michael@0: if (theme == "auto") { michael@0: theme = Services.prefs.getCharPref("devtools.theme"); michael@0: this.autoThemeEnabled = true; michael@0: // Setup theme change listener. michael@0: this._handleThemeChange = this._handleThemeChange.bind(this); michael@0: gDevTools.on("pref-changed", this._handleThemeChange); michael@0: } michael@0: // Reuse the existing popup elements. michael@0: this._panel = this._document.getElementById(id); michael@0: if (!this._panel) { michael@0: this._panel = this._document.createElementNS(XUL_NS, "panel"); michael@0: this._panel.setAttribute("id", id); michael@0: this._panel.className = "devtools-autocomplete-popup devtools-monospace " michael@0: + theme + "-theme"; michael@0: michael@0: this._panel.setAttribute("noautofocus", "true"); michael@0: this._panel.setAttribute("level", "top"); michael@0: if (!aOptions.onKeypress) { michael@0: this._panel.setAttribute("ignorekeys", "true"); michael@0: } michael@0: michael@0: let mainPopupSet = this._document.getElementById("mainPopupSet"); michael@0: if (mainPopupSet) { michael@0: mainPopupSet.appendChild(this._panel); michael@0: } michael@0: else { michael@0: this._document.documentElement.appendChild(this._panel); michael@0: } michael@0: } michael@0: else { michael@0: this._list = this._panel.firstChild; michael@0: } michael@0: michael@0: if (!this._list) { michael@0: this._list = this._document.createElementNS(XUL_NS, "richlistbox"); michael@0: this._panel.appendChild(this._list); michael@0: michael@0: // Open and hide the panel, so we initialize the API of the richlistbox. michael@0: this._panel.openPopup(null, this.position, 0, 0); michael@0: this._panel.hidePopup(); michael@0: } michael@0: michael@0: this._list.setAttribute("flex", "1"); michael@0: this._list.setAttribute("seltype", "single"); michael@0: michael@0: if (aOptions.listBoxId) { michael@0: this._list.setAttribute("id", aOptions.listBoxId); michael@0: } michael@0: this._list.className = "devtools-autocomplete-listbox " + theme + "-theme"; michael@0: michael@0: if (this.onSelect) { michael@0: this._list.addEventListener("select", this.onSelect, false); michael@0: } michael@0: michael@0: if (this.onClick) { michael@0: this._list.addEventListener("click", this.onClick, false); michael@0: } michael@0: michael@0: if (this.onKeypress) { michael@0: this._list.addEventListener("keypress", this.onKeypress, false); michael@0: } michael@0: } michael@0: exports.AutocompletePopup = AutocompletePopup; michael@0: michael@0: AutocompletePopup.prototype = { michael@0: _document: null, michael@0: _panel: null, michael@0: _list: null, michael@0: __scrollbarWidth: null, michael@0: michael@0: // Event handlers. michael@0: onSelect: null, michael@0: onClick: null, michael@0: onKeypress: null, michael@0: michael@0: /** michael@0: * Open the autocomplete popup panel. michael@0: * michael@0: * @param nsIDOMNode aAnchor michael@0: * Optional node to anchor the panel to. michael@0: * @param Number aXOffset michael@0: * Horizontal offset in pixels from the left of the node to the left michael@0: * of the popup. michael@0: * @param Number aYOffset michael@0: * Vertical offset in pixels from the top of the node to the starting michael@0: * of the popup. michael@0: */ michael@0: openPopup: function AP_openPopup(aAnchor, aXOffset = 0, aYOffset = 0) michael@0: { michael@0: this.__maxLabelLength = -1; michael@0: this._updateSize(); michael@0: this._panel.openPopup(aAnchor, this.position, aXOffset, aYOffset); michael@0: michael@0: if (this.autoSelect) { michael@0: this.selectFirstItem(); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Hide the autocomplete popup panel. michael@0: */ michael@0: hidePopup: function AP_hidePopup() michael@0: { michael@0: this._panel.hidePopup(); michael@0: }, michael@0: michael@0: /** michael@0: * Check if the autocomplete popup is open. michael@0: */ michael@0: get isOpen() { michael@0: return this._panel.state == "open" || this._panel.state == "showing"; michael@0: }, michael@0: michael@0: /** michael@0: * Destroy the object instance. Please note that the panel DOM elements remain michael@0: * in the DOM, because they might still be in use by other instances of the michael@0: * same code. It is the responsability of the client code to perform DOM michael@0: * cleanup. michael@0: */ michael@0: destroy: function AP_destroy() michael@0: { michael@0: if (this.isOpen) { michael@0: this.hidePopup(); michael@0: } michael@0: this.clearItems(); michael@0: michael@0: if (this.onSelect) { michael@0: this._list.removeEventListener("select", this.onSelect, false); michael@0: } michael@0: michael@0: if (this.onClick) { michael@0: this._list.removeEventListener("click", this.onClick, false); michael@0: } michael@0: michael@0: if (this.onKeypress) { michael@0: this._list.removeEventListener("keypress", this.onKeypress, false); michael@0: } michael@0: michael@0: if (this.autoThemeEnabled) { michael@0: gDevTools.off("pref-changed", this._handleThemeChange); michael@0: } michael@0: michael@0: this._document = null; michael@0: this._list = null; michael@0: this._panel = null; michael@0: }, michael@0: michael@0: /** michael@0: * Get the autocomplete items array. michael@0: * michael@0: * @param Number aIndex The index of the item what is wanted. michael@0: * michael@0: * @return The autocomplete item at index aIndex. michael@0: */ michael@0: getItemAtIndex: function AP_getItemAtIndex(aIndex) michael@0: { michael@0: return this._list.getItemAtIndex(aIndex)._autocompleteItem; michael@0: }, michael@0: michael@0: /** michael@0: * Get the autocomplete items array. michael@0: * michael@0: * @return array michael@0: * The array of autocomplete items. michael@0: */ michael@0: getItems: function AP_getItems() michael@0: { michael@0: let items = []; michael@0: michael@0: Array.forEach(this._list.childNodes, function(aItem) { michael@0: items.push(aItem._autocompleteItem); michael@0: }); michael@0: michael@0: return items; michael@0: }, michael@0: michael@0: /** michael@0: * Set the autocomplete items list, in one go. michael@0: * michael@0: * @param array aItems michael@0: * The list of items you want displayed in the popup list. michael@0: */ michael@0: setItems: function AP_setItems(aItems) michael@0: { michael@0: this.clearItems(); michael@0: aItems.forEach(this.appendItem, this); michael@0: michael@0: // Make sure that the new content is properly fitted by the XUL richlistbox. michael@0: if (this.isOpen) { michael@0: if (this.autoSelect) { michael@0: this.selectFirstItem(); michael@0: } michael@0: this._updateSize(); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Selects the first item of the richlistbox. Note that first item here is the michael@0: * item closes to the input element, which means that 0th index if position is michael@0: * below, and last index if position is above. michael@0: */ michael@0: selectFirstItem: function AP_selectFirstItem() michael@0: { michael@0: if (this.position.contains("before")) { michael@0: this.selectedIndex = this.itemCount - 1; michael@0: } michael@0: else { michael@0: this.selectedIndex = 0; michael@0: } michael@0: this._list.ensureIndexIsVisible(this._list.selectedIndex); michael@0: }, michael@0: michael@0: __maxLabelLength: -1, michael@0: michael@0: get _maxLabelLength() { michael@0: if (this.__maxLabelLength != -1) { michael@0: return this.__maxLabelLength; michael@0: } michael@0: michael@0: let max = 0; michael@0: for (let i = 0; i < this._list.childNodes.length; i++) { michael@0: let item = this._list.childNodes[i]._autocompleteItem; michael@0: let str = item.label; michael@0: if (item.count) { michael@0: str += (item.count + ""); michael@0: } michael@0: max = Math.max(str.length, max); michael@0: } michael@0: michael@0: this.__maxLabelLength = max; michael@0: return this.__maxLabelLength; michael@0: }, michael@0: michael@0: /** michael@0: * Update the panel size to fit the content. michael@0: * michael@0: * @private michael@0: */ michael@0: _updateSize: function AP__updateSize() michael@0: { michael@0: if (!this._panel) { michael@0: return; michael@0: } michael@0: michael@0: this._list.style.width = (this._maxLabelLength + 3) +"ch"; michael@0: this._list.ensureIndexIsVisible(this._list.selectedIndex); michael@0: }, michael@0: michael@0: /** michael@0: * Clear all the items from the autocomplete list. michael@0: */ michael@0: clearItems: function AP_clearItems() michael@0: { michael@0: // Reset the selectedIndex to -1 before clearing the list michael@0: this.selectedIndex = -1; michael@0: michael@0: while (this._list.hasChildNodes()) { michael@0: this._list.removeChild(this._list.firstChild); michael@0: } michael@0: michael@0: this.__maxLabelLength = -1; michael@0: michael@0: // Reset the panel and list dimensions. New dimensions are calculated when michael@0: // a new set of items is added to the autocomplete popup. michael@0: this._list.width = ""; michael@0: this._list.style.width = ""; michael@0: this._list.height = ""; michael@0: this._panel.width = ""; michael@0: this._panel.height = ""; michael@0: this._panel.top = ""; michael@0: this._panel.left = ""; michael@0: }, michael@0: michael@0: /** michael@0: * Getter for the index of the selected item. michael@0: * michael@0: * @type number michael@0: */ michael@0: get selectedIndex() { michael@0: return this._list.selectedIndex; michael@0: }, michael@0: michael@0: /** michael@0: * Setter for the selected index. michael@0: * michael@0: * @param number aIndex michael@0: * The number (index) of the item you want to select in the list. michael@0: */ michael@0: set selectedIndex(aIndex) { michael@0: this._list.selectedIndex = aIndex; michael@0: if (this.isOpen && this._list.ensureIndexIsVisible) { michael@0: this._list.ensureIndexIsVisible(this._list.selectedIndex); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Getter for the selected item. michael@0: * @type object michael@0: */ michael@0: get selectedItem() { michael@0: return this._list.selectedItem ? michael@0: this._list.selectedItem._autocompleteItem : null; michael@0: }, michael@0: michael@0: /** michael@0: * Setter for the selected item. michael@0: * michael@0: * @param object aItem michael@0: * The object you want selected in the list. michael@0: */ michael@0: set selectedItem(aItem) { michael@0: this._list.selectedItem = this._findListItem(aItem); michael@0: if (this.isOpen) { michael@0: this._list.ensureIndexIsVisible(this._list.selectedIndex); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Append an item into the autocomplete list. michael@0: * michael@0: * @param object aItem michael@0: * The item you want appended to the list. michael@0: * The item object can have the following properties: michael@0: * - label {String} Property which is used as the displayed value. michael@0: * - preLabel {String} [Optional] The String that will be displayed michael@0: * before the label indicating that this is the already michael@0: * present text in the input box, and label is the text michael@0: * that will be auto completed. When this property is michael@0: * present, |preLabel.length| starting characters will be michael@0: * removed from label. michael@0: * - count {Number} [Optional] The number to represent the count of michael@0: * autocompleted label. michael@0: */ michael@0: appendItem: function AP_appendItem(aItem) michael@0: { michael@0: let listItem = this._document.createElementNS(XUL_NS, "richlistitem"); michael@0: if (this.direction) { michael@0: listItem.setAttribute("dir", this.direction); michael@0: } michael@0: let label = this._document.createElementNS(XUL_NS, "label"); michael@0: label.setAttribute("value", aItem.label); michael@0: label.setAttribute("class", "autocomplete-value"); michael@0: if (aItem.preLabel) { michael@0: let preDesc = this._document.createElementNS(XUL_NS, "label"); michael@0: preDesc.setAttribute("value", aItem.preLabel); michael@0: preDesc.setAttribute("class", "initial-value"); michael@0: listItem.appendChild(preDesc); michael@0: label.setAttribute("value", aItem.label.slice(aItem.preLabel.length)); michael@0: } michael@0: listItem.appendChild(label); michael@0: if (aItem.count && aItem.count > 1) { michael@0: let countDesc = this._document.createElementNS(XUL_NS, "label"); michael@0: countDesc.setAttribute("value", aItem.count); michael@0: countDesc.setAttribute("flex", "1"); michael@0: countDesc.setAttribute("class", "autocomplete-count"); michael@0: listItem.appendChild(countDesc); michael@0: } michael@0: listItem._autocompleteItem = aItem; michael@0: michael@0: this._list.appendChild(listItem); michael@0: }, michael@0: michael@0: /** michael@0: * Find the richlistitem element that belongs to an item. michael@0: * michael@0: * @private michael@0: * michael@0: * @param object aItem michael@0: * The object you want found in the list. michael@0: * michael@0: * @return nsIDOMNode|null michael@0: * The nsIDOMNode that belongs to the given item object. This node is michael@0: * the richlistitem element. michael@0: */ michael@0: _findListItem: function AP__findListItem(aItem) michael@0: { michael@0: for (let i = 0; i < this._list.childNodes.length; i++) { michael@0: let child = this._list.childNodes[i]; michael@0: if (child._autocompleteItem == aItem) { michael@0: return child; michael@0: } michael@0: } michael@0: return null; michael@0: }, michael@0: michael@0: /** michael@0: * Remove an item from the popup list. michael@0: * michael@0: * @param object aItem michael@0: * The item you want removed. michael@0: */ michael@0: removeItem: function AP_removeItem(aItem) michael@0: { michael@0: let item = this._findListItem(aItem); michael@0: if (!item) { michael@0: throw new Error("Item not found!"); michael@0: } michael@0: this._list.removeChild(item); michael@0: }, michael@0: michael@0: /** michael@0: * Getter for the number of items in the popup. michael@0: * @type number michael@0: */ michael@0: get itemCount() { michael@0: return this._list.childNodes.length; michael@0: }, michael@0: michael@0: /** michael@0: * Getter for the height of each item in the list. michael@0: * michael@0: * @private michael@0: * michael@0: * @type number michael@0: */ michael@0: get _itemHeight() { michael@0: return this._list.selectedItem.clientHeight; michael@0: }, michael@0: michael@0: /** michael@0: * Select the next item in the list. michael@0: * michael@0: * @return object michael@0: * The newly selected item object. michael@0: */ michael@0: selectNextItem: function AP_selectNextItem() michael@0: { michael@0: if (this.selectedIndex < (this.itemCount - 1)) { michael@0: this.selectedIndex++; michael@0: } michael@0: else { michael@0: this.selectedIndex = 0; michael@0: } michael@0: michael@0: return this.selectedItem; michael@0: }, michael@0: michael@0: /** michael@0: * Select the previous item in the list. michael@0: * michael@0: * @return object michael@0: * The newly-selected item object. michael@0: */ michael@0: selectPreviousItem: function AP_selectPreviousItem() michael@0: { michael@0: if (this.selectedIndex > 0) { michael@0: this.selectedIndex--; michael@0: } michael@0: else { michael@0: this.selectedIndex = this.itemCount - 1; michael@0: } michael@0: michael@0: return this.selectedItem; michael@0: }, michael@0: michael@0: /** michael@0: * Select the top-most item in the next page of items or michael@0: * the last item in the list. michael@0: * michael@0: * @return object michael@0: * The newly-selected item object. michael@0: */ michael@0: selectNextPageItem: function AP_selectNextPageItem() michael@0: { michael@0: let itemsPerPane = Math.floor(this._list.scrollHeight / this._itemHeight); michael@0: let nextPageIndex = this.selectedIndex + itemsPerPane + 1; michael@0: this.selectedIndex = nextPageIndex > this.itemCount - 1 ? michael@0: this.itemCount - 1 : nextPageIndex; michael@0: michael@0: return this.selectedItem; michael@0: }, michael@0: michael@0: /** michael@0: * Select the bottom-most item in the previous page of items, michael@0: * or the first item in the list. michael@0: * michael@0: * @return object michael@0: * The newly-selected item object. michael@0: */ michael@0: selectPreviousPageItem: function AP_selectPreviousPageItem() michael@0: { michael@0: let itemsPerPane = Math.floor(this._list.scrollHeight / this._itemHeight); michael@0: let prevPageIndex = this.selectedIndex - itemsPerPane - 1; michael@0: this.selectedIndex = prevPageIndex < 0 ? 0 : prevPageIndex; michael@0: michael@0: return this.selectedItem; michael@0: }, michael@0: michael@0: /** michael@0: * Focuses the richlistbox. michael@0: */ michael@0: focus: function AP_focus() michael@0: { michael@0: this._list.focus(); michael@0: }, michael@0: michael@0: /** michael@0: * Manages theme switching for the popup based on the devtools.theme pref. michael@0: * michael@0: * @private michael@0: * michael@0: * @param String aEvent michael@0: * The name of the event. In this case, "pref-changed". michael@0: * @param Object aData michael@0: * An object passed by the emitter of the event. In this case, the michael@0: * object consists of three properties: michael@0: * - pref {String} The name of the preference that was modified. michael@0: * - newValue {Object} The new value of the preference. michael@0: * - oldValue {Object} The old value of the preference. michael@0: */ michael@0: _handleThemeChange: function AP__handleThemeChange(aEvent, aData) michael@0: { michael@0: if (aData.pref == "devtools.theme") { michael@0: this._panel.classList.toggle(aData.oldValue + "-theme", false); michael@0: this._panel.classList.toggle(aData.newValue + "-theme", true); michael@0: this._list.classList.toggle(aData.oldValue + "-theme", false); michael@0: this._list.classList.toggle(aData.newValue + "-theme", true); michael@0: } michael@0: }, michael@0: };