Bug 415960 - bookmark tags edit control should provide autocomplete (r=mano, core by asouzis@users.sf.net)
authorDietrich Ayala <dietrich@mozilla.com>
Fri, 31 Oct 2008 09:16:22 -0700
changeset 21150 efe787d282859c792322015f6ab6b6614a569f22
parent 21149 7aeaf064ad9f971882102c90106643663dddd95f
child 21151 087c6fa28ecb5050037c15612b9b9bb853ae2456
push id3357
push userdietrich@mozilla.com
push dateFri, 31 Oct 2008 16:17:53 +0000
treeherdermozilla-central@efe787d28285 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmano, core
bugs415960
milestone1.9.1b2pre
Bug 415960 - bookmark tags edit control should provide autocomplete (r=mano, core by asouzis@users.sf.net)
browser/base/content/browser-places.js
browser/components/places/content/editBookmarkOverlay.xul
browser/themes/gnomestripe/browser/places/editBookmarkOverlay.css
browser/themes/pinstripe/browser/browser.css
browser/themes/pinstripe/browser/places/editBookmarkOverlay.css
browser/themes/winstripe/browser/places/editBookmarkOverlay.css
toolkit/components/places/src/nsTaggingService.js
toolkit/components/places/tests/unit/test_tag_autocomplete_search.js
--- a/browser/base/content/browser-places.js
+++ b/browser/base/content/browser-places.js
@@ -118,17 +118,20 @@ var StarUI = {
             var elt = aEvent.target;
             if (elt.localName != "tree" ||
                 (elt.localName == "tree" && !elt.hasAttribute("editing")))
               this.cancelButtonOnCommand();
           }
         }
         else if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) {
           // hide the panel unless the folder tree is focused
-          if (aEvent.target.localName != "tree")
+          // or the tag autocomplete popup is open
+          if (aEvent.target.localName != "tree" &&
+              (aEvent.target.id != "editBMPanel_tagsField" ||
+               !aEvent.target.popupOpen))
             this.panel.hidePopup();
         }
         break;
     }
   },
 
   _overlayLoaded: false,
   _overlayLoading: false,
--- a/browser/components/places/content/editBookmarkOverlay.xul
+++ b/browser/components/places/content/editBookmarkOverlay.xul
@@ -172,16 +172,21 @@
         </row>
 
         <row align="center" id="editBMPanel_tagsRow">
           <label value="&editBookmarkOverlay.tags.label;"
                  accesskey="&editBookmarkOverlay.tags.accesskey;"
                  control="editBMPanel_tagsField"
                  observes="paneElementsBroadcaster"/>
           <textbox id="editBMPanel_tagsField"
+                   type="autocomplete"
+                   autocompletesearch="places-tag-autocomplete" 
+                   completedefaultindex="true"
+                   tabscrolling="true"
+                   showcommentcolumn="true"
                    onblur="gEditItemOverlay.onTagsFieldBlur();"
                    observes="paneElementsBroadcaster"
                    emptytext="&editBookmarkOverlay.tagsEmptyDesc.label;"/>
           <button id="editBMPanel_tagsSelectorExpander"
                   class="expander-down"
                   tooltiptext="&editBookmarkOverlay.tagsExpanderDown.tooltip;"
                   tooltiptextdown="&editBookmarkOverlay.tagsExpanderDown.tooltip;"
                   tooltiptextup="&editBookmarkOverlay.expanderUp.tooltip;"
--- a/browser/themes/gnomestripe/browser/places/editBookmarkOverlay.css
+++ b/browser/themes/gnomestripe/browser/places/editBookmarkOverlay.css
@@ -110,16 +110,25 @@
 
 
 /* Hide the drop marker and the popup. */
 #editBMPanel_namePicker[droppable="false"] > .menulist-dropmarker,
 #editBMPanel_namePicker[droppable="false"] > menupopup {
   display: none;
 }
 
+/* Hide the value column of the tag autocomplete popup
+ * leaving only the comment column visible. This is
+ * so that only the tag being edited is shown in the
+ * popup.
+ */
+#editBMPanel_tagsField #treecolAutoCompleteValue {
+  visibility: collapse;
+}
+
 
 /* Bookmark panel dropdown menu items */
 #editBMPanel_folderMenuList[selectedIndex="0"],
 #editBMPanel_toolbarFolderItem {
   list-style-image: url("chrome://browser/skin/places/bookmarksToolbar.png") !important;  
 }
 
 #editBMPanel_folderMenuList[selectedIndex="1"],
