From 3438230b4133ddba8d6b82568917dea21bda339d Mon Sep 17 00:00:00 2001 From: Girish Sharma Date: Tue, 29 Jan 2013 03:02:35 +0530 Subject: [PATCH] Bug 831693 - Experiment with auto completion in Inspector Searchbox, r=paul --HG-- rename : browser/devtools/webconsole/AutocompletePopup.jsm => browser/devtools/shared/AutocompletePopup.jsm --- browser/devtools/inspector/InspectorPanel.jsm | 105 +--- browser/devtools/inspector/SelectorSearch.jsm | 549 ++++++++++++++++++ browser/devtools/inspector/inspector.css | 55 ++ browser/devtools/inspector/test/Makefile.in | 4 + .../browser_inspector_bug_650804_search.js | 4 +- ...ector_bug_831693_combinator_suggestions.js | 113 ++++ ...r_inspector_bug_831693_input_suggestion.js | 115 ++++ ...spector_bug_831693_search_suggestions.html | 27 + ...r_bug_831693_searchbox_panel_navigation.js | 156 +++++ .../AutocompletePopup.jsm | 259 ++++++--- browser/devtools/webconsole/Makefile.in | 1 - browser/devtools/webconsole/webconsole.js | 14 +- .../browser/devtools/webconsole.properties | 4 - 13 files changed, 1247 insertions(+), 159 deletions(-) create mode 100644 browser/devtools/inspector/SelectorSearch.jsm create mode 100644 browser/devtools/inspector/test/browser_inspector_bug_831693_combinator_suggestions.js create mode 100644 browser/devtools/inspector/test/browser_inspector_bug_831693_input_suggestion.js create mode 100644 browser/devtools/inspector/test/browser_inspector_bug_831693_search_suggestions.html create mode 100644 browser/devtools/inspector/test/browser_inspector_bug_831693_searchbox_panel_navigation.js rename browser/devtools/{webconsole => shared}/AutocompletePopup.jsm (53%) diff --git a/browser/devtools/inspector/InspectorPanel.jsm b/browser/devtools/inspector/InspectorPanel.jsm index eb6d99db0427..25990c4c6c30 100644 --- a/browser/devtools/inspector/InspectorPanel.jsm +++ b/browser/devtools/inspector/InspectorPanel.jsm @@ -24,6 +24,8 @@ XPCOMUtils.defineLazyModuleGetter(this, "Highlighter", "resource:///modules/devtools/Highlighter.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "ToolSidebar", "resource:///modules/devtools/Sidebar.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SelectorSearch", + "resource:///modules/devtools/SelectorSearch.jsm"); const LAYOUT_CHANGE_TIMER = 250; @@ -62,16 +64,6 @@ InspectorPanel.prototype = { this.nodemenu.addEventListener("popupshowing", this._setupNodeMenu, true); this.nodemenu.addEventListener("popuphiding", this._resetNodeMenu, true); - // Initialize the search related items - this.searchBox = this.panelDoc.getElementById("inspector-searchbox"); - this._lastSearched = null; - this._searchResults = null; - this._searchIndex = 0; - this._onHTMLSearch = this._onHTMLSearch.bind(this); - this._onSearchKeypress = this._onSearchKeypress.bind(this); - this.searchBox.addEventListener("command", this._onHTMLSearch, true); - this.searchBox.addEventListener("keypress", this._onSearchKeypress, true); - // Create an empty selection this._selection = new Selection(); this.onNewSelection = this.onNewSelection.bind(this); @@ -153,6 +145,7 @@ InspectorPanel.prototype = { deferred.resolve(this); }.bind(this)); + this.setupSearchBox(); this.setupSidebar(); return deferred.promise; @@ -195,6 +188,24 @@ InspectorPanel.prototype = { this.isDirty = true; }, + /** + * Hooks the searchbar to show result and auto completion suggestions. + */ + setupSearchBox: function InspectorPanel_setupSearchBox() { + // Initiate the selectors search object. + let setNodeFunction = function(node) { + this.selection.setNode(node, "selectorsearch"); + }.bind(this); + if (this.searchSuggestions) { + this.searchSuggestions.destroy(); + this.searchSuggestions = null; + } + this.searchBox = this.panelDoc.getElementById("inspector-searchbox"); + this.searchSuggestions = new SelectorSearch(this.browser.contentDocument, + this.searchBox, + setNodeFunction); + }, + /** * Build the sidebar. */ @@ -256,6 +267,7 @@ InspectorPanel.prototype = { self.selection.setNode(newWindow.document.documentElement, "navigateaway"); } self._initMarkup(); + self.setupSearchBox(); } if (newWindow.document.readyState == "loading") { @@ -400,9 +412,8 @@ InspectorPanel.prototype = { this.nodemenu.removeEventListener("popupshowing", this._setupNodeMenu, true); this.nodemenu.removeEventListener("popuphiding", this._resetNodeMenu, true); - this.searchBox.removeEventListener("command", this._onHTMLSearch, true); - this.searchBox.removeEventListener("keypress", this._onSearchKeypress, true); this.breadcrumbs.destroy(); + this.searchSuggestions.destroy(); this.selection.off("new-node", this.onNewSelection); this.selection.off("before-new-node", this.onBeforeNewSelection); this.selection.off("detached", this.onDetached); @@ -414,82 +425,14 @@ InspectorPanel.prototype = { this.panelDoc = null; this.panelWin = null; this.breadcrumbs = null; + this.searchSuggestions = null; this.lastNodemenuItem = null; this.nodemenu = null; - this.searchBox = null; this.highlighter = null; - this._searchResults = null; return Promise.resolve(null); }, - /** - * The command callback for the HTML search box. This function is - * automatically invoked as the user is typing. - */ - _onHTMLSearch: function InspectorPanel__onHTMLSearch() { - let query = this.searchBox.value; - if (query == this._lastSearched) { - return; - } - this._lastSearched = query; - this._searchIndex = 0; - - if (query.length == 0) { - this.searchBox.removeAttribute("filled"); - this.searchBox.classList.remove("devtools-no-search-result"); - return; - } - - this.searchBox.setAttribute("filled", true); - this._searchResults = this.browser.contentDocument.querySelectorAll(query); - if (this._searchResults.length > 0) { - this.searchBox.classList.remove("devtools-no-search-result"); - this.cancelLayoutChange(); - this.selection.setNode(this._searchResults[0]); - } else { - this.searchBox.classList.add("devtools-no-search-result"); - } - }, - - /** - * Search for the search box value as a query selector. - */ - _onSearchKeypress: function InspectorPanel__onSearchKeypress(aEvent) { - let query = this.searchBox.value; - switch(aEvent.keyCode) { - case aEvent.DOM_VK_ENTER: - case aEvent.DOM_VK_RETURN: - if (query == this._lastSearched) { - this._searchIndex = (this._searchIndex + 1) % this._searchResults.length; - } else { - this._onHTMLSearch(); - return; - } - break; - - case aEvent.DOM_VK_UP: - if (--this._searchIndex < 0) { - this._searchIndex = this._searchResults.length - 1; - } - break; - - case aEvent.DOM_VK_DOWN: - this._searchIndex = (this._searchIndex + 1) % this._searchResults.length; - break; - - default: - return; - } - - aEvent.preventDefault(); - aEvent.stopPropagation(); - this.cancelLayoutChange(); - if (this._searchResults.length > 0) { - this.selection.setNode(this._searchResults[this._searchIndex]); - } - }, - /** * Show the node menu. */ diff --git a/browser/devtools/inspector/SelectorSearch.jsm b/browser/devtools/inspector/SelectorSearch.jsm new file mode 100644 index 000000000000..c0d7fb820420 --- /dev/null +++ b/browser/devtools/inspector/SelectorSearch.jsm @@ -0,0 +1,549 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AutocompletePopup", + "resource:///modules/devtools/AutocompletePopup.jsm"); +this.EXPORTED_SYMBOLS = ["SelectorSearch"]; + +// Maximum number of selector suggestions shown in the panel. +const MAX_SUGGESTIONS = 15; + +/** + * Converts any input box on a page to a CSS selector search and suggestion box. + * + * @constructor + * @param nsIDOMDocument aContentDocument + * The content document which inspector is attached to. + * @param nsiInputElement aInputNode + * The input element to which the panel will be attached and from where + * search input will be taken. + * @param Function aCallback + * The method to callback when a search is available. + * This method is called with the matched node as the first argument. + */ +this.SelectorSearch = function(aContentDocument, aInputNode, aCallback) { + this.doc = aContentDocument; + this.callback = aCallback; + this.searchBox = aInputNode; + this.panelDoc = this.searchBox.ownerDocument; + + // initialize variables. + this._lastSearched = null; + this._lastValidSearch = ""; + this._lastToLastValidSearch = null; + this._searchResults = null; + this._searchSuggestions = {}; + this._searchIndex = 0; + + // bind! + this._showPopup = this._showPopup.bind(this); + this._onHTMLSearch = this._onHTMLSearch.bind(this); + this._onSearchKeypress = this._onSearchKeypress.bind(this); + this._onListBoxKeypress = this._onListBoxKeypress.bind(this); + + // Options for the AutocompletePopup. + let options = { + panelId: "inspector-searchbox-panel", + listBoxId: "searchbox-panel-listbox", + fixedWidth: true, + autoSelect: true, + position: "before_start", + direction: "ltr", + onClick: this._onListBoxKeypress, + onKeypress: this._onListBoxKeypress, + }; + this.searchPopup = new AutocompletePopup(this.panelDoc, options); + + // event listeners. + this.searchBox.addEventListener("command", this._onHTMLSearch, true); + this.searchBox.addEventListener("keypress", this._onSearchKeypress, true); +} + +this.SelectorSearch.prototype = { + + // The possible states of the query. + States: { + CLASS: "class", + ID: "id", + TAG: "tag", + }, + + // The current state of the query. + _state: null, + + // The query corresponding to last state computation. + _lastStateCheckAt: null, + + /** + * Computes the state of the query. State refers to whether the query + * currently requires a class suggestion, or a tag, or an Id suggestion. + * This getter will effectively compute the state by traversing the query + * character by character each time the query changes. + * + * @example + * '#f' requires an Id suggestion, so the state is States.ID + * 'div > .foo' requires class suggestion, so state is States.CLASS + */ + get state() { + if (!this.searchBox || !this.searchBox.value) { + return null; + } + + let query = this.searchBox.value; + if (this._lastStateCheckAt == query) { + // If query is the same, return early. + return this._state; + } + this._lastStateCheckAt = query; + + this._state = null; + let subQuery = ""; + // Now we iterate over the query and decide the state character by character. + // The logic here is that while iterating, the state can go from one to + // another with some restrictions. Like, if the state is Class, then it can + // never go to Tag state without a space or '>' character; Or like, a Class + // state with only '.' cannot go to an Id state without any [a-zA-Z] after + // the '.' which means that '.#' is a selector matching a class name '#'. + // Similarily for '#.' which means a selctor matching an id '.'. + for (let i = 1; i <= query.length; i++) { + // Calculate the state. + subQuery = query.slice(0, i); + let [secondLastChar, lastChar] = subQuery.slice(-2); + switch (this._state) { + case null: + // This will happen only in the first iteration of the for loop. + lastChar = secondLastChar; + case this.States.TAG: + this._state = lastChar == "." + ? this.States.CLASS + : lastChar == "#" + ? this.States.ID + : this.States.TAG; + break; + + case this.States.CLASS: + if (subQuery.match(/[\.]+[^\.]*$/)[0].length > 2) { + // Checks whether the subQuery has atleast one [a-zA-Z] after the '.'. + this._state = (lastChar == " " || lastChar == ">") + ? this.States.TAG + : lastChar == "#" + ? this.States.ID + : this.States.CLASS; + } + break; + + case this.States.ID: + if (subQuery.match(/[#]+[^#]*$/)[0].length > 2) { + // Checks whether the subQuery has atleast one [a-zA-Z] after the '#'. + this._state = (lastChar == " " || lastChar == ">") + ? this.States.TAG + : lastChar == "." + ? this.States.CLASS + : this.States.ID; + } + break; + } + } + return this._state; + }, + + /** + * Removes event listeners and cleans up references. + */ + destroy: function SelectorSearch_destroy() { + // event listeners. + this.searchBox.removeEventListener("command", this._onHTMLSearch, true); + this.searchBox.removeEventListener("keypress", this._onSearchKeypress, true); + this.searchPopup.destroy(); + this.searchPopup = null; + this.searchBox = null; + this.doc = null; + this.panelDoc = null; + this._searchResults = null; + this._searchSuggestions = null; + this.callback = null; + }, + + /** + * The command callback for the input box. This function is automatically + * invoked as the user is typing if the input box type is search. + */ + _onHTMLSearch: function SelectorSearch__onHTMLSearch() { + let query = this.searchBox.value; + if (query == this._lastSearched) { + return; + } + this._lastSearched = query; + this._searchIndex = 0; + + if (query.length == 0) { + this._lastValidSearch = ""; + this.searchBox.removeAttribute("filled"); + this.searchBox.classList.remove("devtools-no-search-result"); + if (this.searchPopup.isOpen) { + this.searchPopup.hidePopup(); + } + return; + } + + this.searchBox.setAttribute("filled", true); + try { + this._searchResults = this.doc.querySelectorAll(query); + } + catch (ex) { + this._searchResults = []; + } + if (this._searchResults.length > 0) { + this._lastValidSearch = query; + // Even though the selector matched atleast one node, there is still + // possibility of suggestions. + if (query.match(/[\s>+]$/)) { + // If the query has a space or '>' at the end, create a selector to match + // the children of the selector inside the search box by adding a '*'. + this._lastValidSearch += "*"; + } + else if (query.match(/[\s>+][\.#a-zA-Z][\.#>\s+]*$/)) { + // If the query is a partial descendant selector which does not matches + // any node, remove the last incomplete part and add a '*' to match + // everything. For ex, convert 'foo > b' to 'foo > *' . + let lastPart = query.match(/[\s>+][\.#a-zA-Z][^>\s+]*$/)[0]; + this._lastValidSearch = query.slice(0, -1 * lastPart.length + 1) + "*"; + } + + if (!query.slice(-1).match(/[\.#\s>+]/)) { + // Hide the popup if we have some matching nodes and the query is not + // ending with [.# >] which means that the selector is not at the + // beginning of a new class, tag or id. + if (this.searchPopup.isOpen) { + this.searchPopup.hidePopup(); + } + } + else { + this.showSuggestions(); + } + this.searchBox.classList.remove("devtools-no-search-result"); + this.callback(this._searchResults[0]); + } + else { + if (query.match(/[\s>+]$/)) { + this._lastValidSearch = query + "*"; + } + else if (query.match(/[\s>+][\.#a-zA-Z][\.#>\s+]*$/)) { + let lastPart = query.match(/[\s+>][\.#a-zA-Z][^>\s+]*$/)[0]; + this._lastValidSearch = query.slice(0, -1 * lastPart.length + 1) + "*"; + } + this.searchBox.classList.add("devtools-no-search-result"); + this.showSuggestions(); + } + }, + + /** + * Handles keypresses inside the input box. + */ + _onSearchKeypress: function SelectorSearch__onSearchKeypress(aEvent) { + let query = this.searchBox.value; + switch(aEvent.keyCode) { + case aEvent.DOM_VK_ENTER: + case aEvent.DOM_VK_RETURN: + if (query == this._lastSearched) { + this._searchIndex = (this._searchIndex + 1) % this._searchResults.length; + } + else { + this._onHTMLSearch(); + return; + } + break; + + case aEvent.DOM_VK_UP: + if (this.searchPopup.isOpen && this.searchPopup.itemCount > 0) { + this.searchPopup.focus(); + if (this.searchPopup.selectedIndex == this.searchPopup.itemCount - 1) { + this.searchPopup.selectedIndex = + Math.max(0, this.searchPopup.itemCount - 2); + } + else { + this.searchPopup.selectedIndex = this.searchPopup.itemCount - 1; + } + this.searchBox.value = this.searchPopup.selectedItem.label; + } + else if (--this._searchIndex < 0) { + this._searchIndex = this._searchResults.length - 1; + } + break; + + case aEvent.DOM_VK_DOWN: + if (this.searchPopup.isOpen && this.searchPopup.itemCount > 0) { + this.searchPopup.focus(); + this.searchPopup.selectedIndex = 0; + this.searchBox.value = this.searchPopup.selectedItem.label; + } + this._searchIndex = (this._searchIndex + 1) % this._searchResults.length; + break; + + case aEvent.DOM_VK_TAB: + if (this.searchPopup.isOpen && + this.searchPopup.getItemAtIndex(this.searchPopup.itemCount - 1) + .preLabel == query) { + this.searchPopup.selectedIndex = this.searchPopup.itemCount - 1; + this.searchBox.value = this.searchPopup.selectedItem.label; + this._onHTMLSearch(); + } + break; + + case aEvent.DOM_VK_BACK_SPACE: + case aEvent.DOM_VK_DELETE: + // need to throw away the lastValidSearch. + this._lastToLastValidSearch = null; + // This gets the most complete selector from the query. For ex. + // '.foo.ba' returns '.foo' , '#foo > .bar.baz' returns '#foo > .bar' + // '.foo +bar' returns '.foo +' and likewise. + this._lastValidSearch = (query.match(/(.*)[\.#][^\.# ]{0,}$/) || + query.match(/(.*[\s>+])[a-zA-Z][^\.# ]{0,}$/) || + ["",""])[1]; + return; + + default: + return; + } + + aEvent.preventDefault(); + aEvent.stopPropagation(); + if (this._searchResults.length > 0) { + this.callback(this._searchResults[this._searchIndex]); + } + }, + + /** + * Handles keypress and mouse click on the suggestions richlistbox. + */ + _onListBoxKeypress: function SelectorSearch__onListBoxKeypress(aEvent) { + switch(aEvent.keyCode || aEvent.button) { + case aEvent.DOM_VK_ENTER: + case aEvent.DOM_VK_RETURN: + case aEvent.DOM_VK_TAB: + case 0: // left mouse button + aEvent.stopPropagation(); + aEvent.preventDefault(); + this.searchBox.value = this.searchPopup.selectedItem.label; + this.searchBox.focus(); + this._onHTMLSearch(); + break; + + case aEvent.DOM_VK_UP: + if (this.searchPopup.selectedIndex == 0) { + this.searchPopup.selectedIndex = -1; + aEvent.stopPropagation(); + aEvent.preventDefault(); + this.searchBox.focus(); + } + else { + let index = this.searchPopup.selectedIndex; + this.searchBox.value = this.searchPopup.getItemAtIndex(index - 1).label; + } + break; + + case aEvent.DOM_VK_DOWN: + if (this.searchPopup.selectedIndex == this.searchPopup.itemCount - 1) { + this.searchPopup.selectedIndex = -1; + aEvent.stopPropagation(); + aEvent.preventDefault(); + this.searchBox.focus(); + } + else { + let index = this.searchPopup.selectedIndex; + this.searchBox.value = this.searchPopup.getItemAtIndex(index + 1).label; + } + break; + + case aEvent.DOM_VK_BACK_SPACE: + aEvent.stopPropagation(); + aEvent.preventDefault(); + this.searchBox.focus(); + if (this.searchBox.selectionStart > 0) { + this.searchBox.value = + this.searchBox.value.substring(0, this.searchBox.selectionStart - 1); + } + this._lastToLastValidSearch = null; + let query = this.searchBox.value; + this._lastValidSearch = (query.match(/(.*)[\.#][^\.# ]{0,}$/) || + query.match(/(.*[\s>+])[a-zA-Z][^\.# ]{0,}$/) || + ["",""])[1]; + this._onHTMLSearch(); + break; + } + }, + + + /** + * Populates the suggestions list and show the suggestion popup. + */ + _showPopup: function SelectorSearch__showPopup(aList, aFirstPart) { + // Sort alphabetically in increaseing order. + aList = aList.sort(); + // Sort based on count= in decreasing order. + aList = aList.sort(function([a1,a2], [b1,b2]) { + return a2 < b2; + }); + + let total = 0; + let query = this.searchBox.value; + let toLowerCase = false; + let items = []; + // In case of tagNames, change the case to small. + if (query.match(/.*[\.#][^\.#]{0,}$/) == null) { + toLowerCase = true; + } + for (let [value, count] of aList) { + // for cases like 'div ' or 'div >' or 'div+' + if (query.match(/[\s>+]$/)) { + value = query + value; + } + // for cases like 'div #a' or 'div .a' or 'div > d' and likewise + else if (query.match(/[\s>+][\.#a-zA-Z][^\s>+\.#]*$/)) { + let lastPart = query.match(/[\s>+][\.#a-zA-Z][^>\s+\.#]*$/)[0]; + value = query.slice(0, -1 * lastPart.length + 1) + value; + } + // for cases like 'div.class' or '#foo.bar' and likewise + else if (query.match(/[a-zA-Z][#\.][^#\.\s+>]*$/)) { + let lastPart = query.match(/[a-zA-Z][#\.][^#\.\s>+]*$/)[0]; + value = query.slice(0, -1 * lastPart.length + 1) + value; + } + let item = { + preLabel: query, + label: value, + count: count + }; + if (toLowerCase) { + item.label = value.toLowerCase(); + } + items.unshift(item); + if (++total > MAX_SUGGESTIONS - 1) { + break; + } + } + if (total > 0) { + this.searchPopup.setItems(items); + this.searchPopup.openPopup(this.searchBox); + } + else { + this.searchPopup.hidePopup(); + } + }, + + /** + * Suggests classes,ids and tags based on the user input as user types in the + * searchbox. + */ + showSuggestions: function SelectorSearch_showSuggestions() { + let query = this.searchBox.value; + if (this._lastValidSearch != "" && + this._lastToLastValidSearch != this._lastValidSearch) { + this._searchSuggestions = { + ids: new Map(), + classes: new Map(), + tags: new Map(), + }; + + let nodes = []; + try { + nodes = this.doc.querySelectorAll(this._lastValidSearch); + } catch (ex) {} + for (let node of nodes) { + this._searchSuggestions.ids.set(node.id, 1); + this._searchSuggestions.tags + .set(node.tagName, + (this._searchSuggestions.tags.get(node.tagName) || 0) + 1); + for (let className of node.classList) { + this._searchSuggestions.classes + .set(className, + (this._searchSuggestions.classes.get(className) || 0) + 1); + } + } + this._lastToLastValidSearch = this._lastValidSearch; + } + else if (this._lastToLastValidSearch != this._lastValidSearch) { + this._searchSuggestions = { + ids: new Map(), + classes: new Map(), + tags: new Map(), + }; + + if (query.length == 0) { + return; + } + + let nodes = null; + if (this.state == this.States.CLASS) { + nodes = this.doc.querySelectorAll("[class]"); + for (let node of nodes) { + for (let className of node.classList) { + this._searchSuggestions.classes + .set(className, + (this._searchSuggestions.classes.get(className) || 0) + 1); + } + } + } + else if (this.state == this.States.ID) { + nodes = this.doc.querySelectorAll("[id]"); + for (let node of nodes) { + this._searchSuggestions.ids.set(node.id, 1); + } + } + else if (this.state == this.States.TAG) { + nodes = this.doc.getElementsByTagName("*"); + for (let node of nodes) { + this._searchSuggestions.tags + .set(node.tagName, + (this._searchSuggestions.tags.get(node.tagName) || 0) + 1); + } + } + else { + return; + } + this._lastToLastValidSearch = this._lastValidSearch; + } + + // Filter the suggestions based on search box value. + let result = []; + let firstPart = ""; + if (this.state == this.States.TAG) { + // gets the tag that is being completed. For ex. 'div.foo > s' returns 's', + // 'di' returns 'di' and likewise. + firstPart = (query.match(/[\s>+]?([a-zA-Z]*)$/) || ["",query])[1]; + for (let [tag, count] of this._searchSuggestions.tags) { + if (tag.toLowerCase().startsWith(firstPart.toLowerCase())) { + result.push([tag, count]); + } + } + } + else if (this.state == this.States.CLASS) { + // gets the class that is being completed. For ex. '.foo.b' returns 'b' + firstPart = query.match(/\.([^\.]*)$/)[1]; + for (let [className, count] of this._searchSuggestions.classes) { + if (className.startsWith(firstPart)) { + result.push(["." + className, count]); + } + } + firstPart = "." + firstPart; + } + else if (this.state == this.States.ID) { + // gets the id that is being completed. For ex. '.foo#b' returns 'b' + firstPart = query.match(/#([^#]*)$/)[1]; + for (let [id, count] of this._searchSuggestions.ids) { + if (id.startsWith(firstPart)) { + result.push(["#" + id, 1]); + } + } + firstPart = "#" + firstPart; + } + + this._showPopup(result, firstPart); + }, +}; diff --git a/browser/devtools/inspector/inspector.css b/browser/devtools/inspector/inspector.css index fb88d9deeea4..f5d08b965041 100644 --- a/browser/devtools/inspector/inspector.css +++ b/browser/devtools/inspector/inspector.css @@ -6,3 +6,58 @@ #inspector-sidebar { min-width: 250px; } + +#inspector-searchbox-panel { + -moz-appearance: none !important; + border: 1px solid hsl(210,24%,10%); + box-shadow: 0 1px 0 hsla(209,29%,72%,.25) inset; + background-color: transparent; + background-image: linear-gradient(to bottom, hsla(209,18%,18%,0.9), hsl(210,24%,16%)); + border-radius: 3px; +} + +#searchbox-panel-listbox { + -moz-appearance: none !important; + background-color: rgba(0,0,0,0); + border-width: 0px !important; + width: 250px; + max-width: 250px; + overflow-x: hidden; +} + +#searchbox-panel-listbox > richlistitem, +#searchbox-panel-listbox > richlistitem[selected] { + overflow-x: hidden; + width: 100%; + background-color: rgba(0,0,0,0); + border-radius: 4px; +} + +#searchbox-panel-listbox:focus > richlistitem[selected], +#searchbox-panel-listbox > richlistitem:hover { + background-color: rgba(0,0,0,0.2); +} + +#searchbox-panel-listbox > richlistitem[selected] > .autocomplete-value { + color: hsl(200,100%,60%); +} + +#searchbox-panel-listbox > richlistitem > label { + color: #ddd; +} + +#searchbox-panel-listbox > richlistitem > .initial-value { + max-width: 130px; + margin-left: 15px; + margin-right: 0; +} + +#searchbox-panel-listbox > richlistitem > .autocomplete-value { + max-width: 150px; + margin: 0; + padding: 1px 0; +} + +#searchbox-panel-listbox > richlistitem > .autocomplete-count { + text-align: right; +} diff --git a/browser/devtools/inspector/test/Makefile.in b/browser/devtools/inspector/test/Makefile.in index ccf5b1a49a2b..5203bbda8c69 100644 --- a/browser/devtools/inspector/test/Makefile.in +++ b/browser/devtools/inspector/test/Makefile.in @@ -39,6 +39,10 @@ _BROWSER_FILES = \ browser_inspector_bug_817558_delete_node.js \ browser_inspector_bug_650804_search.js \ browser_inspector_bug_650804_search.html \ + browser_inspector_bug_831693_input_suggestion.js \ + browser_inspector_bug_831693_searchbox_panel_navigation.js \ + browser_inspector_bug_831693_combinator_suggestions.js \ + browser_inspector_bug_831693_search_suggestions.html \ browser_inspector_bug_835722_infobar_reappears.js \ browser_inspector_bug_840156_destroy_after_navigation.js \ head.js \ diff --git a/browser/devtools/inspector/test/browser_inspector_bug_650804_search.js b/browser/devtools/inspector/test/browser_inspector_bug_650804_search.js index 418363e814e4..17d79ea7a679 100644 --- a/browser/devtools/inspector/test/browser_inspector_bug_650804_search.js +++ b/browser/devtools/inspector/test/browser_inspector_bug_650804_search.js @@ -20,7 +20,7 @@ function test() ["v", "d1", true], ["VK_DOWN", "d2", true], ["VK_ENTER", "d1", true], - [".", "d1", true], + [".", "d1", false], ["c", "d1", false], ["1", "d2", true], ["VK_DOWN", "d2", true], @@ -30,7 +30,7 @@ function test() ["VK_BACK_SPACE", "d1", false], ["VK_BACK_SPACE", "d1", false], ["VK_BACK_SPACE", "d1", true], - [".", "d1", true], + [".", "d1", false], ["c", "d1", false], ["1", "d2", true], ["VK_DOWN", "s2", true], diff --git a/browser/devtools/inspector/test/browser_inspector_bug_831693_combinator_suggestions.js b/browser/devtools/inspector/test/browser_inspector_bug_831693_combinator_suggestions.js new file mode 100644 index 000000000000..1d22bb55f451 --- /dev/null +++ b/browser/devtools/inspector/test/browser_inspector_bug_831693_combinator_suggestions.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function test() +{ + waitForExplicitFinish(); + + let inspector, searchBox, state, popup; + + // The various states of the inspector: [key, suggestions array] + // [ + // what key to press, + // suggestions array with count [ + // [suggestion1, count1], [suggestion2] ... + // ] count can be left to represent 1 + // ] + let keyStates = [ + ["d", [["div", 4]]], + ["i", [["div", 4]]], + ["v", []], + [" ", [["div div", 2], ["div span", 2]]], + [">", [["div >div", 2], ["div >span", 2]]], + ["VK_BACK_SPACE", [["div div", 2], ["div span", 2]]], + ["+", [["div +span"]]], + ["VK_BACK_SPACE", [["div div", 2], ["div span", 2]]], + ["VK_BACK_SPACE", []], + ["VK_BACK_SPACE", [["div", 4]]], + ["VK_BACK_SPACE", [["div", 4]]], + ["VK_BACK_SPACE", []], + ["p", []], + [" ", [["p strong"]]], + ["+", [["p +button"], ["p +p"]]], + ["b", [["p +button"]]], + ["u", [["p +button"]]], + ["t", [["p +button"]]], + ["t", [["p +button"]]], + ["o", [["p +button"]]], + ["n", []], + ["+", [["p +button+p"]]], + ]; + + gBrowser.selectedTab = gBrowser.addTab(); + gBrowser.selectedBrowser.addEventListener("load", function onload() { + gBrowser.selectedBrowser.removeEventListener("load", onload, true); + waitForFocus(setupTest, content); + }, true); + + content.location = "http://mochi.test:8888/browser/browser/devtools/inspector/test/browser_inspector_bug_831693_search_suggestions.html"; + + function $(id) { + if (id == null) return null; + return content.document.getElementById(id); + } + + function setupTest() + { + openInspector(startTest); + } + + function startTest(aInspector) + { + inspector = aInspector; + searchBox = + inspector.panelWin.document.getElementById("inspector-searchbox"); + popup = inspector.searchSuggestions.searchPopup; + + focusSearchBoxUsingShortcut(inspector.panelWin, function() { + searchBox.addEventListener("command", checkState, true); + checkStateAndMoveOn(0); + }); + } + + function checkStateAndMoveOn(index) { + if (index == keyStates.length) { + finishUp(); + return; + } + + let [key, suggestions] = keyStates[index]; + state = index; + + info("pressing key " + key + " to get suggestions " + + JSON.stringify(suggestions)); + EventUtils.synthesizeKey(key, {}, inspector.panelWin); + } + + function checkState(event) { + executeSoon(function() { + let [key, suggestions] = keyStates[state]; + let actualSuggestions = popup.getItems(); + is(popup._panel.state == "open" || popup._panel.state == "showing" + ? actualSuggestions.length: 0, suggestions.length, + "There are expected number of suggestions at " + state + "th step."); + actualSuggestions = actualSuggestions.reverse(); + for (let i = 0; i < suggestions.length; i++) { + is(suggestions[i][0], actualSuggestions[i].label, + "The suggestion at " + i + "th index for " + state + + "th step is correct.") + is(suggestions[i][1] || 1, actualSuggestions[i].count, + "The count for suggestion at " + i + "th index for " + state + + "th step is correct.") + } + checkStateAndMoveOn(state + 1); + }); + } + + function finishUp() { + searchBox = null; + popup = null; + gBrowser.removeCurrentTab(); + finish(); + } +} diff --git a/browser/devtools/inspector/test/browser_inspector_bug_831693_input_suggestion.js b/browser/devtools/inspector/test/browser_inspector_bug_831693_input_suggestion.js new file mode 100644 index 000000000000..03a5ec335d3f --- /dev/null +++ b/browser/devtools/inspector/test/browser_inspector_bug_831693_input_suggestion.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function test() +{ + waitForExplicitFinish(); + + let inspector, searchBox, state, popup; + + // The various states of the inspector: [key, suggestions array] + // [ + // what key to press, + // suggestions array with count [ + // [suggestion1, count1], [suggestion2] ... + // ] count can be left to represent 1 + // ] + let keyStates = [ + ["d", [["div", 2]]], + ["i", [["div", 2]]], + ["v", []], + [".", [["div.c1"]]], + ["VK_BACK_SPACE", []], + ["#", [["div#d1"], ["div#d2"]]], + ["VK_BACK_SPACE", []], + ["VK_BACK_SPACE", [["div", 2]]], + ["VK_BACK_SPACE", [["div", 2]]], + ["VK_BACK_SPACE", []], + [".", [[".c1", 3], [".c2"]]], + ["c", [[".c1", 3], [".c2"]]], + ["2", []], + ["VK_BACK_SPACE", [[".c1", 3], [".c2"]]], + ["1", []], + ["#", [["#d2"], ["#p1"], ["#s2"]]], + ["VK_BACK_SPACE", []], + ["VK_BACK_SPACE", [[".c1", 3], [".c2"]]], + ["VK_BACK_SPACE", [[".c1", 3], [".c2"]]], + ["VK_BACK_SPACE", []], + ["#", [["#b1"], ["#d1"], ["#d2"], ["#p1"], ["#p2"], ["#p3"], ["#s1"], ["#s2"]]], + ["p", [["#p1"], ["#p2"], ["#p3"]]], + ["VK_BACK_SPACE", [["#b1"], ["#d1"], ["#d2"], ["#p1"], ["#p2"], ["#p3"], ["#s1"], ["#s2"]]], + ["VK_BACK_SPACE", []], + ]; + + gBrowser.selectedTab = gBrowser.addTab(); + gBrowser.selectedBrowser.addEventListener("load", function onload() { + gBrowser.selectedBrowser.removeEventListener("load", onload, true); + waitForFocus(setupTest, content); + }, true); + + content.location = "http://mochi.test:8888/browser/browser/devtools/inspector/test/browser_inspector_bug_650804_search.html"; + + function $(id) { + if (id == null) return null; + return content.document.getElementById(id); + } + + function setupTest() + { + openInspector(startTest); + } + + function startTest(aInspector) + { + inspector = aInspector; + searchBox = + inspector.panelWin.document.getElementById("inspector-searchbox"); + popup = inspector.searchSuggestions.searchPopup; + + focusSearchBoxUsingShortcut(inspector.panelWin, function() { + searchBox.addEventListener("command", checkState, true); + checkStateAndMoveOn(0); + }); + } + + function checkStateAndMoveOn(index) { + if (index == keyStates.length) { + finishUp(); + return; + } + + let [key, suggestions] = keyStates[index]; + state = index; + + info("pressing key " + key + " to get suggestions " + + JSON.stringify(suggestions)); + EventUtils.synthesizeKey(key, {}, inspector.panelWin); + } + + function checkState(event) { + executeSoon(function() { + let [key, suggestions] = keyStates[state]; + let actualSuggestions = popup.getItems(); + is(popup._panel.state == "open" || popup._panel.state == "showing" + ? actualSuggestions.length: 0, suggestions.length, + "There are expected number of suggestions at " + state + "th step."); + actualSuggestions = actualSuggestions.reverse(); + for (let i = 0; i < suggestions.length; i++) { + is(suggestions[i][0], actualSuggestions[i].label, + "The suggestion at " + i + "th index for " + state + + "th step is correct.") + is(suggestions[i][1] || 1, actualSuggestions[i].count, + "The count for suggestion at " + i + "th index for " + state + + "th step is correct.") + } + checkStateAndMoveOn(state + 1); + }); + } + + function finishUp() { + searchBox = null; + popup = null; + gBrowser.removeCurrentTab(); + finish(); + } +} diff --git a/browser/devtools/inspector/test/browser_inspector_bug_831693_search_suggestions.html b/browser/devtools/inspector/test/browser_inspector_bug_831693_search_suggestions.html new file mode 100644 index 000000000000..a84a2e3d4036 --- /dev/null +++ b/browser/devtools/inspector/test/browser_inspector_bug_831693_search_suggestions.html @@ -0,0 +1,27 @@ + + + + + Inspector Search Box Test + + +
+
+
Hello, I'm nested div
+
+
+ Hello, I'm a span +
+ Hi I am a nested span + Hi I am a nested classed span +
+
+ And me + +

.someclass

+

#someid

+ +

p>strong

+ + + diff --git a/browser/devtools/inspector/test/browser_inspector_bug_831693_searchbox_panel_navigation.js b/browser/devtools/inspector/test/browser_inspector_bug_831693_searchbox_panel_navigation.js new file mode 100644 index 000000000000..2956ea3d0100 --- /dev/null +++ b/browser/devtools/inspector/test/browser_inspector_bug_831693_searchbox_panel_navigation.js @@ -0,0 +1,156 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function test() +{ + waitForExplicitFinish(); + requestLongerTimeout(2); + + let inspector, searchBox, state, panel; + let panelOpeningStates = [0, 3, 14, 17]; + let panelClosingStates = [2, 13, 16]; + + // The various states of the inspector: [key, query] + // [ + // what key to press, + // what should be the text in the searchbox + // ] + let keyStates = [ + ["d", "d"], + ["i", "di"], + ["v", "div"], + [".", "div."], + ["VK_UP", "div.c1"], + ["VK_DOWN", "div.l1"], + ["VK_DOWN", "div.l1"], + ["VK_BACK_SPACE", "div.l"], + ["VK_TAB", "div.l1"], + [" ", "div.l1 "], + ["VK_UP", "div.l1 DIV"], + ["VK_UP", "div.l1 DIV"], + [".", "div.l1 DIV."], + ["VK_TAB", "div.l1 DIV.c1"], + ["VK_BACK_SPACE", "div.l1 DIV.c"], + ["VK_BACK_SPACE", "div.l1 DIV."], + ["VK_BACK_SPACE", "div.l1 DIV"], + ["VK_BACK_SPACE", "div.l1 DI"], + ["VK_BACK_SPACE", "div.l1 D"], + ["VK_BACK_SPACE", "div.l1 "], + ["VK_UP", "div.l1 DIV"], + ["VK_BACK_SPACE", "div.l1 DI"], + ["VK_BACK_SPACE", "div.l1 D"], + ["VK_BACK_SPACE", "div.l1 "], + ["VK_UP", "div.l1 DIV"], + ["VK_UP", "div.l1 DIV"], + ["VK_TAB", "div.l1 DIV"], + ["VK_BACK_SPACE", "div.l1 DI"], + ["VK_BACK_SPACE", "div.l1 D"], + ["VK_BACK_SPACE", "div.l1 "], + ["VK_DOWN", "div.l1 DIV"], + ["VK_DOWN", "div.l1 SPAN"], + ["VK_DOWN", "div.l1 SPAN"], + ["VK_BACK_SPACE", "div.l1 SPA"], + ["VK_BACK_SPACE", "div.l1 SP"], + ["VK_BACK_SPACE", "div.l1 S"], + ["VK_BACK_SPACE", "div.l1 "], + ["VK_BACK_SPACE", "div.l1"], + ["VK_BACK_SPACE", "div.l"], + ["VK_BACK_SPACE", "div."], + ["VK_BACK_SPACE", "div"], + ["VK_BACK_SPACE", "di"], + ["VK_BACK_SPACE", "d"], + ["VK_BACK_SPACE", ""], + ]; + + gBrowser.selectedTab = gBrowser.addTab(); + gBrowser.selectedBrowser.addEventListener("load", function onload() { + gBrowser.selectedBrowser.removeEventListener("load", onload, true); + waitForFocus(setupTest, content); + }, true); + + content.location = "http://mochi.test:8888/browser/browser/devtools/inspector/test/browser_inspector_bug_831693_search_suggestions.html"; + + function $(id) { + if (id == null) return null; + return content.document.getElementById(id); + } + + function setupTest() + { + openInspector(startTest); + } + + function startTest(aInspector) + { + inspector = aInspector; + searchBox = + inspector.panelWin.document.getElementById("inspector-searchbox"); + panel = inspector.searchSuggestions.searchPopup._list; + + focusSearchBoxUsingShortcut(inspector.panelWin, function() { + searchBox.addEventListener("keypress", checkState, true); + panel.addEventListener("keypress", checkState, true); + checkStateAndMoveOn(0); + }); + } + + function checkStateAndMoveOn(index) { + if (index == keyStates.length) { + finishUp(); + return; + } + + let [key, query] = keyStates[index]; + state = index; + + info("pressing key " + key + " to get searchbox value as " + query); + EventUtils.synthesizeKey(key, {}, inspector.panelWin); + } + + function checkState(event) { + if (panelOpeningStates.indexOf(state) != -1 && + !inspector.searchSuggestions.searchPopup.isOpen) { + info("Panel is not open, should wait before it shows up."); + panel.parentNode.addEventListener("popupshown", function retry() { + panel.parentNode.removeEventListener("popupshown", retry, false); + info("Panel is visible now"); + executeSoon(checkState); + }, false); + return; + } + else if (panelClosingStates.indexOf(state) != -1 && + panel.parentNode.state != "closed") { + info("Panel is open, should wait for it to close."); + panel.parentNode.addEventListener("popuphidden", function retry() { + panel.parentNode.removeEventListener("popuphidden", retry, false); + info("Panel is hidden now"); + executeSoon(checkState); + }, false); + return; + } + + // Using setTimout as the "command" event fires at delay after keypress + window.setTimeout(function() { + let [key, query] = keyStates[state]; + + if (searchBox.value == query) { + ok(true, "The suggestion at " + state + "th step on " + + "pressing " + key + " key is correct."); + } + else { + info("value is not correct, waiting longer for state " + state + + " with panel " + panel.parentNode.state); + checkState(); + return; + } + checkStateAndMoveOn(state + 1); + }, 200); + } + + function finishUp() { + searchBox = null; + panel = null; + gBrowser.removeCurrentTab(); + finish(); + } +} diff --git a/browser/devtools/webconsole/AutocompletePopup.jsm b/browser/devtools/shared/AutocompletePopup.jsm similarity index 53% rename from browser/devtools/webconsole/AutocompletePopup.jsm rename to browser/devtools/shared/AutocompletePopup.jsm index 415952f3a3b1..10d806558c3c 100644 --- a/browser/devtools/webconsole/AutocompletePopup.jsm +++ b/browser/devtools/shared/AutocompletePopup.jsm @@ -7,19 +7,10 @@ const Cu = Components.utils; // The XUL and XHTML namespace. const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; -const XHTML_NS = "http://www.w3.org/1999/xhtml"; - -const HUD_STRINGS_URI = "chrome://browser/locale/devtools/webconsole.properties"; - Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); -XPCOMUtils.defineLazyGetter(this, "stringBundle", function () { - return Services.strings.createBundle(HUD_STRINGS_URI); -}); - - this.EXPORTED_SYMBOLS = ["AutocompletePopup"]; /** @@ -28,21 +19,50 @@ this.EXPORTED_SYMBOLS = ["AutocompletePopup"]; * @constructor * @param nsIDOMDocument aDocument * The document you want the popup attached to. + * @param Object aOptions + * An object consiting any of the following options: + * - panelId {String} The id for the popup panel. + * - listBoxId {String} The id for the richlistbox inside the panel. + * - position {String} The position for the popup panel. + * - autoSelect {Boolean} Boolean to allow the first entry of the popup + * panel to be automatically selected when the popup shows. + * - fixedWidth {Boolean} Boolean to control dynamic width of the popup. + * - direction {String} The direction of the text in the panel. rtl or ltr + * - onSelect {String} The select event handler for the richlistbox + * - onClick {String} The click event handler for the richlistbox. + * - onKeypress {String} The keypress event handler for the richlistitems. */ -this.AutocompletePopup = function AutocompletePopup(aDocument) +this.AutocompletePopup = +function AutocompletePopup(aDocument, + aOptions = {fixedWidth: false, + autoSelect: false, + position: "after_start", + panelId: "devtools_autoCompletePopup"}) { this._document = aDocument; + this.fixedWidth = aOptions.fixedWidth; + this.autoSelect = aOptions.autoSelect; + this.position = aOptions.position; + this.direction = aOptions.direction; + + this.onSelect = aOptions.onSelect; + this.onClick = aOptions.onClick; + this.onKeypress = aOptions.onKeypress; + + let id = aOptions.panelId; // Reuse the existing popup elements. - this._panel = this._document.getElementById("webConsole_autocompletePopup"); + this._panel = this._document.getElementById(id); if (!this._panel) { this._panel = this._document.createElementNS(XUL_NS, "panel"); - this._panel.setAttribute("id", "webConsole_autocompletePopup"); - this._panel.setAttribute("label", - stringBundle.GetStringFromName("Autocomplete.label")); + this._panel.setAttribute("id", id); + this._panel.setAttribute("class", "devtools-autocomplete-popup"); + this._panel.setAttribute("noautofocus", "true"); - this._panel.setAttribute("ignorekeys", "true"); this._panel.setAttribute("level", "top"); + if (!aOptions.onKeypress) { + this._panel.setAttribute("ignorekeys", "true"); + } let mainPopupSet = this._document.getElementById("mainPopupSet"); if (mainPopupSet) { @@ -51,22 +71,41 @@ this.AutocompletePopup = function AutocompletePopup(aDocument) else { this._document.documentElement.appendChild(this._panel); } - - this._list = this._document.createElementNS(XUL_NS, "richlistbox"); - this._list.flex = 1; - this._panel.appendChild(this._list); - - // Open and hide the panel, so we initialize the API of the richlistbox. - this._panel.width = 1; - this._panel.height = 1; - this._panel.openPopup(null, "overlap", 0, 0, false, false); - this._panel.hidePopup(); - this._panel.width = ""; - this._panel.height = ""; + this._list = null; } else { this._list = this._panel.firstChild; } + + if (!this._list) { + this._list = this._document.createElementNS(XUL_NS, "richlistbox"); + this._panel.appendChild(this._list); + + // Open and hide the panel, so we initialize the API of the richlistbox. + this._panel.openPopup(null, this.popup, 0, 0); + this._panel.hidePopup(); + } + + this._list.flex = 1; + this._list.setAttribute("seltype", "single"); + + if (aOptions.listBoxId) { + this._list.setAttribute("id", aOptions.listBoxId); + } + this._list.setAttribute("class", "devtools-autocomplete-listbox"); + + + if (this.onSelect) { + this._list.addEventListener("select", this.onSelect, false); + } + + if (this.onClick) { + this._list.addEventListener("click", this.onClick, false); + } + + if (this.onKeypress) { + this._list.addEventListener("keypress", this.onKeypress, false); + } } AutocompletePopup.prototype = { @@ -74,6 +113,11 @@ AutocompletePopup.prototype = { _panel: null, _list: null, + // Event handlers. + onSelect: null, + onClick: null, + onKeypress: null, + /** * Open the autocomplete popup panel. * @@ -82,17 +126,14 @@ AutocompletePopup.prototype = { */ openPopup: function AP_openPopup(aAnchor) { - this._panel.openPopup(aAnchor, "after_start", 0, 0, false, false); + this._panel.openPopup(aAnchor, this.position, 0, 0); - if (this.onSelect) { - this._list.addEventListener("select", this.onSelect, false); + if (this.autoSelect) { + this.selectFirstItem(); } - - if (this.onClick) { - this._list.addEventListener("click", this.onClick, false); + if (!this.fixedWidth) { + this._updateSize(); } - - this._updateSize(); }, /** @@ -101,14 +142,6 @@ AutocompletePopup.prototype = { hidePopup: function AP_hidePopup() { this._panel.hidePopup(); - - if (this.onSelect) { - this._list.removeEventListener("select", this.onSelect, false); - } - - if (this.onClick) { - this._list.removeEventListener("click", this.onClick, false); - } }, /** @@ -131,11 +164,35 @@ AutocompletePopup.prototype = { } this.clearItems(); + if (this.onSelect) { + this._list.removeEventListener("select", this.onSelect, false); + } + + if (this.onClick) { + this._list.removeEventListener("click", this.onClick, false); + } + + if (this.onKeypress) { + this._list.removeEventListener("keypress", this.onKeypress, false); + } + this._document = null; this._list = null; this._panel = null; }, + /** + * Get the autocomplete items array. + * + * @param Number aIndex The index of the item what is wanted. + * + * @return The autocomplete item at index aIndex. + */ + getItemAtIndex: function AP_getItemAtIndex(aIndex) + { + return this._list.getItemAtIndex(aIndex)._autocompleteItem; + }, + /** * Get the autocomplete items array. * @@ -166,9 +223,27 @@ AutocompletePopup.prototype = { // Make sure that the new content is properly fitted by the XUL richlistbox. if (this.isOpen) { - // We need the timeout to allow the content to reflow. Attempting to - // update the richlistbox size too early does not work. - this._document.defaultView.setTimeout(this._updateSize.bind(this), 1); + if (this.autoSelect) { + this.selectFirstItem(); + } + if (!this.fixedWidth) { + this._updateSize(); + } + } + }, + + /** + * Selects the first item of the richlistbox. Note that first item here is the + * item closes to the input element, which means that 0th index if position is + * below, and last index if position is above. + */ + selectFirstItem: function AP_selectFirstItem() + { + if (this.position.contains("before")) { + this.selectedIndex = this.itemCount - 1; + } + else { + this.selectedIndex = 0; } }, @@ -179,11 +254,23 @@ AutocompletePopup.prototype = { */ _updateSize: function AP__updateSize() { - if (!this._panel) { - return; - } - this._list.width = this._panel.clientWidth + - this._scrollbarWidth; + // We need the timeout to allow the content to reflow. Attempting to + // update the richlistbox size too early does not work. + this._document.defaultView.setTimeout(function() { + if (!this._panel) { + return; + } + this._list.width = this._panel.clientWidth + + this._scrollbarWidth; + // Height change is required, otherwise the panel is drawn at an offset + // the first time. + this._list.height = this._panel.clientHeight; + // This brings the panel back at right position. + this._list.top = 0; + // Changing panel height might make the selected item out of view, so + // bring it back to view. + this._list.ensureIndexIsVisible(this._list.selectedIndex); + }.bind(this), 5); }, /** @@ -198,14 +285,16 @@ AutocompletePopup.prototype = { this._list.removeChild(this._list.firstChild); } - // Reset the panel and list dimensions. New dimensions are calculated when a - // new set of items is added to the autocomplete popup. - this._list.width = ""; - this._list.height = ""; - this._panel.width = ""; - this._panel.height = ""; - this._panel.top = ""; - this._panel.left = ""; + if (!this.fixedWidth) { + // Reset the panel and list dimensions. New dimensions are calculated when + // a new set of items is added to the autocomplete popup. + this._list.width = ""; + this._list.height = ""; + this._panel.width = ""; + this._panel.height = ""; + this._panel.top = ""; + this._panel.left = ""; + } }, /** @@ -225,7 +314,7 @@ AutocompletePopup.prototype = { */ set selectedIndex(aIndex) { this._list.selectedIndex = aIndex; - if (this._list.ensureIndexIsVisible) { + if (this.isOpen && this._list.ensureIndexIsVisible) { this._list.ensureIndexIsVisible(this._list.selectedIndex); } }, @@ -247,23 +336,51 @@ AutocompletePopup.prototype = { */ set selectedItem(aItem) { this._list.selectedItem = this._findListItem(aItem); - this._list.ensureIndexIsVisible(this._list.selectedIndex); + if (this.isOpen) { + this._list.ensureIndexIsVisible(this._list.selectedIndex); + } }, /** * Append an item into the autocomplete list. * * @param object aItem - * The item you want appended to the list. The object must have a - * "label" property which is used as the displayed value. + * The item you want appended to the list. + * The item object can have the following properties: + * - label {String} Property which is used as the displayed value. + * - preLabel {String} [Optional] The String that will be displayed + * before the label indicating that this is the already + * present text in the input box, and label is the text + * that will be auto completed. When this property is + * present, |preLabel.length| starting characters will be + * removed from label. + * - count {Number} [Optional] The number to represent the count of + * autocompleted label. */ appendItem: function AP_appendItem(aItem) { - let description = this._document.createElementNS(XUL_NS, "description"); - description.textContent = aItem.label; - let listItem = this._document.createElementNS(XUL_NS, "richlistitem"); - listItem.appendChild(description); + if (this.direction) { + listItem.setAttribute("dir", this.direction); + } + let label = this._document.createElementNS(XUL_NS, "label"); + label.setAttribute("value", aItem.label); + label.setAttribute("class", "autocomplete-value"); + if (aItem.preLabel) { + let preDesc = this._document.createElementNS(XUL_NS, "label"); + preDesc.setAttribute("value", aItem.preLabel); + preDesc.setAttribute("class", "initial-value"); + listItem.appendChild(preDesc); + label.setAttribute("value", aItem.label.slice(aItem.preLabel.length)); + } + listItem.appendChild(label); + if (aItem.count && aItem.count > 1) { + let countDesc = this._document.createElementNS(XUL_NS, "label"); + countDesc.setAttribute("value", aItem.count); + countDesc.setAttribute("flex", "1"); + countDesc.setAttribute("class", "autocomplete-count"); + listItem.appendChild(countDesc); + } listItem._autocompleteItem = aItem; this._list.appendChild(listItem); @@ -351,6 +468,14 @@ AutocompletePopup.prototype = { return this.selectedItem; }, + /** + * Focuses the richlistbox. + */ + focus: function AP_focus() + { + this._list.focus(); + }, + /** * Determine the scrollbar width in the current document. * diff --git a/browser/devtools/webconsole/Makefile.in b/browser/devtools/webconsole/Makefile.in index 65557eb461eb..6d2875baf173 100644 --- a/browser/devtools/webconsole/Makefile.in +++ b/browser/devtools/webconsole/Makefile.in @@ -13,7 +13,6 @@ EXTRA_JS_MODULES = \ HUDService.jsm \ PropertyPanel.jsm \ NetworkPanel.jsm \ - AutocompletePopup.jsm \ WebConsolePanel.jsm \ $(NULL) diff --git a/browser/devtools/webconsole/webconsole.js b/browser/devtools/webconsole/webconsole.js index 242ee7ffac08..85190620a319 100644 --- a/browser/devtools/webconsole/webconsole.js +++ b/browser/devtools/webconsole/webconsole.js @@ -29,7 +29,7 @@ XPCOMUtils.defineLazyModuleGetter(this, "NetworkPanel", "resource:///modules/NetworkPanel.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "AutocompletePopup", - "resource:///modules/AutocompletePopup.jsm"); + "resource:///modules/devtools/AutocompletePopup.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "WebConsoleUtils", "resource://gre/modules/devtools/WebConsoleUtils.jsm"); @@ -2731,9 +2731,15 @@ JSTerm.prototype = { init: function JST_init() { let chromeDocument = this.hud.owner.chromeDocument; - this.autocompletePopup = new AutocompletePopup(chromeDocument); - this.autocompletePopup.onSelect = this.onAutocompleteSelect.bind(this); - this.autocompletePopup.onClick = this.acceptProposedCompletion.bind(this); + let autocompleteOptions = { + onSelect: this.onAutocompleteSelect.bind(this), + onClick: this.acceptProposedCompletion.bind(this), + panelId: "webConsole_autocompletePopup", + listBoxId: "webConsole_autocompletePopupListBox", + position: "before_start" + }; + this.autocompletePopup = new AutocompletePopup(chromeDocument, + autocompleteOptions); let doc = this.hud.document; this.completeNode = doc.querySelector(".jsterm-complete-node"); diff --git a/browser/locales/en-US/chrome/browser/devtools/webconsole.properties b/browser/locales/en-US/chrome/browser/devtools/webconsole.properties index 1f8e1ad41d28..2a67b9cd4f84 100644 --- a/browser/locales/en-US/chrome/browser/devtools/webconsole.properties +++ b/browser/locales/en-US/chrome/browser/devtools/webconsole.properties @@ -110,10 +110,6 @@ scratchpad.linkText=Shift+RETURN - Open in Scratchpad # string gcliterm.instanceLabel=Instance of %S -# LOCALIZATION NOTE (Autocomplete.label): -# The autocomplete popup panel label/title. -Autocomplete.label=Autocomplete popup - # LOCALIZATION NOTE (stacktrace.anonymousFunction): # This string is used to display JavaScript functions that have no given name - # they are said to be anonymous. See stacktrace.outputMessage.