--- a/browser/themes/pinstripe/browser/browser.css
+++ b/browser/themes/pinstripe/browser/browser.css
@@ -1321,17 +1321,21 @@ richlistitem[selected="true"][current="t
 #editBookmarkPanel #editBMPanel_namePicker[droppable="false"] {
   color: #ffffff;
 }
 
 #editBookmarkPanel #editBMPanel_namePicker[droppable="false"] > .menulist-dropmarker {
   display: none;
 }
 
-#editBookmarkPanel #editBMPanel_tagsField,
+#editBookmarkPanel #editBMPanel_tagsField[focused="true"] {
+  background-color: #eeeeee;
+  color: #000000;
+}
+
 #editBookmarkPanel #editBMPanel_namePicker[droppable="false"] > .menulist-editable-box {
   -moz-appearance: none !important;
   cursor: text;
   margin: 2px 4px;
   border: 2px solid;
   -moz-border-top-colors: #1c1c1c #545454 ;
   -moz-border-right-colors: #1c1c1c #636363;
   -moz-border-bottom-colors: #1c1c1c #797979;
@@ -1341,17 +1345,16 @@ richlistitem[selected="true"][current="t
   background-color: #666666;
   color: #ffffff;
 }
 
 #editBookmarkPanel #editBMPanel_tagsField[empty="true"] {
   color: #bbbbbb;
 }
 
-#editBookmarkPanel #editBMPanel_tagsField[focused="true"],
 #editBookmarkPanel #editBMPanel_namePicker[droppable="false"][focused="true"] > .menulist-editable-box {
   outline: 2px solid -moz-mac-focusring;
   outline-offset: -1px;
   -moz-outline-radius: 1px;
   background-color: #eeeeee;
   color: #000000;
 }
 
--- a/browser/themes/pinstripe/browser/places/editBookmarkOverlay.css
+++ b/browser/themes/pinstripe/browser/places/editBookmarkOverlay.css
@@ -148,16 +148,25 @@
 #editBMPanel_namePicker[droppable="false"] > .menulist-dropmarker {
   display: none;
 }
 
 #editBMPanel_namePicker[droppable="false"] > menupopup {
   display: none;
 }
 
+/* Hide the value column of the tag autocomplete popup
+ * leaving only the comment column visible. This is
+ * so that only the tag being edited is shown in the
+ * popup.
+ */
+#editBMPanel_tagsField #treecolAutoCompleteValue {
+  visibility: collapse;
+}
+
 
 /* ----- BOOKMARK PANEL DROPDOWN MENU ITEMS ----- */	
 
 #editBMPanel_folderMenuList[selectedIndex="0"],
 #editBMPanel_toolbarFolderItem {
   list-style-image: url("chrome://browser/skin/places/bookmarksToolbar.png") !important;  
 }
 
--- a/browser/themes/winstripe/browser/places/editBookmarkOverlay.css
+++ b/browser/themes/winstripe/browser/places/editBookmarkOverlay.css
@@ -107,16 +107,25 @@
 
 /* Hide the drop marker and the popup. */
 
 #editBMPanel_namePicker[droppable="false"] > .menulist-dropmarker,
 #editBMPanel_namePicker[droppable="false"] > menupopup {
   display: none;
 }
 
+/* Hide the value column of the tag autocomplete popup
+ * leaving only the comment column visible. This is
+ * so that only the tag being edited is shown in the
+ * popup.
+ */
+#editBMPanel_tagsField #treecolAutoCompleteValue {
+  visibility: collapse;
+}
+
 
 /* ::::: bookmark panel dropdown icons ::::: */
 
 #editBMPanel_folderMenuList[selectedIndex="0"],
 #editBMPanel_toolbarFolderItem {
   list-style-image: url("chrome://browser/skin/places/bookmarksToolbar.png") !important;
   -moz-image-region: auto !important;
 }
--- a/toolkit/components/places/src/nsTaggingService.js
+++ b/toolkit/components/places/src/nsTaggingService.js
@@ -78,16 +78,17 @@ TaggingService.prototype = {
 
   get _tagsResult() {
     if (!this.__tagsResult) {
       var options = this._history.getNewQueryOptions();
       var query = this._history.getNewQuery();
       query.setFolders([this._bms.tagsFolder], 1);
       this.__tagsResult = this._history.executeQuery(query, options);
       this.__tagsResult.root.containerOpen = true;
+      this.__tagsResult.viewer = this;
 
       // we need to null out the result on shutdown
       var observerSvc = Cc[OBSS_CONTRACTID].getService(Ci.nsIObserverService);
       observerSvc.addObserver(this, "xpcom-shutdown", false);
     }
     return this.__tagsResult;
   },
 
@@ -275,36 +276,278 @@ TaggingService.prototype = {
 
     // sort the tag list
     tags.sort();
     aCount.value = tags.length;
     return tags;
   },
 
   // nsITaggingService
+  _allTags: null,
   get allTags() {
-    var tags = [];
-    var root = this._tagsResult.root;
-    var cc = root.childCount;
-    for (var j=0; j < cc; j++) {
-      var child = root.getChild(j);
-      tags.push(child.title);
+    if (!this._allTags) {
+      this._allTags = [];
+      var root = this._tagsResult.root;
+      var cc = root.childCount;
+      for (var j=0; j < cc; j++) {
+        var child = root.getChild(j);
+        this._allTags.push(child.title);
+      }
+
+      // sort the tag list
+      this.allTags.sort();
     }
-
-    // sort the tag list
-    tags.sort();
-    return tags;
+    return this._allTags;
   },
 
   // nsIObserver
-  observe: function TS_observe(subject, topic, data) {
-    if (topic == "xpcom-shutdown") {
+  observe: function TS_observe(aSubject, aTopic, aData) {
+    if (aTopic == "xpcom-shutdown") {
       this.__tagsResult.root.containerOpen = false;
+      this.__tagsResult.viewer = null;
       this.__tagsResult = null;
       var observerSvc = Cc[OBSS_CONTRACTID].getService(Ci.nsIObserverService);
       observerSvc.removeObserver(this, "xpcom-shutdown");
     }
+  },
+
+  // nsINavHistoryResultViewer
+  // Used to invalidate the cached tag list
+  itemInserted: function() this._allTags = null,
+  itemRemoved: function() this._allTags = null,
+  itemMoved: function() {},
+  itemChanged: function() {},
+  itemReplaced: function() {},
+  containerOpened: function() {},
+  containerClosed: function() {},
+  invalidateContainer: function() this._allTags = null,
+  invalidateAll: function() this._allTags = null,
+  sortingChanged: function() {},
+  result: null
+};
+
+// Implements nsIAutoCompleteResult
+function TagAutoCompleteResult(searchString, searchResult,
+                               defaultIndex, errorDescription,
+                               results, comments) {
+  this._searchString = searchString;
+  this._searchResult = searchResult;
+  this._defaultIndex = defaultIndex;
+  this._errorDescription = errorDescription;
+  this._results = results;
+  this._comments = comments;
+}
+
+TagAutoCompleteResult.prototype = {
+  
+  /**
+   * The original search string
+   */
+  get searchString() {
+    return this._searchString;
+  },
+
+  /**
+   * The result code of this result object, either:
+   *         RESULT_IGNORED   (invalid searchString)
+   *         RESULT_FAILURE   (failure)
+   *         RESULT_NOMATCH   (no matches found)
+   *         RESULT_SUCCESS   (matches found)
+   */
+  get searchResult() {
+    return this._searchResult;
+  },
+
+  /**
+   * Index of the default item that should be entered if none is selected
+   */
+  get defaultIndex() {
+    return this._defaultIndex;
+  },
+
+  /**
+   * A string describing the cause of a search failure
+   */
+  get errorDescription() {
+    return this._errorDescription;
+  },
+
+  /**
+   * The number of matches
+   */
+  get matchCount() {
+    return this._results.length;
+  },
+
+  /**
+   * Get the value of the result at the given index
+   */
+  getValueAt: function PTACR_getValueAt(index) {
+    return this._results[index];
+  },
+
+  /**
+   * Get the comment of the result at the given index
+   */
+  getCommentAt: function PTACR_getCommentAt(index) {
+    return this._comments[index];
+  },
+
+  /**
+   * Get the style hint for the result at the given index
+   */
+  getStyleAt: function PTACR_getStyleAt(index) {
+    if (!this._comments[index])
+      return null;  // not a category label, so no special styling
+
+    if (index == 0)
+      return "suggestfirst";  // category label on first line of results
+
+    return "suggesthint";   // category label on any other line of results
+  },
+
+  /**
+   * Get the image for the result at the given index
+   */
+  getImageAt: function PTACR_getImageAt(index) {
+    return null;
+  },
+
+  /**
+   * Remove the value at the given index from the autocomplete results.
+   * If removeFromDb is set to true, the value should be removed from
+   * persistent storage as well.
+   */
+  removeValueAt: function PTACR_removeValueAt(index, removeFromDb) {
+    this._results.splice(index, 1);
+    this._comments.splice(index, 1);
+  },
+
+  QueryInterface: function(aIID) {
+    if (!aIID.equals(Ci.nsIAutoCompleteResult) && !aIID.equals(Ci.nsISupports))
+        throw Components.results.NS_ERROR_NO_INTERFACE;
+    return this;
   }
 };
 
+// Implements nsIAutoCompleteSearch
+function TagAutoCompleteSearch() {
+}
+
+TagAutoCompleteSearch.prototype = {
+  _stopped : false, 
+
+  get tagging() {
+    let svc = Cc["@mozilla.org/browser/tagging-service;1"].
+              getService(Ci.nsITaggingService);
+    this.__defineGetter__("tagging", function() svc);
+    return this.tagging;
+  },
+
+  /*
+   * Search for a given string and notify a listener (either synchronously
+   * or asynchronously) of the result
+   *
+   * @param searchString - The string to search for
+   * @param searchParam - An extra parameter
+   * @param previousResult - A previous result to use for faster searching
+   * @param listener - A listener to notify when the search is complete
+   */
+  startSearch: function PTACS_startSearch(searchString, searchParam, result, listener) {
+    var searchResults = this.tagging.allTags;
+    var results = [];
+    var comments = [];
+    this._stopped = false;
+
+    // only search on characters for the last tag
+    var index = Math.max(searchString.lastIndexOf(","), 
+      searchString.lastIndexOf(";"));
+    var before = ''; 
+    if (index != -1) {  
+      before = searchString.slice(0, index+1);
+      searchString = searchString.slice(index+1);
+      // skip past whitespace
+      var m = searchString.match(/\s+/);
+      if (m) {
+         before += m[0];
+         searchString = searchString.slice(m[0].length);
+      }
+    }
+
+    if (!searchString.length) {
+      var newResult = new TagAutoCompleteResult(searchString,
+        Ci.nsIAutoCompleteResult.RESULT_NOMATCH, 0, "", results, comments);
+      listener.onSearchResult(self, newResult);
+      return;
+    }
+    
+    var self = this;
+    // generator: if yields true, not done
+    function doSearch() {
+      var i = 0;
+      while (i < searchResults.length) {
+        if (self._stopped)
+          yield false;
+        // for each match, prepend what the user has typed so far
+        var pattern = new RegExp("(^" + searchResults[i] + "$|" + searchResults[i] + "(,|;))");
+        if (searchResults[i].indexOf(searchString) == 0 &&
+            !pattern.test(before)) {
+          results.push(before + searchResults[i]);
+          comments.push(searchResults[i]);
+        }
+    
+        ++i;
+        // 100 loops per yield
+        if ((i % 100) == 0) {
+          var newResult = new TagAutoCompleteResult(searchString,
+            Ci.nsIAutoCompleteResult.RESULT_SUCCESS_ONGOING, 0, "", results, comments);
+          listener.onSearchResult(self, newResult);
+          yield true;
+        } 
+      }
+
+      var newResult = new TagAutoCompleteResult(searchString,
+        Ci.nsIAutoCompleteResult.RESULT_SUCCESS, 0, "", results, comments);
+      listener.onSearchResult(self, newResult);
+      yield false;
+    }
+    
+    // chunk the search results via a generator
+    var gen = doSearch();
+    function driveGenerator() {
+      if (gen.next()) { 
+        var timer = Cc["@mozilla.org/timer;1"]
+          .createInstance(Components.interfaces.nsITimer);
+        self._callback = driveGenerator;
+        timer.initWithCallback(self, 0, timer.TYPE_ONE_SHOT);
+      }
+      else {
+        gen.close();	
+      }
+    }
+    driveGenerator();
+  },
+
+  notify: function PTACS_notify(timer) {
+    if (this._callback) 
+      this._callback();
+  },
+
+  /*
+   * Stop an asynchronous search that is in progress
+   */
+  stopSearch: function PTACS_stopSearch() {
+    this._stopped = true;
+  },
+
+  // nsISupports
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteSearch,
+                                         Ci.nsITimerCallback]), 
+
+  classDescription: "Places Tag AutoComplete",
+  contractID: "@mozilla.org/autocomplete/search;1?name=places-tag-autocomplete",
+  classID: Components.ID("{1dcc23b0-d4cb-11dc-9ad6-479d56d89593}")
+};
+
+var component = [TaggingService, TagAutoCompleteSearch];
 function NSGetModule(compMgr, fileSpec) {
-  return XPCOMUtils.generateModule([TaggingService]);
+  return XPCOMUtils.generateModule(component);
 }
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_tag_autocomplete_search.js
@@ -0,0 +1,167 @@
+/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is tag autocomplete search unit test code.
+ *
+ * The Initial Developer of the Original Code is POTI Inc.
+ * Portions created by the Initial Developer are Copyright (C) 2007
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Matt Crocker <matt@songbirdnest.com>
+ *   Seth Spitzer <sspitzer@mozilla.org>
+ *   Adam Souzis <adam@souzis.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+var current_test = 0;
+
+function AutoCompleteInput(aSearches) {
+  this.searches = aSearches;
+}
+AutoCompleteInput.prototype = {
+  constructor: AutoCompleteInput, 
+
+  searches: null,
+  
+  minResultsForPopup: 0,
+  timeout: 10,
+  searchParam: "",
+  textValue: "",
+  disableAutoComplete: false,  
+  completeDefaultIndex: false,
+  
+  get searchCount() {
+    return this.searches.length;
+  },
+  
+  getSearchAt: function(aIndex) {
+    return this.searches[aIndex];
+  },
+  
+  onSearchBegin: function() {},
+  onSearchComplete: function() {},
+  
+  popupOpen: false,  
+  
+  popup: { 
+    setSelectedIndex: function(aIndex) {},
+    invalidate: function() {},
+
+    // nsISupports implementation
+    QueryInterface: function(iid) {
+      if (iid.equals(Ci.nsISupports) ||
+          iid.equals(Ci.nsIAutoCompletePopup))
+        return this;
+
+      throw Components.results.NS_ERROR_NO_INTERFACE;
+    }    
+  },
+    
+  // nsISupports implementation
+  QueryInterface: function(iid) {
+    if (iid.equals(Ci.nsISupports) ||
+        iid.equals(Ci.nsIAutoCompleteInput))
+      return this;
+
+    throw Components.results.NS_ERROR_NO_INTERFACE;
+  }
+}
+
+// Get tagging service
+try {
+  var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].
+                getService(Ci.nsITaggingService);
+} catch(ex) {
+  do_throw("Could not get tagging service\n");
+}
+
+function ensure_tag_results(results, searchTerm)
+{
+  var controller = Cc["@mozilla.org/autocomplete/controller;1"].
+                   getService(Ci.nsIAutoCompleteController);  
+  
+  // Make an AutoCompleteInput that uses our searches
+  // and confirms results on search complete
+  var input = new AutoCompleteInput(["places-tag-autocomplete"]);
+
+  controller.input = input;
+
+  var numSearchesStarted = 0;
+  input.onSearchBegin = function input_onSearchBegin() {
+    numSearchesStarted++;
+    do_check_eq(numSearchesStarted, 1);
+  };
+
+  input.onSearchComplete = function input_onSearchComplete() {
+    do_check_eq(numSearchesStarted, 1);
+    if (results.length)
+      do_check_eq(controller.searchStatus, 
+                  Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH);
+    else
+      do_check_eq(controller.searchStatus, 
+                  Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH);
+
+    do_check_eq(controller.matchCount, results.length);
+    for (var i=0; i<controller.matchCount; i++) {
+      do_check_eq(controller.getValueAt(i), results[i]);
+    }
+   
+    if (current_test < (tests.length - 1)) {
+      current_test++;
+      tests[current_test]();
+    }
+
+    do_test_finished();
+  };
+
+  // Search is asynchronous, so don't let the test finish immediately
+  do_test_pending();
+
+  controller.startSearch(searchTerm);
+}
+
+var uri1 = uri("http://site.tld/1");
+  
+var tests = [
+  function test1() { ensure_tag_results(["bar", "baz", "boo"], "b"); },
+  function test2() { ensure_tag_results(["bar", "baz"], "ba"); },
+  function test3() { ensure_tag_results(["bar"], "bar"); }, 
+  function test4() { ensure_tag_results([], "barb"); }, 
+  function test5() { ensure_tag_results([], "foo"); },
+  function test6() { ensure_tag_results(["first tag, bar", "first tag, baz"], "first tag, ba"); },
+  function test7() { ensure_tag_results(["first tag;  bar", "first tag;  baz"], "first tag;  ba"); }
+];
+
+/** 
+ * Test tag autocomplete
+ */
+function run_test() {
+  tagssvc.tagURI(uri1, ["bar", "baz", "boo"]);
+
+  tests[0]();
+}