Bug 1180944 - Implement one-off searches from Awesomebar. r?mak,florian draft
authorDrew Willcoxon <adw@mozilla.com>
Mon, 01 Aug 2016 18:27:15 -0700
changeset 395317 1e0e4721333c2d99ab4eea911f671c032fb16ca3
parent 379627 13bdb28018357af151edadbb3f76a474ff00ec07
child 526973 2cfbd2ef8b411699e0c552d5b8e01655d61fe047
push id24746
push userdwillcoxon@mozilla.com
push dateTue, 02 Aug 2016 01:27:37 +0000
reviewersmak, florian
bugs1180944
milestone50.0a1
Bug 1180944 - Implement one-off searches from Awesomebar. r?mak,florian MozReview-Commit-ID: A9YXB32L7MN
browser/app/profile/firefox.js
browser/base/content/browser.xul
browser/base/content/test/urlbar/browser.ini
browser/base/content/test/urlbar/browser_autocomplete_autoselect.js
browser/base/content/test/urlbar/browser_autocomplete_edit_completed.js
browser/base/content/test/urlbar/browser_bug1070778.js
browser/base/content/test/urlbar/browser_urlbarAutoFillTrimURLs.js
browser/base/content/test/urlbar/browser_urlbarOneOffs.js
browser/base/content/test/urlbar/browser_urlbarSearchSuggestions.js
browser/base/content/test/urlbar/browser_urlbarSearchTelemetry.js
browser/base/content/test/urlbar/browser_urlbarStop.js
browser/base/content/test/urlbar/browser_urlbar_autoFill_backspaced.js
browser/base/content/test/urlbar/head.js
browser/base/content/urlbarBindings.xml
browser/components/search/content/search.xml
browser/components/search/content/searchbarBindings.css
browser/components/search/test/browser_hiddenOneOffs_diacritics.js
browser/components/search/test/browser_oneOffHeader.js
browser/components/search/test/browser_searchbar_keyboard_navigation.js
browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js
browser/components/search/test/head.js
browser/themes/linux/searchbar.css
browser/themes/osx/searchbar.css
browser/themes/shared/jar.inc.mn
browser/themes/shared/search/gear.svg
browser/themes/windows/searchbar.css
toolkit/components/autocomplete/nsAutoCompleteController.cpp
toolkit/components/autocomplete/nsAutoCompleteController.h
toolkit/components/autocomplete/nsIAutoCompleteController.idl
toolkit/components/autocomplete/nsIAutoCompleteInput.idl
toolkit/components/places/PlacesUtils.jsm
toolkit/components/places/UnifiedComplete.js
toolkit/components/satchel/nsFormFillController.cpp
toolkit/content/widgets/autocomplete.xml
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -299,16 +299,22 @@ pref("browser.urlbar.maxCharsForSearchSu
 
 // Restrictions to current suggestions can also be applied (intersection).
 // Typed suggestion works only if history is set to true.
 pref("browser.urlbar.suggest.history.onlyTyped",    false);
 
 pref("browser.urlbar.formatting.enabled", true);
 pref("browser.urlbar.trimURLs", true);
 
+#if defined(NIGHTLY_BUILD)
+pref("browser.urlbar.oneOffSearches", true);
+#else
+pref("browser.urlbar.oneOffSearches", false);
+#endif
+
 pref("browser.altClickSave", false);
 
 // Enable logging downloads operations to the Console.
 pref("browser.download.loglevel", "Error");
 
 // Number of milliseconds to wait for the http headers (and thus
 // the Content-Disposition filename) before giving up and falling back to
 // picking a filename without that info in hand so that the user sees some
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -141,24 +141,17 @@
     <panel type="autocomplete" id="PopupSearchAutoComplete" noautofocus="true" hidden="true"/>
 
     <!-- for url bar autocomplete -->
     <panel type="autocomplete-richlistbox"
            id="PopupAutoCompleteRichResult"
            noautofocus="true"
            hidden="true"
            flip="none"
-           level="parent">
-#ifdef NIGHTLY_BUILD
-      <hbox id="urlbar-search-footer" flex="1" align="stretch" pack="end">
-        <button id="urlbar-search-settings" label="&changeSearchSettings.button;"
-                oncommand="BrowserUITelemetry.countSearchSettingsEvent('urlbar'); openPreferences('paneSearch')"/>
-      </hbox>
-#endif
-    </panel>
+           level="parent"/>
 
     <!-- for select dropdowns. The menupopup is what shows the list of options,
          and the popuponly menulist makes things like the menuactive attributes
          work correctly on the menupopup. ContentSelectDropdown expects the
          popuponly menulist to be its immediate parent. -->
     <menulist popuponly="true" id="ContentSelectDropdown" hidden="true">
       <menupopup rolluponmousewheel="true"
                  activateontab="true"
--- a/browser/base/content/test/urlbar/browser.ini
+++ b/browser/base/content/test/urlbar/browser.ini
@@ -51,16 +51,17 @@ support-files =
   authenticate.sjs
 [browser_urlbarDecode.js]
 [browser_urlbarDelete.js]
 [browser_urlbarEnter.js]
 [browser_urlbarEnterAfterMouseOver.js]
 skip-if = os == "linux" # Bug 1073339 - Investigate autocomplete test unreliability on Linux/e10s
 [browser_urlbarHashChangeProxyState.js]
 [browser_urlbarKeepStateAcrossTabSwitches.js]
+[browser_urlbarOneOffs.js]
 [browser_urlbarPrivateBrowsingWindowChange.js]
 [browser_urlbarRevert.js]
 [browser_urlbarSearchSingleWordNotification.js]
 [browser_urlbarSearchSuggestions.js]
 support-files =
   searchSuggestionEngine.xml
   searchSuggestionEngine.sjs
 [browser_urlbarSearchSuggestionsNotification.js]
--- a/browser/base/content/test/urlbar/browser_autocomplete_autoselect.js
+++ b/browser/base/content/test/urlbar/browser_autocomplete_autoselect.js
@@ -1,16 +1,31 @@
 function repeat(limit, func) {
   for (let i = 0; i < limit; i++) {
     func(i);
   }
 }
 
 function is_selected(index) {
   is(gURLBar.popup.richlistbox.selectedIndex, index, `Item ${index + 1} should be selected`);
+
+  // This is true because although both the listbox and the one-offs can have
+  // selections, the test doesn't check that.
+  is(gURLBar.popup.oneOffSearchButtons.selectedButton, null,
+     "A result is selected, so the one-offs should not have a selection");
+}
+
+function is_selected_one_off(index) {
+  is(gURLBar.popup.oneOffSearchButtons.selectedButtonIndex, index,
+     "Expected one-off button should be selected");
+
+  // This is true because although both the listbox and the one-offs can have
+  // selections, the test doesn't check that.
+  is(gURLBar.popup.richlistbox.selectedIndex, -1,
+     "A one-off is selected, so the listbox should not have a selection");
 }
 
 add_task(function*() {
   let maxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults");
 
   registerCleanupFunction(function* () {
     yield PlacesTestUtils.clearHistory();
   });
@@ -32,22 +47,37 @@ add_task(function*() {
   is(results.length, maxResults,
      "Should get maxResults=" + maxResults + " results");
   is_selected(0);
 
   info("Key Down to select the next item");
   EventUtils.synthesizeKey("VK_DOWN", {});
   is_selected(1);
 
-  info("Key Down maxResults times should wrap around all the way around");
-  repeat(maxResults, () => EventUtils.synthesizeKey("VK_DOWN", {}));
+  info("Key Down maxResults-1 times should select the first one-off");
+  repeat(maxResults - 1, () => EventUtils.synthesizeKey("VK_DOWN", {}));
+  is_selected_one_off(0);
+
+  info("Key Down numButtons-1 should select the last one-off");
+  let numButtons =
+    gURLBar.popup.oneOffSearchButtons.getSelectableButtons(true).length;
+  repeat(numButtons - 1, () => EventUtils.synthesizeKey("VK_DOWN", {}));
+  is_selected_one_off(numButtons - 1);
+
+  info("Key Down twice more should select the second result");
+  repeat(2, () => EventUtils.synthesizeKey("VK_DOWN", {}));
   is_selected(1);
 
-  info("Key Up maxResults times should wrap around the other way");
-  repeat(maxResults, () => EventUtils.synthesizeKey("VK_UP", {}));
+  info("Key Down maxResults + numButtons times should wrap around");
+  repeat(maxResults + numButtons,
+         () => EventUtils.synthesizeKey("VK_DOWN", {}));
+  is_selected(1);
+
+  info("Key Up maxResults + numButtons times should wrap around the other way");
+  repeat(maxResults + numButtons, () => EventUtils.synthesizeKey("VK_UP", {}));
   is_selected(1);
 
   info("Page Up will go up the list, but not wrap");
   EventUtils.synthesizeKey("VK_PAGE_UP", {})
   is_selected(0);
 
   info("Page Up again will wrap around to the end of the list");
   EventUtils.synthesizeKey("VK_PAGE_UP", {})
--- a/browser/base/content/test/urlbar/browser_autocomplete_edit_completed.js
+++ b/browser/base/content/test/urlbar/browser_autocomplete_edit_completed.js
@@ -26,20 +26,23 @@ add_task(function*() {
   let nextValue = gURLBar.controller.getFinalCompleteValueAt(nextIndex);
   is(list.selectedIndex, nextIndex, "The next item is selected.");
   is(gURLBar.value, nextValue, "The selected URL is completed.");
 
   info("Press backspace");
   EventUtils.synthesizeKey("VK_BACK_SPACE", {});
   yield promiseSearchComplete();
 
-  let editedValue = gURLBar.value;
+  let editedValue = gURLBar.textValue;
   is(list.selectedIndex, initialIndex, "The initial index is selected again.");
   isnot(editedValue, nextValue, "The URL has changed.");
 
+  let docLoad = waitForDocLoadAndStopIt("http://" + editedValue);
+
   info("Press return to load edited URL.");
   EventUtils.synthesizeKey("VK_RETURN", {});
   yield Promise.all([
     promisePopupHidden(gURLBar.popup),
-    waitForDocLoadAndStopIt("http://" + editedValue)]);
+    docLoad,
+  ]);
 
   gBrowser.removeTab(gBrowser.selectedTab);
 });
--- a/browser/base/content/test/urlbar/browser_bug1070778.js
+++ b/browser/base/content/test/urlbar/browser_bug1070778.js
@@ -38,17 +38,17 @@ add_task(function*() {
   is_selected(1);
   // Re-select keyword item
   EventUtils.synthesizeKey("VK_UP", {});
   is_selected(0);
 
   EventUtils.synthesizeKey("b", {});
   yield promiseSearchComplete();
 
-  is(gURLBar.value, "keyword ab", "urlbar should have expected input");
+  is(gURLBar.textValue, "keyword ab", "urlbar should have expected input");
 
   let result = gURLBar.popup.richlistbox.firstChild;
   isnot(result, null, "Should have first item");
   let uri = NetUtil.newURI(result.getAttribute("url"));
   is(uri.spec, makeActionURI("keyword", {url: "http://example.com/?q=ab", input: "keyword ab"}).spec, "Expect correct url");
 
   EventUtils.synthesizeKey("VK_ESCAPE", {});
   yield promisePopupHidden(gURLBar.popup);
--- a/browser/base/content/test/urlbar/browser_urlbarAutoFillTrimURLs.js
+++ b/browser/base/content/test/urlbar/browser_urlbarAutoFillTrimURLs.js
@@ -39,18 +39,18 @@ function continue_test() {
     info(`Testing with input: ${aTyped}`);
     gURLBar.inputField.value = aTyped.substr(0, aTyped.length - 1);
     gURLBar.focus();
     gURLBar.selectionStart = aTyped.length - 1;
     gURLBar.selectionEnd = aTyped.length - 1;
 
     EventUtils.synthesizeKey(aTyped.substr(-1), {});
     waitForSearchComplete(function () {
-      info(`Got value: ${gURLBar.value}`);
-      is(gURLBar.value, aExpected, "Autofilled value is as expected");
+      info(`Got value: ${gURLBar.textValue}`);
+      is(gURLBar.textValue, aExpected, "Autofilled value is as expected");
       aCallback();
     });
   }
 
   test_autoFill("http://", "http://", function () {
     test_autoFill("http://au", "http://autofilltrimurl.com/", function () {
       test_autoFill("http://www.autofilltrimurl.com", "http://www.autofilltrimurl.com/", function () {
         // Now ensure selecting from the popup correctly trims.
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/urlbar/browser_urlbarOneOffs.js
@@ -0,0 +1,233 @@
+const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml";
+
+let gMaxResults;
+
+add_task(function* init() {
+  Services.prefs.setBoolPref("browser.urlbar.oneOffSearches", true);
+  gMaxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults");
+
+  // Add a search suggestion engine and move it to the front so that it appears
+  // as the first one-off.
+  let engine = yield promiseNewSearchEngine(TEST_ENGINE_BASENAME);
+  Services.search.moveEngine(engine, 0);
+
+  registerCleanupFunction(function* () {
+    yield hidePopup();
+    yield PlacesTestUtils.clearHistory();
+  });
+
+  yield PlacesTestUtils.clearHistory();
+
+  let visits = [];
+  for (let i = 0; i < gMaxResults; i++) {
+    visits.push({
+      uri: makeURI("http://example.com/browser_urlbarOneOffs.js/?" + i),
+      // TYPED so that the visit shows up when the urlbar's drop-down arrow is
+      // pressed.
+      transition: Ci.nsINavHistoryService.TRANSITION_TYPED,
+    });
+  }
+  yield PlacesTestUtils.addVisits(visits);
+});
+
+// Keys up and down through the history panel, i.e., the panel that's shown when
+// there's no text in the textbox.
+add_task(function* history() {
+  gURLBar.focus();
+  EventUtils.synthesizeKey("VK_DOWN", {})
+  yield promisePopupShown(gURLBar.popup);
+
+  assertState(-1, -1, "");
+
+  // Key down through each result.
+  for (let i = 0; i < gMaxResults; i++) {
+    EventUtils.synthesizeKey("VK_DOWN", {})
+    assertState(i, -1,
+      "example.com/browser_urlbarOneOffs.js/?" + (gMaxResults - i - 1));
+  }
+
+  // Key down through each one-off.
+  let numButtons =
+    gURLBar.popup.oneOffSearchButtons.getSelectableButtons(true).length;
+  for (let i = 0; i < numButtons; i++) {
+    EventUtils.synthesizeKey("VK_DOWN", {})
+    assertState(-1, i, "");
+  }
+
+  // Key down once more.  Nothing should be selected.
+  EventUtils.synthesizeKey("VK_DOWN", {})
+  assertState(-1, -1, "");
+
+  // Once more.  The first result should be selected.
+  EventUtils.synthesizeKey("VK_DOWN", {})
+  assertState(0, -1,
+    "example.com/browser_urlbarOneOffs.js/?" + (gMaxResults - 1));
+
+  // Now key up.  Nothing should be selected again.
+  EventUtils.synthesizeKey("VK_UP", {})
+  assertState(-1, -1, "");
+
+  // Key up through each one-off.
+  for (let i = numButtons - 1; i >= 0; i--) {
+    EventUtils.synthesizeKey("VK_UP", {})
+    assertState(-1, i, "");
+  }
+
+  // Key up through each result.
+  for (let i = gMaxResults - 1; i >= 0; i--) {
+    EventUtils.synthesizeKey("VK_UP", {})
+    assertState(i, -1,
+      "example.com/browser_urlbarOneOffs.js/?" + (gMaxResults - i - 1));
+  }
+
+  // Key up once more.  Nothing should be selected.
+  EventUtils.synthesizeKey("VK_UP", {})
+  assertState(-1, -1, "");
+
+  yield hidePopup();
+});
+
+// Keys up and down through the non-history panel, i.e., the panel that's shown
+// when you type something in the textbox.
+add_task(function* typedValue() {
+  // Use a typed value that returns the visits added above but that doesn't
+  // trigger autofill since that would complicate the test.
+  let typedValue = "browser_urlbarOneOffs";
+  yield promiseAutocompleteResultPopup(typedValue, window, true);
+
+  assertState(0, -1, typedValue);
+
+  // Key down through each result.  The first result is already selected, which
+  // is why gMaxResults - 1 is the correct number of times to do this.
+  for (let i = 0; i < gMaxResults - 1; i++) {
+    EventUtils.synthesizeKey("VK_DOWN", {})
+    // i starts at zero so that the textValue passed to assertState is correct.
+    // But that means that i + 1 is the expected selected index, since initially
+    // (when this loop starts) the first result is selected.
+    assertState(i + 1, -1,
+      "example.com/browser_urlbarOneOffs.js/?" + (gMaxResults - i - 1));
+  }
+
+  // Key down through each one-off.
+  let numButtons =
+    gURLBar.popup.oneOffSearchButtons.getSelectableButtons(true).length;
+  for (let i = 0; i < numButtons; i++) {
+    EventUtils.synthesizeKey("VK_DOWN", {})
+    assertState(-1, i, typedValue);
+  }
+
+  // Key down once more.  The selection should wrap around to the first result.
+  EventUtils.synthesizeKey("VK_DOWN", {})
+  assertState(0, -1, typedValue);
+
+  // Now key up.  The selection should wrap back around to the one-offs.  Key
+  // up through all the one-offs.
+  for (let i = numButtons - 1; i >= 0; i--) {
+    EventUtils.synthesizeKey("VK_UP", {})
+    assertState(-1, i, typedValue);
+  }
+
+  // Key up through each non-heuristic result.
+  for (let i = gMaxResults - 2; i >= 0; i--) {
+    EventUtils.synthesizeKey("VK_UP", {})
+    assertState(i + 1, -1,
+      "example.com/browser_urlbarOneOffs.js/?" + (gMaxResults - i - 1));
+  }
+
+  // Key up once more.  The heuristic result should be selected.
+  EventUtils.synthesizeKey("VK_UP", {})
+  assertState(0, -1, typedValue);
+
+  yield hidePopup();
+});
+
+// Checks that "Search with Current Search Engine" items are updated to "Search
+// with One-Off Engine" when a one-off is selected.
+add_task(function* searchWith() {
+  let typedValue = "foo";
+  yield promiseAutocompleteResultPopup(typedValue);
+
+  assertState(0, -1, typedValue);
+
+  let item = gURLBar.popup.richlistbox.firstChild;
+  Assert.equal(item._actionText.textContent,
+               "Search with " + Services.search.currentEngine.name,
+               "Sanity check: first result's action text");
+
+  // Tab to the first one-off.  Now the first result and the first one-off
+  // should both be selected.
+  EventUtils.synthesizeKey("VK_TAB", {})
+  assertState(0, 0, typedValue);
+
+  let engineName = gURLBar.popup.oneOffSearchButtons.selectedButton.engine.name;
+  Assert.notEqual(engineName, Services.search.currentEngine.name,
+                  "Sanity check: First one-off engine should not be " +
+                  "the current engine");
+  Assert.equal(item._actionText.textContent,
+               "Search with " + engineName,
+               "First result's action text should be updated");
+
+  yield hidePopup();
+});
+
+// Clicks a one-off.
+add_task(function* oneOffClick() {
+  gBrowser.selectedTab = gBrowser.addTab();
+
+  let typedValue = "foo";
+  yield promiseAutocompleteResultPopup(typedValue);
+
+  assertState(0, -1, typedValue);
+
+  let oneOffs = gURLBar.popup.oneOffSearchButtons.getSelectableButtons(true);
+  let resultsPromise = promiseSearchResultsLoaded();
+  EventUtils.synthesizeMouseAtCenter(oneOffs[0], {});
+  yield resultsPromise;
+
+  gBrowser.removeTab(gBrowser.selectedTab);
+});
+
+// Presses the Return key when a one-off is selected.
+add_task(function* oneOffReturn() {
+  gBrowser.selectedTab = gBrowser.addTab();
+
+  let typedValue = "foo";
+  yield promiseAutocompleteResultPopup(typedValue, window, true);
+
+  assertState(0, -1, typedValue);
+
+  // Tab to select the first one-off.
+  EventUtils.synthesizeKey("VK_TAB", {})
+  assertState(0, 0, typedValue);
+
+  let resultsPromise = promiseSearchResultsLoaded();
+  EventUtils.synthesizeKey("VK_RETURN", {})
+  yield resultsPromise;
+
+  gBrowser.removeTab(gBrowser.selectedTab);
+});
+
+
+function assertState(result, oneOff, textValue = undefined) {
+  Assert.equal(gURLBar.popup.selectedIndex, result,
+               "Expected result should be selected");
+  Assert.equal(gURLBar.popup.oneOffSearchButtons.selectedButtonIndex, oneOff,
+               "Expected one-off should be selected");
+  if (textValue !== undefined) {
+    Assert.equal(gURLBar.textValue, textValue, "Expected textValue");
+  }
+}
+
+function* hidePopup() {
+  EventUtils.synthesizeKey("VK_ESCAPE", {});
+  yield promisePopupHidden(gURLBar.popup);
+}
+
+function promiseSearchResultsLoaded() {
+  let tab = gBrowser.selectedTab;
+  return promiseTabLoadEvent(tab).then(() => {
+    Assert.equal(tab.linkedBrowser.currentURI.spec,
+                 "http://mochi.test:8888/",
+                 'Expected "search results" page loaded');
+  });
+}
--- a/browser/base/content/test/urlbar/browser_urlbarSearchSuggestions.js
+++ b/browser/base/content/test/urlbar/browser_urlbarSearchSuggestions.js
@@ -20,17 +20,20 @@ add_task(function* prepare() {
     Assert.ok(!gURLBar.popup.popupOpen, "popup should be closed");
   });
 });
 
 add_task(function* clickSuggestion() {
   gBrowser.selectedTab = gBrowser.addTab();
   gURLBar.focus();
   yield promiseAutocompleteResultPopup("foo");
-  let [idx, suggestion] = yield promiseFirstSuggestion();
+  let [idx, suggestion, engineName] = yield promiseFirstSuggestion();
+  Assert.equal(engineName,
+               "browser_searchSuggestionEngine%20searchSuggestionEngine.xml",
+               "Expected suggestion engine");
   let item = gURLBar.popup.richlistbox.getItemAtIndex(idx);
   let loadPromise = promiseTabLoaded(gBrowser.selectedTab);
   item.click();
   yield loadPromise;
   let uri = Services.search.currentEngine.getSubmission(suggestion).uri;
   Assert.ok(uri.equals(gBrowser.currentURI),
             "The search results page should have loaded");
   gBrowser.removeTab(gBrowser.selectedTab);
@@ -42,24 +45,24 @@ function getFirstSuggestion() {
   let present = false;
   for (let i = 0; i < matchCount; i++) {
     let url = controller.getValueAt(i);
     let mozActionMatch = url.match(/^moz-action:([^,]+),(.*)$/);
     if (mozActionMatch) {
       let [, type, paramStr] = mozActionMatch;
       let params = JSON.parse(paramStr);
       if (type == "searchengine" && "searchSuggestion" in params) {
-        return [i, params.searchSuggestion];
+        return [i, params.searchSuggestion, params.engineName];
       }
     }
   }
   return [-1, null];
 }
 
 function promiseFirstSuggestion() {
   return new Promise(resolve => {
-    let pair;
+    let tuple;
     waitForCondition(() => {
-      pair = getFirstSuggestion();
-      return pair[0] >= 0;
-    }, () => resolve(pair));
+      tuple = getFirstSuggestion();
+      return tuple[0] >= 0;
+    }, () => resolve(tuple));
   });
 }
--- a/browser/base/content/test/urlbar/browser_urlbarSearchTelemetry.js
+++ b/browser/base/content/test/urlbar/browser_urlbarSearchTelemetry.js
@@ -18,16 +18,22 @@ add_task(function* prepare() {
     // Clicking urlbar results causes visits to their associated pages, so clear
     // that history now.
     yield PlacesTestUtils.clearHistory();
 
     // Make sure the popup is closed for the next test.
     gURLBar.blur();
     Assert.ok(!gURLBar.popup.popupOpen, "popup should be closed");
   });
+  // Move the mouse away from the urlbar one-offs so that a one-off engine is
+  // not inadvertently selected.
+  yield new Promise(resolve => {
+    EventUtils.synthesizeNativeMouseMove(window.document.documentElement, 0, 0,
+                                         resolve);
+  });
 });
 
 add_task(function* heuristicResult() {
   yield compareCounts(function* () {
     gBrowser.selectedTab = gBrowser.addTab();
     yield promiseAutocompleteResultPopup("heuristicResult");
     let action = getActionAtIndex(0);
     Assert.ok(!!action, "there should be an action at index 0");
--- a/browser/base/content/test/urlbar/browser_urlbarStop.js
+++ b/browser/base/content/test/urlbar/browser_urlbarStop.js
@@ -15,17 +15,16 @@ add_task(function* () {
   gBrowser.selectedTab = gBrowser.addTab("about:blank");
   is(gURLBar.textValue, "", "location bar is empty");
 
   yield typeAndSubmitAndStop(badURL);
   is(gURLBar.textValue, gURLBar.trimValue(badURL), "location bar reflects stopped page in an empty tab");
   gBrowser.removeCurrentTab();
 });
 
-function typeAndSubmitAndStop(url) {
-  gBrowser.userTypedValue = url;
-  URLBarSetURI();
+function* typeAndSubmitAndStop(url) {
+  yield promiseAutocompleteResultPopup(url, window, true);
   is(gURLBar.textValue, gURLBar.trimValue(url), "location bar reflects loading page");
 
   let promise = waitForDocLoadAndStopIt(url, gBrowser.selectedBrowser, false);
   gURLBar.handleCommand();
-  return promise;
+  yield promise;
 }
--- a/browser/base/content/test/urlbar/browser_urlbar_autoFill_backspaced.js
+++ b/browser/base/content/test/urlbar/browser_urlbar_autoFill_backspaced.js
@@ -2,23 +2,23 @@
  * confirm the remaining value.
  */
 
 function* test_autocomplete(data) {
   let {desc, typed, autofilled, modified, keys, action, onAutoFill} = data;
   info(desc);
 
   yield promiseAutocompleteResultPopup(typed);
-  is(gURLBar.value, autofilled, "autofilled value is as expected");
+  is(gURLBar.textValue, autofilled, "autofilled value is as expected");
   if (onAutoFill)
     onAutoFill()
 
   keys.forEach(key => EventUtils.synthesizeKey(key, {}));
 
-  is(gURLBar.value, modified, "backspaced value is as expected");
+  is(gURLBar.textValue, modified, "backspaced value is as expected");
 
   yield promiseSearchComplete();
 
   ok(gURLBar.popup.richlistbox.children.length > 0, "Should get at least 1 result");
   let result = gURLBar.popup.richlistbox.children[0];
   let type = result.getAttribute("type");
   let types = type.split(/\s+/);
   ok(types.indexOf(action) >= 0, `The type attribute "${type}" includes the expected action "${action}"`);
--- a/browser/base/content/test/urlbar/head.js
+++ b/browser/base/content/test/urlbar/head.js
@@ -322,20 +322,28 @@ function promiseSearchComplete(win = win
         Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH;
     }
 
     // Wait until there are at least two matches.
     return new Promise(resolve => waitForCondition(searchIsComplete, resolve));
   });
 }
 
-function promiseAutocompleteResultPopup(inputText, win = window) {
+function promiseAutocompleteResultPopup(inputText,
+                                        win = window,
+                                        fireInputEvent = false) {
   waitForFocus(() => {
     win.gURLBar.focus();
     win.gURLBar.value = inputText;
+    if (fireInputEvent) {
+      // This is necessary to get the urlbar to set gBrowser.userTypedValue.
+      let event = document.createEvent("Events");
+      event.initEvent("input", true, true);
+      win.gURLBar.dispatchEvent(event);
+    }
     win.gURLBar.controller.startSearch(inputText);
   }, win);
 
   return promiseSearchComplete(win);
 }
 
 function promiseNewSearchEngine(basename) {
   return new Promise((resolve, reject) => {
--- a/browser/base/content/urlbarBindings.xml
+++ b/browser/base/content/urlbarBindings.xml
@@ -103,16 +103,18 @@ file, You can obtain one at http://mozil
           let label = Services.strings.createBundle("chrome://browser/locale/browser.properties").
                                    GetStringFromName("pasteAndGo.label");
           pasteAndGo.setAttribute("label", label);
           pasteAndGo.setAttribute("anonid", "paste-and-go");
           pasteAndGo.setAttribute("oncommand",
               "gURLBar.select(); goDoCommand('cmd_paste'); gURLBar.handleCommand();");
           cxmenu.insertBefore(pasteAndGo, insertLocation.nextSibling);
         }
+
+        this._enableOrDisableOneOffSearches();
       ]]></constructor>
 
       <destructor><![CDATA[
         this._prefs.removeObserver("", this);
         this._prefs = null;
         this.inputField.controllers.removeController(this._copyCutController);
         this.inputField.removeEventListener("paste", this, false);
         this.inputField.removeEventListener("mousedown", this, false);
@@ -192,17 +194,21 @@ file, You can obtain one at http://mozil
             case KeyEvent.DOM_VK_LEFT:
             case KeyEvent.DOM_VK_RIGHT:
             case KeyEvent.DOM_VK_HOME:
               // Reset the selected index so that nsAutoCompleteController
               // simply closes the popup without trying to fill anything.
               this.popup.selectedIndex = -1;
               break;
           }
-
+          if (this.popup.popupOpen &&
+              !this.popup.disableKeyNavigation &&
+              this.popup.handleKeyPress(aEvent)) {
+            return true;
+          }
           return this.handleKeyPress(aEvent);
         ]]></body>
       </method>
 
       <field name="_mayTrimURLs">true</field>
       <method name="trimValue">
         <parameter name="aURL"/>
         <body><![CDATA[
@@ -326,145 +332,234 @@ file, You can obtain one at http://mozil
           }
 
           // tell widget to revert to last typed text only if the user
           // was scrolling when they hit escape
           return !isScrolling;
         ]]></body>
       </method>
 
+      <!--
+        This is ultimately called by the autocomplete controller as the result
+        of handleEnter when the Return key is pressed in the textbox.  Since
+        onPopupClick also calls handleEnter, this is also called as a result in
+        that case.
+
+        @param event
+               The event that triggered the command.
+        @param openUILinkWhere
+               Optional.  The "where" to pass to openUILinkIn.  This method
+               computes the appropriate "where" given the event, but you can
+               use this to override it.
+        @param openUILinkParams
+               Optional.  The parameters to pass to openUILinkIn.  As with
+               "where", this method computes the appropriate parameters, but
+               any parameters you supply here will override those.
+      -->
       <method name="handleCommand">
-        <parameter name="aTriggeringEvent"/>
+        <parameter name="event"/>
+        <parameter name="openUILinkWhere"/>
+        <parameter name="openUILinkParams"/>
         <body><![CDATA[
-          if (aTriggeringEvent instanceof MouseEvent && aTriggeringEvent.button == 2)
-            return; // Do nothing for right clicks
+          let isMouseEvent = event instanceof MouseEvent;
+          if (isMouseEvent && event.button == 2) {
+            // Do nothing for right clicks.
+            return;
+          }
 
-          var url = this.value;
-          var mayInheritPrincipal = false;
-          var postData = null;
+          // Do the command of the selected one-off if it's not an engine.
+          let selectedOneOff =
+            this.popup.oneOffSearchButtons.visuallySelectedButton;
+          if (selectedOneOff && !selectedOneOff.engine) {
+            selectedOneOff.doCommand();
+            return;
+          }
 
-          let action = this._parseActionUrl(this._value);
+          let where = openUILinkWhere;
+          if (!where) {
+            if (isMouseEvent) {
+              where = whereToOpenLink(event, false, false);
+            } else {
+              // If the current tab is empty, ignore Alt+Enter (reuse this tab)
+              let altEnter = !isMouseEvent &&
+                             event &&
+                             event.altKey &&
+                             !isTabEmpty(gBrowser.selectedTab);
+              where = altEnter ? "tab" : "current";
+            }
+          }
+
+          let url = this.value;
+          let mayInheritPrincipal = false;
+          let postData = null;
           let lastLocationChange = gBrowser.selectedBrowser.lastLocationChange;
+          let matchLastLocationChange = true;
 
-          let matchLastLocationChange = true;
+          let action = this._parseActionUrl(url);
           if (action) {
-            if (action.type == "switchtab") {
-              url = action.params.url;
-              if (this.hasAttribute("actiontype")) {
-                this.handleRevert();
-                let prevTab = gBrowser.selectedTab;
-                if (switchToTabHavingURI(url) && isTabEmpty(prevTab)) {
-                  gBrowser.removeTab(prevTab);
+            switch (action.type) {
+              case "visiturl":
+              case "keyword":
+              case "remotetab":
+                url = action.params.url;
+                break;
+              case "switchtab":
+                url = action.params.url;
+                if (this.hasAttribute("actiontype")) {
+                  this.handleRevert();
+                  let prevTab = gBrowser.selectedTab;
+                  if (switchToTabHavingURI(url) && isTabEmpty(prevTab)) {
+                    gBrowser.removeTab(prevTab);
+                  }
+                  return;
                 }
-                return;
-              }
-            } else if (action.type == "remotetab") {
-              url = action.params.url;
-            } else if (action.type == "keyword") {
-              url = action.params.url;
-            } else if (action.type == "searchengine") {
-              [url, postData] = this._parseAndRecordSearchEngineAction(action);
-            } else if (action.type == "visiturl") {
-              url = action.params.url;
+                break;
+              case "searchengine":
+                if (selectedOneOff && selectedOneOff.engine) {
+                  // Replace the engine with the selected one-off engine.
+                  action.params.engineName = engine.name;
+                }
+                [url, postData] = this._recordSearchEngineLoad(
+                  action.params.engineName,
+                  action.params.searchSuggestion || action.params.searchQuery,
+                  event,
+                  where,
+                  openUILinkParams
+                );
+                break;
             }
-            continueOperation.call(this);
-          }
-          else {
-            this._canonizeURL(aTriggeringEvent, response => {
-              [url, postData, mayInheritPrincipal] = response;
-              if (url) {
-                matchLastLocationChange = (lastLocationChange ==
-                                           gBrowser.selectedBrowser.lastLocationChange);
-                continueOperation.call(this);
-              }
-            });
+            this._loadURL(url, postData, where, openUILinkParams,
+                          matchLastLocationChange, mayInheritPrincipal);
+            return;
           }
 
-          function continueOperation()
-          {
-            this.value = url;
-            gBrowser.userTypedValue = url;
-            if (gInitialPages.includes(url)) {
-              gBrowser.selectedBrowser.initialPageLoadedFromURLBar = url;
+          // If there's a selected one-off button and the input value is a
+          // search query (or "keyword" in URI-fixup terminology), then load a
+          // search using the one-off's engine.
+          if (selectedOneOff && selectedOneOff.engine) {
+            // `url` (which is this.value) may be an autofilled string.  Search
+            // only with the portion that the user typed, if any, by preferring
+            // the autocomplete controller's searchString.
+            let value = this._searchStringOnHandleEnter ||
+                        this.mController.searchString ||
+                        url;
+            let fixup;
+            try {
+              fixup = Services.uriFixup.getFixupURIInfo(
+                value,
+                Services.uriFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP
+              );
+            } catch (ex) {}
+            if (fixup && fixup.keywordProviderName) {
+              [url, postData] =
+                this._recordSearchEngineLoad(selectedOneOff.engine, value,
+                                             event, where, openUILinkParams);
+              this._loadURL(url, postData, where, openUILinkParams,
+                            matchLastLocationChange, mayInheritPrincipal);
+              return;
             }
-            try {
-              addToUrlbarHistory(url);
-            } catch (ex) {
-              // Things may go wrong when adding url to session history,
-              // but don't let that interfere with the loading of the url.
-              Cu.reportError(ex);
-            }
+          }
 
-            let loadCurrent = () => {
-              try {
-                openUILinkIn(url, "current", {
-                  allowThirdPartyFixup: true,
-                  indicateErrorPageLoad: true,
-                  disallowInheritPrincipal: !mayInheritPrincipal,
-                  allowPinnedTabHostChange: true,
-                  postData: postData,
-                  allowPopups: url.startsWith("javascript:"),
-                });
-              } catch (ex) {
-                // This load can throw an exception in certain cases, which means
-                // we'll want to replace the URL with the loaded URL:
-                if (ex.result != Cr.NS_ERROR_LOAD_SHOWED_ERRORPAGE) {
-                  this.handleRevert();
-                }
-              }
+          this._canonizeURL(event, response => {
+            [url, postData, mayInheritPrincipal] = response;
+            if (url) {
+              matchLastLocationChange =
+                lastLocationChange ==
+                gBrowser.selectedBrowser.lastLocationChange;
+              this._loadURL(url, postData, where, openUILinkParams,
+                            matchLastLocationChange, mayInheritPrincipal);
+            }
+          });
+        ]]></body>
+      </method>
 
-              // Ensure the start of the URL is visible for UX reasons:
-              this.selectionStart = this.selectionEnd = 0;
-            };
-
-            // Focus the content area before triggering loads, since if the load
-            // occurs in a new tab, we want focus to be restored to the content
-            // area when the current tab is re-selected.
-            gBrowser.selectedBrowser.focus();
-
-            let isMouseEvent = aTriggeringEvent instanceof MouseEvent;
-
-            // If the current tab is empty, ignore Alt+Enter (just reuse this tab)
-            let altEnter = !isMouseEvent && aTriggeringEvent &&
-              aTriggeringEvent.altKey && !isTabEmpty(gBrowser.selectedTab);
+      <method name="_loadURL">
+        <parameter name="url"/>
+        <parameter name="postData"/>
+        <parameter name="openUILinkWhere"/>
+        <parameter name="openUILinkParams"/>
+        <parameter name="matchLastLocationChange"/>
+        <parameter name="mayInheritPrincipal"/>
+        <body><![CDATA[
+          this.value = url;
+          gBrowser.userTypedValue = url;
+          if (gInitialPages.includes(url)) {
+            gBrowser.selectedBrowser.initialPageLoadedFromURLBar = url;
+          }
+          try {
+            addToUrlbarHistory(url);
+          } catch (ex) {
+            // Things may go wrong when adding url to session history,
+            // but don't let that interfere with the loading of the url.
+            Cu.reportError(ex);
+          }
 
-            if (isMouseEvent || altEnter) {
-              // Use the standard UI link behaviors for clicks or Alt+Enter
-              let where = "tab";
-              if (isMouseEvent)
-                where = whereToOpenLink(aTriggeringEvent, false, false);
+          let params = {
+            postData: postData,
+            allowThirdPartyFixup: true,
+          };
+          if (openUILinkWhere == "current") {
+            params.indicateErrorPageLoad = true;
+            params.allowPinnedTabHostChange = true;
+            params.disallowInheritPrincipal = !mayInheritPrincipal;
+            params.allowPopups = url.startsWith("javascript:");
+          } else {
+            params.initiatingDoc = document;
+          }
+
+          if (openUILinkParams) {
+            for (let key in openUILinkParams) {
+              params[key] = openUILinkParams[key];
+            }
+          }
 
-              if (where == "current") {
-                if (matchLastLocationChange) {
-                  loadCurrent();
-                }
-              } else {
-                this.handleRevert();
-                let params = { allowThirdPartyFixup: true,
-                               postData: postData,
-                               initiatingDoc: document };
-                openUILinkIn(url, where, params);
-              }
-            } else {
-              if (matchLastLocationChange) {
-                loadCurrent();
-              }
+          // Focus the content area before triggering loads, since if the load
+          // occurs in a new tab, we want focus to be restored to the content
+          // area when the current tab is re-selected.
+          gBrowser.selectedBrowser.focus();
+
+          if (openUILinkWhere == "current" && !matchLastLocationChange) {
+            return;
+          }
+
+          if (openUILinkWhere != "current") {
+            this.handleRevert();
+          }
+
+          try {
+            openUILinkIn(url, openUILinkWhere, params);
+          } catch (ex) {
+            // This load can throw an exception in certain cases, which means
+            // we'll want to replace the URL with the loaded URL:
+            if (ex.result != Cr.NS_ERROR_LOAD_SHOWED_ERRORPAGE) {
+              this.handleRevert();
             }
           }
+
+          if (openUILinkWhere == "current") {
+            // Ensure the start of the URL is visible for usability reasons.
+            this.selectionStart = this.selectionEnd = 0;
+          }
         ]]></body>
       </method>
 
-      <method name="_parseAndRecordSearchEngineAction">
-        <parameter name="action"/>
+      <method name="_recordSearchEngineLoad">
+        <parameter name="engineOrEngineName"/>
+        <parameter name="query"/>
+        <parameter name="event"/>
+        <parameter name="openUILinkWhere"/>
+        <parameter name="openUILinkParams"/>
         <body><![CDATA[
           let engine =
-            Services.search.getEngineByName(action.params.engineName);
+            typeof(engineOrEngineName) == "string" ?
+              Services.search.getEngineByName(engineOrEngineName) :
+              engineOrEngineName;
           BrowserSearch.recordSearchInTelemetry(engine, "urlbar");
-          let query = action.params.searchSuggestion ||
-                      action.params.searchQuery;
+          this.popup.oneOffSearchButtons
+              .maybeRecordTelemetry(event, openUILinkWhere, openUILinkParams);
           let submission = engine.getSubmission(query, null, "keyword");
           return [submission.uri.spec, submission.postData];
         ]]></body>
       </method>
 
       <method name="_canonizeURL">
         <parameter name="aTriggeringEvent"/>
         <parameter name="aCallback"/>
@@ -723,21 +818,31 @@ file, You can obtain one at http://mozil
                   this.popup.searchSuggestionsNotificationWasDismissed(
                     this._prefs.getBoolPref("suggest.searches")
                   );
                 }
                 break;
               case "trimURLs":
                 this._mayTrimURLs = this._prefs.getBoolPref(aData);
                 break;
+              case "oneOffSearches":
+                this._enableOrDisableOneOffSearches();
+                break;
             }
           }
         ]]></body>
       </method>
 
+      <method name="_enableOrDisableOneOffSearches">
+        <body><![CDATA[
+          let enable = this._prefs.getBoolPref("oneOffSearches");
+          this.popup.enableOneOffSearches(enable);
+        ]]></body>
+      </method>
+
       <method name="handleEvent">
         <parameter name="aEvent"/>
         <body><![CDATA[
           switch (aEvent.type) {
             case "paste":
               let originalPasteData = aEvent.clipboardData.getData("text/plain");
               if (!originalPasteData) {
                 return;
@@ -898,33 +1003,40 @@ file, You can obtain one at http://mozil
             this.gotResultForCurrentQuery = false;
             this.mController.handleText();
           }
           this.resetActionType();
         ]]></body>
       </method>
 
       <method name="handleEnter">
+        <parameter name="event"/>
         <body><![CDATA[
           // We need to ensure we're using a selected autocomplete result.
           // A result should automatically be selected by default,
           // however autocomplete is async and therefore we may not
           // have a result set relating to the current input yet. If that
           // happens, we need to mark that when the first result does get added,
           // it needs to be handled as if enter was pressed with that first
           // result selected.
           // If anything other than the default (first) result is selected, then
           // it must have been manually selected by the human. We let this
           // explicit choice be used, even if it may be related to a previous
           // input.
           // However, if the default result is automatically selected, we
           // ensure that it corresponds to the current input.
 
           if (this.popup.selectedIndex != 0 || this.gotResultForCurrentQuery) {
-            return this.mController.handleEnter(false);
+            // Store the current search string so it can be used in
+            // handleCommand, which will be called as a result of
+            // mController.handleEnter().  handleEnter will reset it.
+            this._searchStringOnHandleEnter = this.mController.searchString;
+            let rv = this.mController.handleEnter(false, event);
+            delete this._searchStringOnHandleEnter;
+            return rv;
           }
 
           this.handleEnterWhenGotResult = true;
 
           return true;
         ]]></body>
       </method>
 
@@ -1094,17 +1206,17 @@ file, You can obtain one at http://mozil
               index: controller.selection.currentIndex,
               kind: "mouse"
             };
           }
 
           // Check for unmodified left-click, and use default behavior
           if (aEvent.button == 0 && !aEvent.shiftKey && !aEvent.ctrlKey &&
               !aEvent.altKey && !aEvent.metaKey) {
-            controller.handleEnter(true);
+            controller.handleEnter(true, aEvent);
             return;
           }
 
           // Check for middle-click or modified clicks on the search bar
           if (popupForSearchBar) {
             // Handle search bar popup clicks
             var search = controller.getValueAt(this.selectedIndex);
 
@@ -1131,16 +1243,21 @@ file, You can obtain one at http://mozil
           }
         ]]></body>
       </method>
     </implementation>
   </binding>
 
   <binding id="urlbar-rich-result-popup" extends="chrome://global/content/bindings/autocomplete.xml#autocomplete-rich-result-popup">
 
+    <resources>
+      <stylesheet src="chrome://browser/content/search/searchbarBindings.css"/>
+      <stylesheet src="chrome://browser/skin/searchbar.css"/>
+    </resources>
+
     <content ignorekeys="true" level="top" consumeoutsideclicks="never"
              aria-owns="richlistbox">
       <xul:hbox anonid="search-suggestions-notification"
                 align="center"
                 role="alert"
                 aria-describedby="search-suggestions-notification-text">
         <xul:description flex="1">
           &urlbar.searchSuggestionsNotification.question;
@@ -1165,16 +1282,20 @@ file, You can obtain one at http://mozil
                     label="&urlbar.searchSuggestionsNotification.enable;"
                     accesskey="&urlbar.searchSuggestionsNotification.enable.accesskey;"
                     onclick="document.getBindingParent(this).dismissSearchSuggestionsNotification(true);"/>
       </xul:hbox>
       <xul:richlistbox anonid="richlistbox" class="autocomplete-richlistbox"
                        flex="1"/>
       <xul:hbox anonid="footer">
         <children/>
+        <xul:vbox anonid="one-off-search-buttons"
+                  class="search-one-offs"
+                  compact="true"
+                  flex="1"/>
       </xul:hbox>
     </content>
 
     <implementation>
       <field name="_maxResults">0</field>
 
       <field name="_bundle" readonly="true">
         Cc["@mozilla.org/intl/stringbundle;1"].
@@ -1187,16 +1308,41 @@ file, You can obtain one at http://mozil
           this, "anonid", "search-suggestions-notification"
         );
       </field>
 
       <field name="footer" readonly="true">
         document.getAnonymousElementByAttribute(this, "anonid", "footer");
       </field>
 
+      <field name="oneOffSearchButtons" readonly="true">
+        document.getAnonymousElementByAttribute(this, "anonid",
+                                                "one-off-search-buttons");
+      </field>
+
+      <field name="_oneOffSearchesEnabled">false</field>
+
+      <method name="enableOneOffSearches">
+        <parameter name="enable"/>
+        <body><![CDATA[
+          this._oneOffSearchesEnabled = enable;
+          if (enable) {
+            this.oneOffSearchButtons.style.display = "-moz-box";
+            this.oneOffSearchButtons.popup = this;
+            this.oneOffSearchButtons.textbox = this.input;
+            this.oneOffSearchButtons.telemetryOrigin = "urlbar";
+          } else {
+            this.oneOffSearchButtons.style.display = "none";
+            this.oneOffSearchButtons.popup = null;
+            this.oneOffSearchButtons.textbox = null;
+            this.oneOffSearchButtons.telemetryOrigin = null;
+          }
+        ]]></body>
+      </method>
+
       <method name="openSearchSuggestionsNotificationLearnMoreURL">
         <body><![CDATA[
         let url = Services.urlFormatter.formatURL(
           Services.prefs.getCharPref("app.support.baseURL") + "suggestions"
         );
         openUILinkIn(url, "tab");
         ]]></body>
       </method>
@@ -1469,72 +1615,78 @@ file, You can obtain one at http://mozil
               resolve();
             };
             this.addEventListener("transitionend", onTransitionEnd, true);
           });
           ]]>
         </body>
       </method>
 
-      <method name="onPopupClick">
-        <parameter name="aEvent"/>
-        <body>
-          <![CDATA[
-          // Ignore right-clicks
-          if (aEvent.button == 2)
-            return;
-
-          var controller = this.view.QueryInterface(Components.interfaces.nsIAutoCompleteController);
-
-          // Check for unmodified left-click, and use default behavior
-          if (aEvent.button == 0 && !aEvent.shiftKey && !aEvent.ctrlKey &&
-              !aEvent.altKey && !aEvent.metaKey) {
-            controller.handleEnter(true);
-            return;
-          }
-
-          // Check for middle-click or modified clicks on the URL bar
-          if (gURLBar && this.mInput == gURLBar) {
-            var url = controller.getValueAt(this.selectedIndex);
-            var options = {};
-
-            // close the autocomplete popup and revert the entered address
-            this.closePopup();
-            controller.handleEscape();
-
-            // Check if this is meant to be an action
-            let action = this.mInput._parseActionUrl(url);
-            if (action) {
-              // TODO (bug 1054816): Centralise the implementation of actions
-              // into a JS module.
-              switch (action.type) {
-                case "switchtab": // Fall through.
-                case "keyword": // Fall through.
-                case "visiturl": {
-                  url = action.params.url;
-                  break;
-                }
-                case "searchengine": {
-                  [url, options.postData] =
-                    this.input._parseAndRecordSearchEngineAction(action);
-                  break;
-                }
-                default: {
-                  return;
-                }
+      <method name="_visuallySelectedOneOffChanged">
+        <body><![CDATA[
+          // Update all searchengine result items to use the newly selected
+          // engine.
+          for (let item of this.richlistbox.childNodes) {
+            if (item.collapsed) {
+              break;
+            }
+            let url = item.getAttribute("url");
+            if (url) {
+              let action = item._parseActionUrl(url);
+              if (action && action.type == "searchengine") {
+                item._adjustAcItem();
               }
             }
+          }
+        ]]></body>
+      </method>
 
-            // respect the usual clicking subtleties
-            openUILink(url, aEvent, options);
-          }
-        ]]>
-        </body>
+      <!-- This handles keypress changes to the selection among the one-off
+           search buttons and between the one-offs and the listbox.  It returns
+           true if the keypress was consumed and false if not. -->
+      <method name="handleKeyPress">
+        <parameter name="aEvent"/>
+        <body><![CDATA[
+          this.oneOffSearchButtons.handleKeyPress(aEvent, this._matchCount,
+                                                  !this._isFirstResultHeuristic,
+                                                  gBrowser.userTypedValue);
+          return aEvent.defaultPrevented;
+        ]]></body>
       </method>
 
+      <!-- This is called when a one-off is clicked and when "search in new tab"
+           is selected from a one-off context menu. -->
+      <method name="handleOneOffSearch">
+        <parameter name="event"/>
+        <parameter name="engine"/>
+        <parameter name="where"/>
+        <parameter name="params"/>
+        <body><![CDATA[
+          this.input.handleCommand(event, where, params);
+        ]]></body>
+      </method>
+
+      <!-- Result listitems call this to determine which search engine they
+           should show in their labels and include in their url attributes. -->
+      <property name="overrideSearchEngineName" readonly="true">
+        <getter><![CDATA[
+          // When building the popup, autocomplete reuses an item at index i if
+          // that item's url attribute matches the controller's value at index
+          // i, but only if overrideSearchEngineName matches the engine in the
+          // url attribute.  To absolutely avoid reusing items that shouldn't be
+          // reused, always return a non-null name here by falling back to the
+          // current engine.
+          let engine =
+            (this.oneOffSearchButtons.visuallySelectedButton &&
+             this.oneOffSearchButtons.visuallySelectedButton.engine) ||
+             Services.search.currentEngine;
+          return engine ? engine.name : null;
+        ]]></getter>
+      </property>
+
       <method name="createResultLabel">
         <parameter name="item"/>
         <parameter name="proposedLabel"/>
         <body>
           <![CDATA[
             let parts = [proposedLabel];
 
             let action = this.mInput._parseActionUrl(item.getAttribute("url"));
@@ -1594,16 +1746,20 @@ file, You can obtain one at http://mozil
             }
           ]]>
         </body>
       </method>
 
     </implementation>
     <handlers>
 
+      <handler event="OneOffsVisuallySelectedButtonChanged"><![CDATA[
+        this._visuallySelectedOneOffChanged();
+      ]]></handler>
+
       <handler event="mousedown"><![CDATA[
         // Required to make the xul:label.text-link elements in the search
         // suggestions notification work correctly when clicked on Linux.
         // This is copied from the mousedown handler in
         // browser-search-autocomplete-result-popup, which apparently had a
         // similar problem.
         event.preventDefault();
       ]]></handler>
--- a/browser/components/search/content/search.xml
+++ b/browser/components/search/content/search.xml
@@ -327,20 +327,18 @@
         ]]></body>
       </method>
 
       <method name="handleSearchCommand">
         <parameter name="aEvent"/>
         <parameter name="aEngine"/>
         <parameter name="aForceNewTab"/>
         <body><![CDATA[
-          var textBox = this._textbox;
-          var textValue = textBox.value;
-
           var where = "current";
+          let params;
 
           // Open ctrl/cmd clicks on one-off buttons in a new background tab.
           if (aEvent && aEvent.originalTarget.getAttribute("anonid") == "search-go-button") {
             if (aEvent.button == 2)
               return;
             where = whereToOpenLink(aEvent, false, true);
           }
           else if (aForceNewTab) {
@@ -349,63 +347,77 @@
               where += "-background";
           }
           else {
             var newTabPref = Services.prefs.getBoolPref("browser.search.openintab");
             if (((aEvent instanceof KeyboardEvent) && aEvent.altKey) ^ newTabPref)
               where = "tab";
             if ((aEvent instanceof MouseEvent) &&
                 (aEvent.button == 1 || aEvent.getModifierState("Accel"))) {
-              where = "tab-background";
+              where = "tab";
+              params = {
+                inBackground: true,
+              };
             }
           }
 
+          this.handleSearchCommandWhere(aEvent, aEngine, where, params);
+        ]]></body>
+      </method>
+
+      <method name="handleSearchCommandWhere">
+        <parameter name="aEvent"/>
+        <parameter name="aEngine"/>
+        <parameter name="aWhere"/>
+        <parameter name="aParams"/>
+        <body><![CDATA[
+          var textBox = this._textbox;
+          var textValue = textBox.value;
+
           let selection = this.telemetrySearchDetails;
-          this.doSearch(textValue, where, aEngine);
+          this.doSearch(textValue, aWhere, aEngine, aParams);
 
           if (!selection || (selection.index == -1)) {
-            let source = "unknown";
-            let type = "unknown";
-            let target = aEvent.originalTarget;
-            if (aEvent instanceof KeyboardEvent) {
-              type = "key";
-              if (this._textbox.selectedButton) {
-                source = "oneoff";
+            let recorded = this.textbox.popup.oneOffButtons
+                               .maybeRecordTelemetry(aEvent, aWhere, aParams);
+            if (!recorded) {
+              let source = "unknown";
+              let type = "unknown";
+              let target = aEvent.originalTarget;
+              if (aEvent instanceof KeyboardEvent) {
+                type = "key";
+              } else if (aEvent instanceof MouseEvent) {
+                type = "mouse";
+                if (target.classList.contains("search-panel-header") ||
+                    target.parentNode.classList.contains("search-panel-header")) {
+                  source = "header";
+                }
+              } else if (aEvent instanceof XULCommandEvent) {
+                if (target.getAttribute("anonid") == "paste-and-search") {
+                  source = "paste";
+                }
               }
-            } else if (aEvent instanceof MouseEvent) {
-              type = "mouse";
-              if (target.classList.contains("searchbar-engine-one-off-item")) {
-                source = "oneoff";
-              } else if (target.classList.contains("search-panel-header") ||
-                         target.parentNode.classList.contains("search-panel-header")) {
-                source = "header";
+              if (!aEngine) {
+                aEngine = this.currentEngine;
               }
-            } else if (aEvent instanceof XULCommandEvent) {
-              if (target.getAttribute("anonid") == "paste-and-search") {
-                source = "paste";
-              } else if (target.getAttribute("anonid") == "search-one-offs-context-open-in-new-tab") {
-                source = "oneoff-context";
-              }
+              BrowserSearch.recordOneoffSearchInTelemetry(aEngine, source, type,
+                                                          aWhere);
             }
-
-            if (!aEngine) {
-              aEngine = this.currentEngine;
-            }
-            BrowserSearch.recordOneoffSearchInTelemetry(aEngine, source, type, where);
           }
 
-          if (where == "tab-background")
+          if (aWhere == "tab" && aParams && aParams.inBackground)
             this.focus();
         ]]></body>
       </method>
 
       <method name="doSearch">
         <parameter name="aData"/>
         <parameter name="aWhere"/>
         <parameter name="aEngine"/>
+        <parameter name="aParams"/>
         <body><![CDATA[
           var textBox = this._textbox;
 
           // Save the current value in the form history
           if (aData && !PrivateBrowsingUtils.isWindowPrivate(window) && this.FormHistory.enabled) {
             this.FormHistory.update(
               { op : "bump",
                 fieldname : textBox.getAttribute("autocompletesearchparam"),
@@ -421,21 +433,23 @@
           this.telemetrySearchDetails = null;
           if (telemetrySearchDetails && telemetrySearchDetails.index == -1) {
             telemetrySearchDetails = null;
           }
           BrowserSearch.recordSearchInTelemetry(engine, "searchbar", telemetrySearchDetails);
           // null parameter below specifies HTML response for search
           let params = {
             postData: submission.postData,
-            inBackground: aWhere == "tab-background"
           };
-          openUILinkIn(submission.uri.spec,
-                       aWhere == "tab-background" ? "tab" : aWhere,
-                       params);
+          if (aParams) {
+            for (let key in aParams) {
+              params[key] = aParams[key];
+            }
+          }
+          openUILinkIn(submission.uri.spec, aWhere, params);
         ]]></body>
       </method>
     </implementation>
 
     <handlers>
       <handler event="command"><![CDATA[
         const target = event.originalTarget;
         if (target.engine) {
@@ -640,16 +654,26 @@
         we can override just the getter.  If that proves to be the case, the setter
         can be removed.
       -->
       <property name="searchParam"
                 onget="return this.getAttribute('autocompletesearchparam') +
                        (PrivateBrowsingUtils.isWindowPrivate(window) ? '|private' : '');"
                 onset="this.setAttribute('autocompletesearchparam', val); return val;"/>
 
+      <!-- This is implemented so that when textbox.value is set directly (e.g.,
+           by tests), the one-off query is updated. -->
+      <method name="onBeforeValueSet">
+        <parameter name="aValue"/>
+        <body><![CDATA[
+          this.popup.oneOffButtons.query = aValue;
+          return aValue;
+        ]]></body>
+      </method>
+
       <!--
         This method overrides the autocomplete binding's openPopup (essentially
         duplicating the logic from the autocomplete popup binding's
         openAutocompletePopup method), modifying it so that the popup is aligned with
         the inner textbox, but sized to not extend beyond the search bar border.
       -->
       <method name="openPopup">
         <body><![CDATA[
@@ -717,222 +741,61 @@
           ]]>
         </body>
       </method>
 
       <!-- override |onTextEntered| in autocomplete.xml -->
       <method name="onTextEntered">
         <parameter name="aEvent"/>
         <body><![CDATA[
-          var evt = aEvent || this.mEnterEvent;
-
           let engine;
           let oneOff = this.selectedButton;
           if (oneOff) {
             if (!oneOff.engine) {
               oneOff.doCommand();
-              this.mEnterEvent = null;
               return;
             }
             engine = oneOff.engine;
           }
-          if (this.mEnterEvent && this._selectionDetails &&
+          if (this._selectionDetails &&
               this._selectionDetails.currentIndex != -1) {
             BrowserSearch.searchBar.telemetrySearchDetails = this._selectionDetails;
             this._selectionDetails = null;
           }
-          document.getBindingParent(this).handleSearchCommand(evt, engine);
-
-          this.mEnterEvent = null;
-        ]]></body>
-      </method>
-
-      <field name="_selectedButton"/>
-      <property name="selectedButton" onget="return this._selectedButton;">
-        <setter><![CDATA[
-          this._changeVisuallySelectedButton(val, true);
-        ]]></setter>
-      </property>
-      <method name="_changeVisuallySelectedButton">
-        <parameter name="val"/>
-        <parameter name="aUpdateLogicallySelectedButton"/>
-        <body><![CDATA[
-          let list = this.getSelectableButtons();
-          let visuallySelectedButton = list.find(button => {
-            return button.getAttribute("selected") == "true"
-          });
-          if (visuallySelectedButton)
-            visuallySelectedButton.removeAttribute("selected");
-
-          let textbox = document.getBindingParent(this).textbox;
-          let header =
-            document.getAnonymousElementByAttribute(this.popup, "anonid",
-                                                    "search-panel-one-offs-header");
-          // Avoid selecting dummy buttons.
-          if (val && !val.classList.contains("dummy")) {
-            val.setAttribute("selected", "true");
-            if (aUpdateLogicallySelectedButton)
-              this._selectedButton = val;
-            if (val.classList.contains("searchbar-engine-one-off-item")) {
-              let headerEngineText =
-                document.getAnonymousElementByAttribute(this.popup, "anonid",
-                                                        "searchbar-oneoffheader-engine");
-              header.selectedIndex = 2;
-              headerEngineText.value = val.engine.name;
-            }
-            else {
-              header.selectedIndex = textbox.value ? 1 : 0;
-            }
-            this.setAttribute("aria-activedescendant", val.id);
-          } else {
-            header.selectedIndex = textbox.value ? 1 : 0;
-            this.removeAttribute("aria-activedescendant");
-            if (aUpdateLogicallySelectedButton)
-              this._selectedButton = null;
-          }
+          document.getBindingParent(this).handleSearchCommand(aEvent, engine);
         ]]></body>
       </method>
 
-      <method name="getSelectableButtons">
-        <parameter name="aCycleEngines"/>
-        <body><![CDATA[
-          let buttons = [];
-          let oneOff = document.getAnonymousElementByAttribute(this.popup, "anonid",
-                                                               "search-panel-one-offs");
-          for (oneOff = oneOff.firstChild; oneOff; oneOff = oneOff.nextSibling) {
-            if (oneOff.classList.contains("dummy"))
-              break;
-            buttons.push(oneOff);
-          }
-
-          if (aCycleEngines)
-            return buttons;
-
-          let addEngine =
-            document.getAnonymousElementByAttribute(this.popup, "anonid", "add-engines");
-          for (addEngine = addEngine.firstChild; addEngine; addEngine = addEngine.nextSibling)
-            buttons.push(addEngine);
-
-          buttons.push(document.getAnonymousElementByAttribute(this.popup, "anonid",
-                                                               "search-settings"));
-          return buttons;
-        ]]></body>
-      </method>
-
-      <method name="advanceSelection">
-        <parameter name="aForward"/>
-        <parameter name="aSkipSuggestions"/>
-        <parameter name="aCycleEngines"/>
-        <body><![CDATA[
-          let popup = this.popup;
-          let list = document.getAnonymousElementByAttribute(popup, "anonid",
-                                                             "search-panel-one-offs");
-          let selectedButton = this.selectedButton;
-          let buttons = this.getSelectableButtons(aCycleEngines);
-
-          let suggestionsHidden;
-          if (!aSkipSuggestions) {
-            let suggestions = document.getAnonymousElementByAttribute(popup, "anonid", "tree");
-            suggestionsHidden = suggestions.getAttribute("collapsed") == "true";
-            aSkipSuggestions = suggestionsHidden;
-          }
-
-          // If the last suggestion is selected, DOWN selects the first button.
-          if (!aSkipSuggestions && aForward &&
-              popup.selectedIndex + 1 == popup.view.rowCount) {
-            this.selectedButton = buttons[0];
-            return false;
-          }
-
-          // If a one-off is selected and no suggestion is selected (or we skip them)
-          if (selectedButton && (popup.selectedIndex == -1 || aSkipSuggestions)) {
-            // cycle through one-off buttons.
-            let index = buttons.indexOf(selectedButton);
-            if (aForward)
-              ++index;
-            else
-              --index;
-            if (index >= 0 && index < buttons.length)
-              this.selectedButton = buttons[index];
-            else
-              this.selectedButton = null;
-
-            if (this.selectedButton || aCycleEngines || suggestionsHidden)
-              return true;
-
-            // Set the selectedIndex to something that will make
-            // handleKeyNavigation (called by autocomplete.xml's onKeyPress
-            // method) reset the text field value to what the user typed.
-            // Doesn't work when aSkipSuggestions=true, see bug 1124747.
-            if (aForward)
-              popup.selectedIndex = popup.view.rowCount - 1;
-            else
-              popup.selectedIndex = popup.view.rowCount;
-            return false;
-          }
-
-          if (!selectedButton) {
-            // If no selection, select the first button or ...
-            if (aForward && aSkipSuggestions) {
-              this.selectedButton = buttons[0];
-              return true;
-            }
-
-            if (!aForward && (aCycleEngines || suggestionsHidden ||
-                              (!aSkipSuggestions && popup.selectedIndex == -1))) {
-              // the last button.
-              this.selectedButton = buttons[buttons.length - 1];
-              return true;
-            }
-          }
-
-          return false;
-        ]]></body>
-      </method>
+      <property name="selectedButton">
+        <getter><![CDATA[
+          return this.popup.oneOffButtons.selectedButton;
+        ]]></getter>
+        <setter><![CDATA[
+          return this.popup.oneOffButtons.selectedButton = val;
+        ]]></setter>
+      </property>
 
       <method name="handleKeyboardNavigation">
         <parameter name="aEvent"/>
         <body><![CDATA[
           let popup = this.popup;
           if (!popup.popupOpen)
             return;
 
-          let list = document.getAnonymousElementByAttribute(popup, "anonid",
-                                                             "search-panel-one-offs");
-          if (!list) // remove this check when removing the old search UI.
-            return;
-
           // accel + up/down changes the default engine and shouldn't affect
           // the selection on the one-off buttons.
           if (aEvent.getModifierState("Accel"))
             return;
 
-          let stopEvent = false;
-
-          // Alt + up/down is very similar to (shift +) tab but differs in that
-          // it loops through the list, whereas tab will move the focus out.
-          if (aEvent.altKey &&
-              (aEvent.keyCode == KeyEvent.DOM_VK_DOWN ||
-               aEvent.keyCode == KeyEvent.DOM_VK_UP)) {
-            stopEvent =
-              this.advanceSelection(aEvent.keyCode == KeyEvent.DOM_VK_DOWN,
-                                    true, true);
-          }
-          else if (aEvent.keyCode == KeyEvent.DOM_VK_DOWN ||
-                   aEvent.keyCode == KeyEvent.DOM_VK_UP) {
-            stopEvent = this.advanceSelection(aEvent.keyCode == KeyEvent.DOM_VK_DOWN);
-          }
-          else if (aEvent.keyCode == KeyEvent.DOM_VK_TAB) {
-            stopEvent = this.advanceSelection(!aEvent.shiftKey, true);
-          }
-
-          if (stopEvent) {
-            aEvent.preventDefault();
-            aEvent.stopPropagation();
-          }
+          let suggestions =
+            document.getAnonymousElementByAttribute(popup, "anonid", "tree");
+          let suggestionsHidden =
+            suggestions.getAttribute("collapsed") == "true";
+          let numItems = suggestionsHidden ? 0 : this.popup.view.rowCount;
+          this.popup.oneOffButtons.handleKeyPress(aEvent, numItems, true);
         ]]></body>
       </method>
 
       <!-- nsIController -->
       <field name="searchbarController" readonly="true"><![CDATA[({
         _self: this,
         supportsCommand: function(aCommand) {
           return aCommand == "cmd_clearhistory" ||
@@ -962,16 +825,20 @@
             default:
               // do nothing with unrecognized command
           }
         }
       })]]></field>
     </implementation>
 
     <handlers>
+      <handler event="input"><![CDATA[
+        this.popup.removeAttribute("showonlysettings");
+      ]]></handler>
+
       <handler event="keypress" phase="capturing"
                action="return this.handleKeyboardNavigation(event);"/>
 
       <handler event="keypress" keycode="VK_UP" modifiers="accel"
                phase="capturing"
                action="document.getBindingParent(this).selectEngine(event, false);"/>
 
       <handler event="keypress" keycode="VK_DOWN" modifiers="accel"
@@ -1008,91 +875,60 @@
       ]]>
       </handler>
 
     </handlers>
   </binding>
 
   <binding id="browser-search-autocomplete-result-popup" extends="chrome://browser/content/urlbarBindings.xml#browser-autocomplete-result-popup">
     <resources>
+      <stylesheet src="chrome://browser/content/search/searchbarBindings.css"/>
       <stylesheet src="chrome://browser/skin/searchbar.css"/>
     </resources>
-    <content ignorekeys="true" level="top" consumeoutsideclicks="never" context="_child">
+    <content ignorekeys="true" level="top" consumeoutsideclicks="never">
       <xul:hbox anonid="searchbar-engine" xbl:inherits="showonlysettings"
                 class="search-panel-header search-panel-current-engine">
         <xul:image class="searchbar-engine-image" xbl:inherits="src"/>
         <xul:label anonid="searchbar-engine-name" flex="1" crop="end"
                    role="presentation"/>
       </xul:hbox>
       <xul:tree anonid="tree" flex="1"
                 class="autocomplete-tree plain search-panel-tree"
                 hidecolumnpicker="true" seltype="single">
         <xul:treecols anonid="treecols">
           <xul:treecol id="treecolAutoCompleteValue" class="autocomplete-treecol" flex="1" overflow="true"/>
         </xul:treecols>
         <xul:treechildren class="autocomplete-treebody"/>
       </xul:tree>
-      <xul:deck anonid="search-panel-one-offs-header"
-                selectedIndex="0"
-                class="search-panel-header search-panel-current-input">
-        <xul:label anonid="searchbar-oneoffheader-search" value="&searchWithHeader.label;"/>
-        <xul:hbox anonid="search-panel-searchforwith"
-                  class="search-panel-current-input">
-          <xul:label anonid="searchbar-oneoffheader-before" value="&searchFor.label;"/>
-          <xul:label anonid="searchbar-oneoffheader-searchtext" flex="1" crop="end" class="search-panel-input-value"/>
-          <xul:label anonid="searchbar-oneoffheader-after" flex="10000" value="&searchWith.label;"/>
-        </xul:hbox>
-        <xul:hbox anonid="search-panel-searchonengine"
-                  class="search-panel-current-input">
-          <xul:label anonid="searchbar-oneoffheader-beforeengine" value="&search.label;"/>
-          <xul:label anonid="searchbar-oneoffheader-engine" flex="1" crop="end"
-                     class="search-panel-input-value"/>
-          <xul:label anonid="searchbar-oneoffheader-afterengine" flex="10000"
-                     value="&searchAfter.label;"/>
-        </xul:hbox>
-      </xul:deck>
-      <xul:description anonid="search-panel-one-offs"
-                       role="group"
-                       class="search-panel-one-offs"/>
-      <xul:vbox anonid="add-engines"/>
-      <xul:button anonid="search-settings"
-                  oncommand="showSettings();"
-                  class="search-setting-button search-panel-header"
-                  label="&changeSearchSettings.button;"/>
-      <xul:menupopup anonid="search-one-offs-context-menu">
-        <xul:menuitem anonid="search-one-offs-context-open-in-new-tab"
-                      label="&searchInNewTab.label;"
-                      accesskey="&searchInNewTab.accesskey;"/>
-        <xul:menuitem anonid="search-one-offs-context-set-default"
-                      label="&searchSetAsDefault.label;"
-                      accesskey="&searchSetAsDefault.accesskey;"/>
-      </xul:menupopup>
+      <xul:vbox anonid="search-one-off-buttons" class="search-one-offs"/>
     </content>
     <implementation>
       <!-- Popup rollup is triggered by native events before the mousedown event
            reaches the DOM. The will be set to true by the popuphiding event and
            false after the mousedown event has been triggered to detect what
            caused rollup. -->
       <field name="_isHiding">false</field>
-      <!-- When a context menu is opened on a one-off button, this is set to the
-           engine of that button for use with the context menu actions. -->
-      <field name="_contextEngine">null</field>
       <field name="_bundle">null</field>
       <property name="bundle" readonly="true">
         <getter>
           <![CDATA[
             if (!this._bundle) {
               const kBundleURI = "chrome://browser/locale/search.properties";
               this._bundle = Services.strings.createBundle(kBundleURI);
             }
             return this._bundle;
           ]]>
         </getter>
       </property>
 
+      <field name="oneOffButtons" readonly="true">
+        document.getAnonymousElementByAttribute(this, "anonid",
+                                                "search-one-off-buttons");
+      </field>
+
       <method name="updateHeader">
         <body><![CDATA[
           let currentEngine = Services.search.currentEngine;
           let uri = currentEngine.iconURI;
           if (uri) {
             this.setAttribute("src", uri.spec);
           }
           else {
@@ -1105,66 +941,52 @@
                                                             [currentEngine.name], 1);
           document.getAnonymousElementByAttribute(this, "anonid", "searchbar-engine-name")
                   .setAttribute("value", headerText);
           document.getAnonymousElementByAttribute(this, "anonid", "searchbar-engine")
                   .engine = currentEngine;
         ]]></body>
       </method>
 
-      <method name="showSettings">
+      <!-- This is called when a one-off is clicked and when "search in new tab"
+           is selected from a one-off context menu. -->
+      <method name="handleOneOffSearch">
+        <parameter name="event"/>
+        <parameter name="engine"/>
+        <parameter name="where"/>
+        <parameter name="params"/>
         <body><![CDATA[
-          BrowserUITelemetry.countSearchSettingsEvent("searchbar");
-          openPreferences("paneSearch");
-          // If the preference tab was already selected, the panel doesn't
-          // close itself automatically.
-          BrowserSearch.searchBar._textbox.closePopup();
+          let searchbar = document.getElementById("searchbar");
+          searchbar.handleSearchCommandWhere(event, engine, where, params);
         ]]></body>
       </method>
+    </implementation>
 
-      <constructor><![CDATA[
-        // Prevent popup events from the context menu from reaching the autocomplete
-        // binding (or other listeners).
-        let menu = document.getAnonymousElementByAttribute(this, "anonid", "search-one-offs-context-menu");
-        let listener = aEvent => aEvent.stopPropagation();
-        menu.addEventListener("popupshowing", listener);
-        menu.addEventListener("popuphiding", listener);
-        menu.addEventListener("popupshown", aEvent => {
-          this._ignoreMouseEvents = true;
-          aEvent.stopPropagation();
-        });
-        menu.addEventListener("popuphidden", aEvent => {
-          this._ignoreMouseEvents = false;
-          aEvent.stopPropagation();
-        });
-      ]]></constructor>
-    </implementation>
     <handlers>
-      <handler event="popuphidden"><![CDATA[
-        Services.tm.mainThread.dispatch(function() {
-          document.getElementById("searchbar").textbox.selectedButton = null;
-        }, Ci.nsIThread.DISPATCH_NORMAL);
-        this._contextEngine = null;
-      ]]></handler>
+      <handler event="popupshowing"><![CDATA[
+        if (!this.oneOffButtons.popup) {
+          // The panel width only spans to the textbox size, but we also want it
+          // to include the magnifier icon's width.
+          let ltr = getComputedStyle(this).direction == "ltr";
+          let magnifierWidth = parseInt(getComputedStyle(this)[
+                                 ltr ? "marginLeft" : "marginRight"
+                               ]) * -1;
+          // Ensure the panel is wide enough to fit at least 3 engines.
+          let minWidth = Math.max(
+            parseInt(this.width) + magnifierWidth,
+            this.oneOffButtons.buttonWidth * 3
+          );
+          this.style.minWidth = minWidth + "px";
 
-      <handler event="contextmenu"><![CDATA[
-        let target = event.originalTarget;
-        // Prevent the context menu from appearing except on the one off buttons.
-        if (!target.classList.contains("searchbar-engine-one-off-item") ||
-            target.classList.contains("dummy")) {
-          event.preventDefault();
-          return;
+          // Set popup after setting the minWidth since it builds the buttons.
+          this.oneOffButtons.popup = this;
+          this.oneOffButtons.textbox = this.input;
+          this.oneOffButtons.telemetryOrigin = "searchbar";
         }
-        document.getAnonymousElementByAttribute(this, "anonid", "search-one-offs-context-set-default")
-                .setAttribute("disabled", target.engine == Services.search.currentEngine);
 
-        this._contextEngine = target.engine;
-      ]]></handler>
-
-      <handler event="popupshowing"><![CDATA[
         // First handle deciding if we are showing the reduced version of the
         // popup containing only the preferences button. We do this if the
         // glass icon has been clicked if the text field is empty.
         let searchbar = document.getElementById("searchbar");
         let tree = document.getAnonymousElementByAttribute(this, "anonid",
                                                            "tree")
         if (searchbar.hasAttribute("showonlysettings")) {
           searchbar.removeAttribute("showonlysettings");
@@ -1180,190 +1002,907 @@
           // The autocomplete binding itself will take care of uncollapsing later,
           // if we currently have no rows but end up having some in the future
           // when the search string changes
           tree.collapsed = !tree.view || !tree.view.rowCount;
         }
 
         // Show the current default engine in the top header of the panel.
         this.updateHeader();
+      ]]></handler>
 
-        // Update the 'Search for <keywords> with:" header.
-        let headerSearchText =
-          document.getAnonymousElementByAttribute(this, "anonid",
-                                                  "searchbar-oneoffheader-searchtext");
-        let headerPanel =
-          document.getAnonymousElementByAttribute(this, "anonid",
-                                                  "search-panel-one-offs-header");
-        let list = document.getAnonymousElementByAttribute(this, "anonid",
-                                                           "search-panel-one-offs");
-        let textbox = searchbar.textbox;
-        let self = this;
-        let inputHandler = function() {
-          headerSearchText.setAttribute("value", textbox.value);
+      <handler event="popuphiding"><![CDATA[
+        this._isHiding = true;
+        setTimeout(() => {
+          this._isHiding = false;
+        }, 0);
+      ]]></handler>
+    </handlers>
+
+  </binding>
+
+  <!-- Used for additional open search providers in the search panel. -->
+  <binding id="addengine-icon" extends="xul:box">
+    <content>
+      <xul:image class="addengine-icon" xbl:inherits="src"/>
+      <xul:image class="addengine-badge"/>
+    </content>
+  </binding>
+
+  <binding id="search-one-offs">
+    <content context="_child">
+      <xul:deck anonid="search-panel-one-offs-header"
+                selectedIndex="0"
+                class="search-panel-header search-panel-current-input">
+        <xul:label anonid="searchbar-oneoffheader-search"
+                   value="&searchWithHeader.label;"/>
+        <xul:hbox anonid="search-panel-searchforwith"
+                  class="search-panel-current-input">
+          <xul:label anonid="searchbar-oneoffheader-before"
+                     value="&searchFor.label;"/>
+          <xul:label anonid="searchbar-oneoffheader-searchtext"
+                     class="search-panel-input-value"
+                     flex="1"
+                     crop="end"/>
+          <xul:label anonid="searchbar-oneoffheader-after"
+                     flex="10000"
+                     value="&searchWith.label;"/>
+        </xul:hbox>
+        <xul:hbox anonid="search-panel-searchonengine"
+                  class="search-panel-current-input">
+          <xul:label anonid="searchbar-oneoffheader-beforeengine"
+                     value="&search.label;"/>
+          <xul:label anonid="searchbar-oneoffheader-engine"
+                     class="search-panel-input-value"
+                     flex="1"
+                     crop="end"/>
+          <xul:label anonid="searchbar-oneoffheader-afterengine"
+                     flex="10000"
+                     value="&searchAfter.label;"/>
+        </xul:hbox>
+      </xul:deck>
+      <xul:description anonid="search-panel-one-offs"
+                       role="group"
+                       class="search-panel-one-offs"
+                       xbl:inherits="compact">
+        <xul:button anonid="search-settings-compact"
+                    oncommand="showSettings();"
+                    class="searchbar-engine-one-off-item search-setting-button-compact"
+                    xbl:inherits="compact"/>
+      </xul:description>
+      <xul:vbox anonid="add-engines"/>
+      <xul:button anonid="search-settings"
+                  oncommand="showSettings();"
+                  class="search-setting-button search-panel-header"
+                  label="&changeSearchSettings.button;"
+                  xbl:inherits="compact"/>
+      <xul:menupopup anonid="search-one-offs-context-menu">
+        <xul:menuitem anonid="search-one-offs-context-open-in-new-tab"
+                      label="&searchInNewTab.label;"
+                      accesskey="&searchInNewTab.accesskey;"/>
+        <xul:menuitem anonid="search-one-offs-context-set-default"
+                      label="&searchSetAsDefault.label;"
+                      accesskey="&searchSetAsDefault.accesskey;"/>
+      </xul:menupopup>
+    </content>
+
+    <implementation implements="nsIDOMEventListener">
+
+      <!-- Width in pixels of the one-off buttons.  49px is the min-width of
+           each search engine button, adapt this const when changing the css.
+           It's actually 48px + 1px of right border. -->
+      <property name="buttonWidth" readonly="true" onget="return 49;"/>
+
+      <field name="_popup">null</field>
+
+      <!-- The popup that contains the one-offs.  This is required, so it should
+           never be null or undefined, except possibly before the one-offs are
+           used. -->
+      <property name="popup">
+        <getter><![CDATA[
+          return this._popup;
+        ]]></getter>
+        <setter><![CDATA[
+          if (this._popup == val) {
+            return val;
+          }
+
+          let events = [
+            "popupshowing",
+            "popuphidden",
+          ];
+          if (this._popup) {
+            for (let event of events) {
+              this._popup.removeEventListener(event, this);
+            }
+          }
+          if (val) {
+            for (let event of events) {
+              val.addEventListener(event, this);
+            }
+          }
+          this._popup = val;
+
+          // If the popup is already open, rebuild the one-offs now.  The
+          // popup may be opening, so check that the state is not closed
+          // instead of checking popupOpen.
+          if (val && val.state != "closed") {
+            this._rebuild();
+          }
+          return val;
+        ]]></setter>
+      </property>
+
+      <field name="_textbox">null</field>
+
+      <!-- The textbox associated with the one-offs.  Set this to a textbox to
+           automatically keep the related one-offs UI up to date.  Otherwise you
+           can leave it null/undefined, and in that case you should update the
+           query property manually. -->
+      <property name="textbox">
+        <getter><![CDATA[
+          return this._textbox;
+        ]]></getter>
+        <setter><![CDATA[
+          if (this._textbox == val) {
+            return val;
+          }
+          if (this._textbox) {
+            this._textbox.removeEventListener("input", this);
+          }
+          if (val) {
+            val.addEventListener("input", this);
+          }
+          return this._textbox = val;
+        ]]></setter>
+      </property>
+
+      <!-- Set this to a string that identifies your one-offs consumer.  It'll
+           be appended to telemetry recorded with maybeRecordTelemetry(). -->
+      <field name="telemetryOrigin">""</field>
+
+      <field name="_query">""</field>
+
+      <!-- The query string currently shown in the one-offs.  If the textbox
+           property is non-null, then this is automatically updated on
+           input. -->
+      <property name="query">
+        <getter><![CDATA[
+          return this._query;
+        ]]></getter>
+        <setter><![CDATA[
+          this._query = val;
+          if (this.popup && this.popup.popupOpen) {
+            this._updateAfterQueryChanged();
+          }
+          return val;
+        ]]></setter>
+      </property>
+
+      <field name="_selectedButton">null</field>
+
+      <!-- The selected one-off, a xul:button, including the add-engine button
+           and the search-settings button.  Null if no one-off is selected. -->
+      <property name="selectedButton">
+        <getter><![CDATA[
+          return this._selectedButton;
+        ]]></getter>
+        <setter><![CDATA[
+          this._changeVisuallySelectedButton(val, true);
+          return val;
+        ]]></setter>
+      </property>
+
+      <!-- The index of the selected one-off, including the add-engine button
+           and the search-settings button.  -1 if no one-off is selected. -->
+      <property name="selectedButtonIndex">
+        <getter><![CDATA[
+          let buttons = this.getSelectableButtons(true);
+          for (let i = 0; i < buttons.length; i++) {
+            if (buttons[i] == this._selectedButton) {
+              return i;
+            }
+          }
+          return -1;
+        ]]></getter>
+        <setter><![CDATA[
+          let buttons = this.getSelectableButtons(true);
+          this.selectedButton = buttons[val];
+          return val;
+        ]]></setter>
+      </property>
+
+      <!-- The visually selected one-off is the same as the selected one-off
+           unless a one-off is moused over.  In that case, the visually selected
+           one-off is the moused-over one-off, which may be different from the
+           selected one-off.  The visually selected one-off is always the one
+           that is visually highlighted.  Includes the add-engine button and the
+           search-settings button.  A xul:button. -->
+      <property name="visuallySelectedButton" readonly="true">
+        <getter><![CDATA[
+          return this.getSelectableButtons(true).find(button => {
+            return button.getAttribute("selected") == "true";
+          });
+        ]]></getter>
+      </property>
+
+      <property name="compact" readonly="true">
+        <getter><![CDATA[
+          return this.getAttribute("compact") == "true";
+        ]]></getter>
+      </property>
+
+      <property name="settingsButton" readonly="true">
+        <getter><![CDATA[
+          let id = this.compact ? "search-settings-compact" : "search-settings";
+          return document.getAnonymousElementByAttribute(this, "anonid", id);
+        ]]></getter>
+      </property>
+
+      <field name="_bundle">null</field>
+
+      <property name="bundle" readonly="true">
+        <getter><![CDATA[
+          if (!this._bundle) {
+            const kBundleURI = "chrome://browser/locale/search.properties";
+            this._bundle = Services.strings.createBundle(kBundleURI);
+          }
+          return this._bundle;
+        ]]></getter>
+      </property>
+
+      <!-- When a context menu is opened on a one-off button, this is set to the
+           engine of that button for use with the context menu actions. -->
+      <field name="_contextEngine">null</field>
+
+      <constructor><![CDATA[
+        // Prevent popup events from the context menu from reaching the autocomplete
+        // binding (or other listeners).
+        let menu = document.getAnonymousElementByAttribute(this, "anonid", "search-one-offs-context-menu");
+        let listener = aEvent => aEvent.stopPropagation();
+        menu.addEventListener("popupshowing", listener);
+        menu.addEventListener("popuphiding", listener);
+        menu.addEventListener("popupshown", aEvent => {
+          this._ignoreMouseEvents = true;
+          aEvent.stopPropagation();
+        });
+        menu.addEventListener("popuphidden", aEvent => {
+          this._ignoreMouseEvents = false;
+          aEvent.stopPropagation();
+        });
+      ]]></constructor>
+
+      <!-- This handles events outside the one-off buttons, like on the popup
+           and textbox. -->
+      <method name="handleEvent">
+        <parameter name="event"/>
+        <body><![CDATA[
+          switch (event.type) {
+            case "input":
+              // The urlbar's value property can be a moz-action URI, but we
+              // want the value that the user sees, which is textValue.  So see
+              // if the textbox has a textValue property, and use it if so.
+              this.query = typeof(event.target.textValue) == "string" ?
+                           event.target.textValue :
+                           event.target.value;
+              break;
+            case "popupshowing":
+              this._rebuild();
+              break;
+            case "popuphidden":
+              Services.tm.mainThread.dispatch(() => {
+                this.selectedButton = null;
+              }, Ci.nsIThread.DISPATCH_NORMAL);
+              this._contextEngine = null;
+              break;
+          }
+        ]]></body>
+      </method>
+
+      <method name="showSettings">
+        <body><![CDATA[
+          BrowserUITelemetry.countSearchSettingsEvent(this.telemetryOrigin);
+          openPreferences("paneSearch");
+          // If the preference tab was already selected, the panel doesn't
+          // close itself automatically.
+          this.popup.hidePopup();
+        ]]></body>
+      </method>
+
+      <!-- Updates the parts of the UI that show the query string. -->
+      <method name="_updateAfterQueryChanged">
+        <body><![CDATA[
+          let headerSearchText =
+            document.getAnonymousElementByAttribute(this, "anonid",
+                                                    "searchbar-oneoffheader-searchtext");
+          let headerPanel =
+            document.getAnonymousElementByAttribute(this, "anonid",
+                                                    "search-panel-one-offs-header");
+          let list = document.getAnonymousElementByAttribute(this, "anonid",
+                                                             "search-panel-one-offs");
+          headerSearchText.setAttribute("value", this.query);
           let groupText;
           let isOneOffSelected =
             this.selectedButton &&
             this.selectedButton.classList.contains("searchbar-engine-one-off-item");
           // Typing de-selects the settings or opensearch buttons at the bottom
           // of the search panel, as typing shows the user intends to search.
           if (this.selectedButton && !isOneOffSelected)
             this.selectedButton = null;
-          if (textbox.value) {
-            self.removeAttribute("showonlysettings");
+          if (this.query) {
             groupText = headerSearchText.previousSibling.value +
                         '"' + headerSearchText.value + '"' +
                         headerSearchText.nextSibling.value;
             if (!isOneOffSelected)
               headerPanel.selectedIndex = 1;
           }
           else {
             let noSearchHeader =
-              document.getAnonymousElementByAttribute(self, "anonid",
+              document.getAnonymousElementByAttribute(this, "anonid",
                                                       "searchbar-oneoffheader-search");
             groupText = noSearchHeader.value;
             if (!isOneOffSelected)
               headerPanel.selectedIndex = 0;
           }
           list.setAttribute("aria-label", groupText);
-        };
-        textbox.addEventListener("input", inputHandler);
-        this.addEventListener("popuphiding", function hiding() {
-          textbox.removeEventListener("input", inputHandler);
-          this.removeEventListener("popuphiding", hiding);
-        });
-        inputHandler();
+        ]]></body>
+      </method>
+
+      <!-- Builds all the UI. -->
+      <method name="_rebuild">
+        <body><![CDATA[
+          // Update the 'Search for <keywords> with:" header.
+          this._updateAfterQueryChanged();
+
+          let list = document.getAnonymousElementByAttribute(this, "anonid",
+                                                             "search-panel-one-offs");
+
+          // Handle opensearch items. This needs to be done before building the
+          // list of one off providers, as that code will return early if all the
+          // alternative engines are hidden.
+          let addEngineList =
+            document.getAnonymousElementByAttribute(this, "anonid", "add-engines");
+          while (addEngineList.firstChild)
+            addEngineList.firstChild.remove();
+
+          const kXULNS =
+            "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+          // Add a button for each engine that the page in the selected browser
+          // offers.  But not when the one-offs are compact.  Compact one-offs
+          // are shown in the urlbar, and the add-engine buttons span the width
+          // of the popup, so if we added all the engines that a site offers, it
+          // could effectively break the urlbar popup by offering a ton of
+          // engines.  We should probably make a smaller version of the buttons
+          // for compact one-offs.
+          if (!this.compact) {
+            for (let engine of gBrowser.selectedBrowser.engines || []) {
+              let button = document.createElementNS(kXULNS, "button");
+              let label = this.bundle.formatStringFromName("cmd_addFoundEngine",
+                                                           [engine.title], 1);
+              button.id = "searchbar-add-engine-" + engine.title.replace(/ /g, '-');
+              button.setAttribute("class", "addengine-item");
+              button.setAttribute("label", label);
+              button.setAttribute("pack", "start");
+
+              button.setAttribute("crop", "end");
+              button.setAttribute("tooltiptext", engine.uri);
+              button.setAttribute("uri", engine.uri);
+              if (engine.icon) {
+                button.setAttribute("image", engine.icon);
+              }
+              button.setAttribute("title", engine.title);
+              addEngineList.appendChild(button);
+            }
+          }
+
+          let settingsButton =
+            document.getAnonymousElementByAttribute(this, "anonid",
+                                                    "search-settings-compact");
+          // Finally, build the list of one-off buttons.
+          while (list.firstChild != settingsButton)
+            list.firstChild.remove();
+          // Remove the trailing empty text node introduced by the binding's
+          // content markup above.
+          if (settingsButton.nextSibling)
+            settingsButton.nextSibling.remove();
 
-        // Handle opensearch items. This needs to be done before building the
-        // list of one off providers, as that code will return early if all the
-        // alternative engines are hidden.
-        let addEngineList =
-          document.getAnonymousElementByAttribute(this, "anonid", "add-engines");
-        while (addEngineList.firstChild)
-          addEngineList.firstChild.remove();
+          let Preferences =
+            Cu.import("resource://gre/modules/Preferences.jsm", {}).Preferences;
+          let pref = Preferences.get("browser.search.hiddenOneOffs");
+          let hiddenList = pref ? pref.split(",") : [];
+
+          let currentEngineName = Services.search.currentEngine.name;
+          let engines = Services.search.getVisibleEngines()
+                                .filter(e => e.name != currentEngineName &&
+                                             hiddenList.indexOf(e.name) == -1);
 
-        const kXULNS =
-          "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+          let header = document.getAnonymousElementByAttribute(this, "anonid",
+                                                               "search-panel-one-offs-header")
+          // header is a xul:deck so collapsed doesn't work on it, see bug 589569.
+          header.hidden = list.collapsed = !engines.length;
+
+          if (!engines.length)
+            return;
+
+          let panelWidth = parseInt(this.popup.clientWidth);
+          // The + 1 is because the last button doesn't have a right border.
+          let enginesPerRow = Math.floor((panelWidth + 1) / this.buttonWidth);
+          let buttonWidth = Math.floor(panelWidth / enginesPerRow);
+          // There will be an emtpy area of:
+          //   panelWidth - enginesPerRow * buttonWidth  px
+          // at the end of each row.
 
-        let addEngines = gBrowser.selectedBrowser.engines;
-        if (addEngines && addEngines.length > 0) {
-          for (let engine of addEngines) {
+          // If the <description> tag with the list of search engines doesn't have
+          // a fixed height, the panel will be sized incorrectly, causing the bottom
+          // of the suggestion <tree> to be hidden.
+          let oneOffCount = engines.length;
+          if (this.compact)
+            ++oneOffCount;
+          let rowCount = Math.ceil(oneOffCount / enginesPerRow);
+          let height = rowCount * 33; // 32px per row, 1px border.
+          list.setAttribute("height", height + "px");
+
+          // Ensure we can refer to the settings button by ID:
+          let settingsEl = document.getAnonymousElementByAttribute(this, "anonid", "search-settings");
+          settingsEl.id = this.id + "-anon-search-settings";
+
+          let dummyItems = enginesPerRow - (oneOffCount % enginesPerRow || enginesPerRow);
+          for (let i = 0; i < engines.length; ++i) {
+            let engine = engines[i];
             let button = document.createElementNS(kXULNS, "button");
-            let label = this.bundle.formatStringFromName("cmd_addFoundEngine",
-                                                         [engine.title], 1);
-            button.id = "searchbar-add-engine-" + engine.title.replace(/ /g, '-');
-            button.setAttribute("class", "addengine-item");
-            button.setAttribute("label", label);
-            button.setAttribute("pack", "start");
+            button.id = this._buttonIDForEngine(engine);
+            let uri = "chrome://browser/skin/search-engine-placeholder.png";
+            if (engine.iconURI) {
+              uri = engine.iconURI.spec;
+            }
+            button.setAttribute("image", uri);
+            button.setAttribute("class", "searchbar-engine-one-off-item");
+            button.setAttribute("tooltiptext", engine.name);
+            button.setAttribute("width", buttonWidth);
+            button.engine = engine;
+
+            if ((i + 1) % enginesPerRow == 0)
+              button.classList.add("last-of-row");
+
+            if (i + 1 == engines.length)
+              button.classList.add("last-engine");
+
+            if (i >= oneOffCount + dummyItems - enginesPerRow)
+              button.classList.add("last-row");
+
+            list.insertBefore(button, settingsButton);
+          }
 
-            button.setAttribute("crop", "end");
-            button.setAttribute("tooltiptext", engine.uri);
-            button.setAttribute("uri", engine.uri);
-            if (engine.icon) {
-              button.setAttribute("image", engine.icon);
+          let hasDummyItems = !!dummyItems;
+          while (dummyItems) {
+            let button = document.createElementNS(kXULNS, "button");
+            button.setAttribute("class", "searchbar-engine-one-off-item dummy last-row");
+            button.setAttribute("width", buttonWidth);
+
+            if (!--dummyItems)
+              button.classList.add("last-of-row");
+
+            list.insertBefore(button, settingsButton);
+          }
+
+          if (this.compact) {
+            this.settingsButton.setAttribute("width", buttonWidth);
+            if (rowCount == 1 && hasDummyItems) {
+              // When there's only one row, make the compact settings button
+              // hug the right edge of the panel.  It may not due to the panel's
+              // width not being an integral factor of the button width.  (See
+              // the "There will be an emtpy area" comment above.)  Increase the
+              // width of the last dummy item by the remainder.
+              let remainder = panelWidth - (enginesPerRow * buttonWidth);
+              let width = remainder + buttonWidth;
+              let lastDummyItem = this.settingsButton.previousSibling;
+              lastDummyItem.setAttribute("width", width);
             }
-            button.setAttribute("title", engine.title);
-            addEngineList.appendChild(button);
           }
-        }
+        ]]></body>
+      </method>
+
+      <method name="_buttonIDForEngine">
+        <parameter name="engine"/>
+        <body><![CDATA[
+          return "searchbar-engine-one-off-item-" +
+                 engine.name.replace(/ /g, '-');
+        ]]></body>
+      </method>
 
-        // Finally, build the list of one-off buttons.
-        while (list.firstChild)
-          list.firstChild.remove();
+      <method name="_buttonForEngine">
+        <parameter name="engine"/>
+        <body><![CDATA[
+          return document.getElementById(this._buttonIDForEngine(engine));
+        ]]></body>
+      </method>
+
+      <method name="_changeVisuallySelectedButton">
+        <parameter name="val"/>
+        <parameter name="aUpdateLogicallySelectedButton"/>
+        <body><![CDATA[
+          let visuallySelectedButton = this.visuallySelectedButton;
+          if (visuallySelectedButton)
+            visuallySelectedButton.removeAttribute("selected");
 
-        let Preferences =
-          Cu.import("resource://gre/modules/Preferences.jsm", {}).Preferences;
-        let pref = Preferences.get("browser.search.hiddenOneOffs");
-        let hiddenList = pref ? pref.split(",") : [];
+          let header =
+            document.getAnonymousElementByAttribute(this, "anonid",
+                                                    "search-panel-one-offs-header");
+          // Avoid selecting dummy buttons.
+          if (val && !val.classList.contains("dummy")) {
+            val.setAttribute("selected", "true");
+            if (val.classList.contains("searchbar-engine-one-off-item") &&
+                val.engine) {
+              let headerEngineText =
+                document.getAnonymousElementByAttribute(this, "anonid",
+                                                        "searchbar-oneoffheader-engine");
+              header.selectedIndex = 2;
+              headerEngineText.value = val.engine.name;
+            }
+            else {
+              header.selectedIndex = this.query ? 1 : 0;
+            }
+            this.setAttribute("aria-activedescendant", val.id);
+          } else {
+            val = null;
+            header.selectedIndex = this.query ? 1 : 0;
+            this.removeAttribute("aria-activedescendant");
+          }
+
+          if (aUpdateLogicallySelectedButton) {
+            this._selectedButton = val;
+            if (val && !val.engine) {
+              // If the button doesn't have an engine, then clear the popup's
+              // selection to indicate that pressing Return while the button is
+              // selected will do the button's command, not search.
+              this.popup.selectedIndex = -1;
+            }
+          }
+
+          let event = document.createEvent("Events");
+          event.initEvent("OneOffsVisuallySelectedButtonChanged", true, false);
+          this.dispatchEvent(event);
+        ]]></body>
+      </method>
 
-        let currentEngineName = Services.search.currentEngine.name;
-        let engines = Services.search.getVisibleEngines()
-                              .filter(e => e.name != currentEngineName &&
-                                           hiddenList.indexOf(e.name) == -1);
+      <method name="getSelectableButtons">
+        <parameter name="aIncludeNonEngineButtons"/>
+        <body><![CDATA[
+          let buttons = [];
+          let oneOff = document.getAnonymousElementByAttribute(this, "anonid",
+                                                               "search-panel-one-offs");
+          for (oneOff = oneOff.firstChild; oneOff; oneOff = oneOff.nextSibling) {
+            // oneOff may be a text node since the list xul:description contains
+            // whitespace and the compact settings button.  See the markup
+            // above.  _rebuild removes text nodes, but it may not have been
+            // called yet (because e.g. the popup hasn't been opened yet).
+            if (oneOff.nodeType == Node.ELEMENT_NODE) {
+              if (oneOff.classList.contains("dummy") ||
+                  oneOff.classList.contains("search-setting-button-compact"))
+                break;
+              buttons.push(oneOff);
+            }
+          }
 
-        let header = document.getAnonymousElementByAttribute(this, "anonid",
-                                                             "search-panel-one-offs-header")
-        // header is a xul:deck so collapsed doesn't work on it, see bug 589569.
-        header.hidden = list.collapsed = !engines.length;
+          if (!aIncludeNonEngineButtons)
+            return buttons;
+
+          let addEngine =
+            document.getAnonymousElementByAttribute(this, "anonid", "add-engines");
+          for (addEngine = addEngine.firstChild; addEngine; addEngine = addEngine.nextSibling)
+            buttons.push(addEngine);
+
+          buttons.push(this.settingsButton);
+          return buttons;
+        ]]></body>
+      </method>
+
+      <method name="handleSearchCommand">
+        <parameter name="aEvent"/>
+        <parameter name="aEngine"/>
+        <parameter name="aForceNewTab"/>
+        <body><![CDATA[
+          let where = "current";
+          let params;
 
-        // 49px is the min-width of each search engine button,
-        // adapt this const when changing the css.
-        // It's actually 48px + 1px of right border.
-        const ENGINE_WIDTH = 49;
-        let panel = document.getElementById("PopupSearchAutoComplete");
-        // The panel width only spans to the textbox size, but we also want it
-        // to include the magnifier icon's width.
-        let ltr = getComputedStyle(this).direction == "ltr";
-        let magnifierWidth = parseInt(getComputedStyle(panel)[
-                               ltr ? "marginLeft" : "marginRight"
-                             ]) * -1;
-        let minWidth = parseInt(panel.width) + magnifierWidth;
-        if (engines.length) {
-          // Ensure the panel is wide enough to fit at least 3 engines.
-          minWidth = Math.max(minWidth, ENGINE_WIDTH * 3);
-        }
-        panel.style.minWidth = minWidth + "px";
+          // Open ctrl/cmd clicks on one-off buttons in a new background tab.
+          if (aForceNewTab) {
+            where = "tab";
+            if (Services.prefs.getBoolPref("browser.tabs.loadInBackground")) {
+              params = {
+                inBackground: true,
+              };
+            }
+          }
+          else {
+            var newTabPref = Services.prefs.getBoolPref("browser.search.openintab");
+            if (((aEvent instanceof KeyboardEvent) && aEvent.altKey) ^ newTabPref)
+              where = "tab";
+            if ((aEvent instanceof MouseEvent) &&
+                (aEvent.button == 1 || aEvent.getModifierState("Accel"))) {
+              where = "tab";
+              params = {
+                inBackground: true,
+              };
+            }
+          }
+
+          this.popup.handleOneOffSearch(aEvent, aEngine, where, params);
+        ]]></body>
+      </method>
+
+      <!--
+        Increments or decrements the index of the currently selected one-off.
+
+        @param aForward
+               If true, the index is incremented, and if false, the index is
+               decremented.
+        @param aWrapAround
+               This has a couple of effects, depending on whether there is
+               currently a selection.
+               (1) If true and the last one-off is currently selected,
+               incrementing the index will cause the selection to be cleared and
+               this method to return true.  Calling advanceSelection again after
+               that (again with aForward=true) will select the first one-off.
+               Likewise if decrementing the index when the first one-off is
+               selected, except in the opposite direction of course.
+               (2) If true and there currently is no selection, decrementing the
+               index will cause the last one-off to become selected and this
+               method to return true.  Only the aForward=false case is affected
+               because it is always the case that if aForward=true and there
+               currently is no selection, the first one-off becomes selected and
+               this method returns true.
+        @param aCycleEngines
+               If true, only engine buttons are included.
+        @return True if the selection can continue to advance after this method
+                returns and false if not.
+      -->
+      <method name="advanceSelection">
+        <parameter name="aForward"/>
+        <parameter name="aWrapAround"/>
+        <parameter name="aCycleEngines"/>
+        <body><![CDATA[
+          let popup = this.popup;
+          let list = document.getAnonymousElementByAttribute(popup, "anonid",
+                                                             "search-panel-one-offs");
+          let selectedButton = this.selectedButton;
+          let buttons = this.getSelectableButtons(aCycleEngines);
+
+          if (selectedButton) {
+            // cycle through one-off buttons.
+            let index = buttons.indexOf(selectedButton);
+            if (aForward)
+              ++index;
+            else
+              --index;
 
-        if (!engines.length)
-          return;
+            if (index >= 0 && index < buttons.length)
+              this.selectedButton = buttons[index];
+            else
+              this.selectedButton = null;
+
+            if (this.selectedButton || aWrapAround)
+              return true;
+
+            return false;
+          }
+
+          // If no selection, select the first button or ...
+          if (aForward) {
+            this.selectedButton = buttons[0];
+            return true;
+          }
+
+          if (!aForward && aWrapAround) {
+            // the last button.
+            this.selectedButton = buttons[buttons.length - 1];
+            return true;
+          }
+
+          return false;
+        ]]></body>
+      </method>
+
+      <!--
+        This handles key presses specific to the one-off buttons like Tab and
+        Alt-Up/Down, and Up/Down keys within the buttons.  Since one-off buttons
+        are always used in conjunction with a list of some sort (in this.popup),
+        it also handles Up/Down keys that cross the boundaries between list
+        items and the one-off buttons.
 
-        let panelWidth = parseInt(panel.clientWidth);
-        // The + 1 is because the last button doesn't have a right border.
-        let enginesPerRow = Math.floor((panelWidth + 1) / ENGINE_WIDTH);
-        let buttonWidth = Math.floor(panelWidth / enginesPerRow);
-        // There will be an emtpy area of:
-        //   panelWidth - enginesPerRow * buttonWidth  px
-        // at the end of each row.
+        @param event
+               The key event.
+        @param numListItems
+               The number of items in the list.  The reason that this is a
+               parameter at all is that the list may contain items at the end
+               that should be ignored, depending on the consumer.  That's true
+               for the urlbar for example.
+        @param allowEmptySelection
+               Pass true if it's OK that neither the list nor the one-off
+               buttons contains a selection.  Pass false if either the list or
+               the one-off buttons (or both) should always contain a selection.
+        @param textboxUserValue
+               When the last list item is selected and the user presses Down,
+               the first one-off becomes selected and the textbox value is
+               restored to the value that the user typed.  Pass that value here.
+               However, if you pass true for allowEmptySelection, you don't need
+               to pass anything for this parameter.  (Pass undefined or null.)
+        @return True if this method handled the keypress and false if not.  If
+                false, then you should let the autocomplete controller handle
+                the keypress.  The value of event.defaultPrevented will be the
+                same as this return value.
+      -->
+      <method name="handleKeyPress">
+        <parameter name="event"/>
+        <parameter name="numListItems"/>
+        <parameter name="allowEmptySelection"/>
+        <parameter name="textboxUserValue"/>
+        <body><![CDATA[
+          if (!this.popup) {
+            return;
+          }
 
-        // If the <description> tag with the list of search engines doesn't have
-        // a fixed height, the panel will be sized incorrectly, causing the bottom
-        // of the suggestion <tree> to be hidden.
-        let rowCount = Math.ceil(engines.length / enginesPerRow);
-        let height = rowCount * 33; // 32px per row, 1px border.
-        list.setAttribute("height", height + "px");
+          let stopEvent = false;
+
+          // Tab cycles through the one-offs and moves the focus out at the end.
+          if (event.keyCode == KeyEvent.DOM_VK_TAB) {
+            stopEvent = this.advanceSelection(!event.shiftKey, false, true);
+          }
 
-        // Ensure we can refer to the settings button by ID:
-        let settingsEl = document.getAnonymousElementByAttribute(this, "anonid", "search-settings");
-        settingsEl.id = this.id + "-anon-search-settings";
+          // Alt + up/down is very similar to (shift +) tab but differs in that
+          // it loops through the list, whereas tab will move the focus out.
+          else if (event.altKey &&
+                   (event.keyCode == KeyEvent.DOM_VK_DOWN ||
+                    event.keyCode == KeyEvent.DOM_VK_UP)) {
+            stopEvent =
+              this.advanceSelection(event.keyCode == KeyEvent.DOM_VK_DOWN,
+                                    true, false);
+          }
 
-        let dummyItems = enginesPerRow - (engines.length % enginesPerRow || enginesPerRow);
-        for (let i = 0; i < engines.length; ++i) {
-          let engine = engines[i];
-          let button = document.createElementNS(kXULNS, "button");
-          button.id = "searchbar-engine-one-off-item-" + engine.name.replace(/ /g, '-');
-          let uri = "chrome://browser/skin/search-engine-placeholder.png";
-          if (engine.iconURI) {
-            uri = engine.iconURI.spec;
+          else if (event.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_UP) {
+            if (numListItems > 0) {
+              if (this.popup.selectedIndex > 0) {
+                // The autocomplete controller should handle this case.
+              } else if (this.popup.selectedIndex == 0) {
+                if (!allowEmptySelection) {
+                  // Wrap around the selection to the last one-off.
+                  this.selectedButton = null;
+                  stopEvent = this.advanceSelection(false, true, true);
+                  if (stopEvent) {
+                    this.popup.selectedIndex = -1;
+                  }
+                }
+              } else {
+                let firstButtonSelected =
+                  this.selectedButton &&
+                  this.selectedButton == this.getSelectableButtons(true)[0];
+                if (firstButtonSelected) {
+                  this.selectedButton = null;
+                } else {
+                  stopEvent = this.advanceSelection(false, true, true);
+                }
+              }
+            } else {
+              stopEvent = this.advanceSelection(false, true, true);
+            }
           }
-          button.setAttribute("image", uri);
-          button.setAttribute("class", "searchbar-engine-one-off-item");
-          button.setAttribute("tooltiptext", engine.name);
-          button.setAttribute("width", buttonWidth);
-          button.engine = engine;
+
+          else if (event.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_DOWN) {
+            if (numListItems > 0) {
+              if (this.popup.selectedIndex >= 0 &&
+                  this.popup.selectedIndex < numListItems - 1) {
+                // The autocomplete controller should handle this case.
+              } else if (this.popup.selectedIndex == numListItems - 1) {
+                this.selectedButton = null;
+                stopEvent = this.advanceSelection(true, true, true);
+                if (stopEvent) {
+                  stopEvent = !allowEmptySelection;
+                  if (this.textbox && typeof(textboxUserValue) == "string") {
+                    this.textbox.value = textboxUserValue;
+                  }
+                  if (!allowEmptySelection) {
+                    this.popup.selectedIndex = -1;
+                  }
+                }
+              } else {
+                let buttons = this.getSelectableButtons(true);
+                let lastButtonSelected =
+                  this.selectedButton &&
+                  this.selectedButton == buttons[buttons.length - 1];
+                if (lastButtonSelected) {
+                  this.selectedButton = null;
+                  stopEvent = allowEmptySelection;
+                } else if (this.selectedButton) {
+                  stopEvent = this.advanceSelection(true, true, true);
+                } else {
+                  // The autocomplete controller should handle this case.
+                }
+              }
+            } else {
+              stopEvent = this.advanceSelection(true, true, true);
+            }
+          }
 
-          if ((i + 1) % enginesPerRow == 0)
-            button.classList.add("last-of-row");
+          if (stopEvent) {
+            event.preventDefault();
+            event.stopPropagation();
+            return true;
+          }
+          return false;
+        ]]></body>
+      </method>
+
+      <!--
+        If the given event is related to the one-offs, this method records
+        one-off telemetry for it.  this.telemetryOrigin will be appended to the
+        computed source, so make sure you set that first.
 
-          if (i >= engines.length + dummyItems - enginesPerRow)
-            button.classList.add("last-row");
+        @param aEvent
+               An event, like a click on a one-off button.
+        @param aOpenUILinkWhere
+               The "where" passed to openUILink.
+        @param aOpenUILinkParams
+               The "params" passed to openUILink.
+        @return True if telemetry was recorded and false if not.
+      -->
+      <method name="maybeRecordTelemetry">
+        <parameter name="aEvent"/>
+        <parameter name="aOpenUILinkWhere"/>
+        <parameter name="aOpenUILinkParams"/>
+        <body><![CDATA[
+          if (!aEvent) {
+            return false;
+          }
 
-          list.appendChild(button);
-        }
+          let source = null;
+          let type = "unknown";
+          let engine = null;
+          let target = aEvent.originalTarget;
 
-        while (dummyItems) {
-          let button = document.createElementNS(kXULNS, "button");
-          button.setAttribute("class", "searchbar-engine-one-off-item dummy last-row");
-          button.setAttribute("width", buttonWidth);
+          if (aEvent instanceof KeyboardEvent) {
+            type = "key";
+            if (this.selectedButton) {
+              source = "oneoff";
+              engine = this.selectedButton.engine;
+            }
+          } else if (aEvent instanceof MouseEvent) {
+            type = "mouse";
+            if (target.classList.contains("searchbar-engine-one-off-item")) {
+              source = "oneoff";
+              engine = target.engine;
+            }
+          } else if ((aEvent instanceof XULCommandEvent) &&
+                     target.getAttribute("anonid") ==
+                       "search-one-offs-context-open-in-new-tab") {
+            source = "oneoff-context";
+            engine = this._contextEngine;
+          }
 
-          if (!--dummyItems)
-            button.classList.add("last-of-row");
+          if (!source) {
+            return false;
+          }
+
+          if (this.telemetryOrigin) {
+            source += "-" + this.telemetryOrigin;
+          }
 
-          list.appendChild(button);
-        }
-      ]]></handler>
+          let tabBackground = aOpenUILinkWhere == "tab" &&
+                              aOpenUILinkParams &&
+                              aOpenUILinkParams.inBackground;
+          let where = tabBackground ? "tab-background" : aOpenUILinkWhere;
+          BrowserSearch.recordOneoffSearchInTelemetry(engine, source, type,
+                                                      where);
+          return true;
+        ]]></body>
+      </method>
+
+    </implementation>
+
+    <handlers>
 
       <handler event="mousedown"><![CDATA[
         // Required to receive click events from the buttons on Linux.
         event.preventDefault();
       ]]></handler>
 
       <handler event="mouseover"><![CDATA[
         let target = event.originalTarget;
@@ -1373,40 +1912,38 @@
         // Ignore mouse events when the context menu is open.
          if (this._ignoreMouseEvents)
            return;
 
         if ((target.classList.contains("searchbar-engine-one-off-item") &&
              !target.classList.contains("dummy")) ||
             target.classList.contains("addengine-item") ||
             target.classList.contains("search-setting-button")) {
-          let textbox = document.getElementById("searchbar").textbox;
-          textbox._changeVisuallySelectedButton(target);
+          this._changeVisuallySelectedButton(target);
         }
       ]]></handler>
 
       <handler event="mouseout"><![CDATA[
         let target = event.originalTarget;
         if (target.localName != "button") {
           return;
         }
 
         // Don't deselect the current button if the context menu is open.
         if (this._ignoreMouseEvents)
           return;
 
-        let textbox = document.getElementById("searchbar").textbox;
         // Unfortunately this will fire before mouseover hits another item.
         // If this button is selected, we replace that selection only if
         // we're not moving to a different one-off item:
         if (target.getAttribute("selected") == "true" &&
             (!event.relatedTarget ||
              !event.relatedTarget.classList.contains("searchbar-engine-one-off-item") ||
              event.relatedTarget.classList.contains("dummy"))) {
-          textbox._changeVisuallySelectedButton(textbox.selectedButton);
+          this._changeVisuallySelectedButton(this.selectedButton);
         }
       ]]></handler>
 
       <handler event="click"><![CDATA[
         if (event.button == 2)
           return; // ignore right clicks.
 
         let button = event.originalTarget;
@@ -1416,30 +1953,32 @@
           return;
 
         // For some reason, if the context menu had been opened prior to the
         // click, the suggestions popup won't be closed after loading the search
         // in the current tab - so we hide it manually. Some focusing magic
         // that happens when a search is loaded ensures that the popup is opened
         // again if it needs to be, so we don't need to worry about which cases
         // require manual hiding.
-        this.hidePopup();
+        this.popup.hidePopup();
 
-        let searchbar = document.getElementById("searchbar");
-        searchbar.handleSearchCommand(event, engine);
+        // Select the clicked button so that consumers can easily tell which
+        // button was acted on.
+        this.selectedButton = button;
+        this.handleSearchCommand(event, engine);
       ]]></handler>
 
       <handler event="command"><![CDATA[
         let target = event.originalTarget;
         if (target.classList.contains("addengine-item")) {
-          // On success, hide and reshow the panel to show the new engine.
+          // On success, hide the panel and tell event listeners to reshow it to
+          // show the new engine.
           let installCallback = {
-            onSuccess: function(engine) {
-              event.target.hidePopup();
-              BrowserSearch.searchBar.openSuggestionsPanel();
+            onSuccess: engine => {
+              this._rebuild();
             },
             onError: function(errorCode) {
               if (errorCode != Ci.nsISearchInstallCallback.ERROR_DUPLICATE_ENGINE) {
                 // Download error is shown by the search service
                 return;
               }
               const kSearchBundleURI = "chrome://global/locale/search/search.properties";
               let searchBundle = Services.strings.createBundle(kSearchBundleURI);
@@ -1456,49 +1995,50 @@
             }
           }
           Services.search.addEngine(target.getAttribute("uri"), null,
                                     target.getAttribute("image"), false,
                                     installCallback);
         }
         let anonid = target.getAttribute("anonid");
         if (anonid == "search-one-offs-context-open-in-new-tab") {
-          let searchbar = document.getElementById("searchbar");
-          searchbar.handleSearchCommand(event, this._contextEngine, true);
+          // Select the context-clicked button so that consumers can easily
+          // tell which button was acted on.
+          this.selectedButton = this._buttonForEngine(this._contextEngine);
+          this.handleSearchCommand(event, this._contextEngine, true);
         }
         if (anonid == "search-one-offs-context-set-default") {
           let currentEngine = Services.search.currentEngine;
 
           // Make the target button of the context menu reflect the current
           // search engine first. Doing this as opposed to rebuilding all the
           // one-off buttons avoids flicker.
-          let button = document.getElementById("searchbar-engine-one-off-item-" +
-            this._contextEngine.name.replace(/ /g, '-'));
-          button.id = "searchbar-engine-one-off-item-" + currentEngine.name.replace(/ /g, '-');
+          let button = this._buttonForEngine(this._contextEngine);
+          button.id = this._buttonIDForEngine(currentEngine);
           let uri = "chrome://browser/skin/search-engine-placeholder.png";
           if (currentEngine.iconURI)
             uri = currentEngine.iconURI.spec;
           button.setAttribute("image", uri);
           button.setAttribute("tooltiptext", currentEngine.name);
           button.engine = currentEngine;
 
           Services.search.currentEngine = this._contextEngine;
         }
       ]]></handler>
 
-      <handler event="popuphiding"><![CDATA[
-        this._isHiding = true;
-        setTimeout(() => {
-          this._isHiding = false;
-        }, 0);
+      <handler event="contextmenu"><![CDATA[
+        let target = event.originalTarget;
+        // Prevent the context menu from appearing except on the one off buttons.
+        if (!target.classList.contains("searchbar-engine-one-off-item") ||
+            target.classList.contains("dummy")) {
+          event.preventDefault();
+          return;
+        }
+        document.getAnonymousElementByAttribute(this, "anonid", "search-one-offs-context-set-default")
+                .setAttribute("disabled", target.engine == Services.search.currentEngine);
+
+        this._contextEngine = target.engine;
       ]]></handler>
     </handlers>
-  </binding>
 
-  <!-- Used for additional open search providers in the search panel. -->
-  <binding id="addengine-icon" extends="xul:box">
-    <content>
-      <xul:image class="addengine-icon" xbl:inherits="src"/>
-      <xul:image class="addengine-badge"/>
-    </content>
   </binding>
 
 </bindings>
--- a/browser/components/search/content/searchbarBindings.css
+++ b/browser/components/search/content/searchbarBindings.css
@@ -2,8 +2,17 @@
  * 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/. */
 
 @namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
 
 .searchbar-textbox {
   -moz-binding: url("chrome://browser/content/search/search.xml#searchbar-textbox");
 }
+
+.search-one-offs {
+  -moz-binding: url("chrome://browser/content/search/search.xml#search-one-offs");
+}
+
+.search-setting-button[compact=true],
+.search-setting-button-compact:not([compact=true]) {
+  display: none;
+}
--- a/browser/components/search/test/browser_hiddenOneOffs_diacritics.js
+++ b/browser/components/search/test/browser_hiddenOneOffs_diacritics.js
@@ -9,31 +9,16 @@ const searchPopup = document.getElementB
 const searchIcon = document.getAnonymousElementByAttribute(searchbar, "anonid",
                                                            "searchbar-search-button");
 
 const diacritic_engine = "Foo \u2661";
 
 var Preferences =
   Cu.import("resource://gre/modules/Preferences.jsm", {}).Preferences;
 
-// Get an array of the one-off buttons.
-function getOneOffs() {
-  let oneOffs = [];
-  let oneOff =
-    document.getAnonymousElementByAttribute(searchPopup, "anonid",
-                                            "search-panel-one-offs");
-  for (oneOff = oneOff.firstChild; oneOff; oneOff = oneOff.nextSibling) {
-    if (oneOff.classList.contains("dummy"))
-      break;
-    oneOffs.push(oneOff);
-  }
-
-  return oneOffs;
-}
-
 add_task(function* init() {
   let currentEngine = Services.search.currentEngine;
   yield promiseNewEngine("testEngine_diacritics.xml", {setAsCurrent: false});
   registerCleanupFunction(() => {
     Services.search.currentEngine = currentEngine;
     Services.prefs.clearUserPref("browser.search.hiddenOneOffs");
   });
 });
--- a/browser/components/search/test/browser_oneOffHeader.js
+++ b/browser/components/search/test/browser_oneOffHeader.js
@@ -5,49 +5,38 @@
 
 const isMac = ("nsILocalFileMac" in Ci);
 
 const searchbar = document.getElementById("searchbar");
 const textbox = searchbar._textbox;
 const searchPopup = document.getElementById("PopupSearchAutoComplete");
 const searchIcon = document.getAnonymousElementByAttribute(searchbar, "anonid",
                                                            "searchbar-search-button");
+
+const oneOffsContainer =
+  document.getAnonymousElementByAttribute(searchPopup, "anonid",
+                                          "search-one-off-buttons");
 const searchSettings =
-  document.getAnonymousElementByAttribute(searchPopup, "anonid",
+  document.getAnonymousElementByAttribute(oneOffsContainer, "anonid",
                                           "search-settings");
 var header =
-  document.getAnonymousElementByAttribute(searchPopup, "anonid",
+  document.getAnonymousElementByAttribute(oneOffsContainer, "anonid",
                                           "search-panel-one-offs-header");
 function getHeaderText() {
   let headerChild = header.selectedPanel;
   while (headerChild.hasChildNodes()) {
     headerChild = headerChild.firstChild;
   }
   let headerStrings = [];
   for (let label = headerChild; label; label = label.nextSibling) {
     headerStrings.push(label.value);
   }
   return headerStrings.join("");
 }
 
-// Get an array of the one-off buttons.
-function getOneOffs() {
-  let oneOffs = [];
-  let oneOff =
-    document.getAnonymousElementByAttribute(searchPopup, "anonid",
-                                            "search-panel-one-offs");
-  for (oneOff = oneOff.firstChild; oneOff; oneOff = oneOff.nextSibling) {
-    if (oneOff.classList.contains("dummy"))
-      break;
-    oneOffs.push(oneOff);
-  }
-
-  return oneOffs;
-}
-
 const msg = isMac ? 5 : 1;
 const utils = window.QueryInterface(Ci.nsIInterfaceRequestor)
                     .getInterface(Ci.nsIDOMWindowUtils);
 const scale = utils.screenPixelsPerCSSPixel;
 function* synthesizeNativeMouseMove(aElement) {
   let rect = aElement.getBoundingClientRect();
   let win = aElement.ownerDocument.defaultView;
   let x = win.mozInnerScreenX + (rect.left + rect.right) / 2;
--- a/browser/components/search/test/browser_searchbar_keyboard_navigation.js
+++ b/browser/components/search/test/browser_searchbar_keyboard_navigation.js
@@ -1,36 +1,25 @@
 // Tests that keyboard navigation in the search panel works as designed.
 
 const searchbar = document.getElementById("searchbar");
 const textbox = searchbar._textbox;
 const searchPopup = document.getElementById("PopupSearchAutoComplete");
+const oneOffsContainer =
+  document.getAnonymousElementByAttribute(searchPopup, "anonid",
+                                          "search-one-off-buttons");
 
 const kValues = ["foo1", "foo2", "foo3"];
 const kUserValue = "foo";
 
-// Get an array of the one-off buttons.
-function getOneOffs() {
-  let oneOffs = [];
-  let oneOff = document.getAnonymousElementByAttribute(searchPopup, "anonid",
-                                                       "search-panel-one-offs");
-  for (oneOff = oneOff.firstChild; oneOff; oneOff = oneOff.nextSibling) {
-    if (oneOff.classList.contains("dummy"))
-      break;
-    oneOffs.push(oneOff);
-  }
-
-  return oneOffs;
-}
-
 function getOpenSearchItems() {
   let os = [];
 
   let addEngineList =
-    document.getAnonymousElementByAttribute(searchPopup, "anonid",
+    document.getAnonymousElementByAttribute(oneOffsContainer, "anonid",
                                             "add-engines");
   for (let item = addEngineList.firstChild; item; item = item.nextSibling)
     os.push(item);
 
   return os;
 }
 
 add_task(function* init() {
--- a/browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js
+++ b/browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js
@@ -1,37 +1,26 @@
 // Tests that keyboard navigation in the search panel works as designed.
 
 const searchbar = document.getElementById("searchbar");
 const textbox = searchbar._textbox;
 const searchPopup = document.getElementById("PopupSearchAutoComplete");
+const oneOffsContainer =
+  document.getAnonymousElementByAttribute(searchPopup, "anonid",
+                                          "search-one-off-buttons");
 const searchIcon = document.getAnonymousElementByAttribute(searchbar, "anonid",
                                                            "searchbar-search-button");
 
 const kValues = ["foo1", "foo2", "foo3"];
 
-// Get an array of the one-off buttons.
-function getOneOffs() {
-  let oneOffs = [];
-  let oneOff = document.getAnonymousElementByAttribute(searchPopup, "anonid",
-                                                       "search-panel-one-offs");
-  for (oneOff = oneOff.firstChild; oneOff; oneOff = oneOff.nextSibling) {
-    if (oneOff.classList.contains("dummy"))
-      break;
-    oneOffs.push(oneOff);
-  }
-
-  return oneOffs;
-}
-
 function getOpenSearchItems() {
   let os = [];
 
   let addEngineList =
-    document.getAnonymousElementByAttribute(searchPopup, "anonid",
+    document.getAnonymousElementByAttribute(oneOffsContainer, "anonid",
                                             "add-engines");
   for (let item = addEngineList.firstChild; item; item = item.nextSibling)
     os.push(item);
 
   return os;
 }
 
 add_task(function* init() {
--- a/browser/components/search/test/head.js
+++ b/browser/components/search/test/head.js
@@ -131,8 +131,29 @@ function promiseTabLoadEvent(tab, url)
   if (url)
     BrowserTestUtils.loadURI(tab.linkedBrowser, url);
 
   // Promise.all rejects if either promise rejects (i.e. if we time out) and
   // if our loaded promise resolves before the timeout, then we resolve the
   // timeout promise as well, causing the all promise to resolve.
   return Promise.all([deferred.promise, loaded]);
 }
+
+// Get an array of the one-off buttons.
+function getOneOffs() {
+  let oneOffs = [];
+  let searchPopup = document.getElementById("PopupSearchAutoComplete");
+  let oneOffsContainer =
+    document.getAnonymousElementByAttribute(searchPopup, "anonid",
+                                            "search-one-off-buttons");
+  let oneOff =
+    document.getAnonymousElementByAttribute(oneOffsContainer, "anonid",
+                                            "search-panel-one-offs");
+  for (oneOff = oneOff.firstChild; oneOff; oneOff = oneOff.nextSibling) {
+    if (oneOff.nodeType == Node.ELEMENT_NODE) {
+      if (oneOff.classList.contains("dummy") ||
+          oneOff.classList.contains("search-setting-button-compact"))
+        break;
+      oneOffs.push(oneOff);
+    }
+  }
+  return oneOffs;
+}
--- a/browser/themes/linux/searchbar.css
+++ b/browser/themes/linux/searchbar.css
@@ -116,17 +116,17 @@ menuitem[cmd="cmd_clearhistory"][disable
 }
 
 .search-panel-input-value {
   color: Highlight;
 }
 
 .search-panel-one-offs {
   margin: 0 -1px !important;
-  border-top: 1px solid rgba(0, 0, 0, 0.2);
+  border-top: 1px solid #ccc;
 }
 
 .searchbar-engine-one-off-item {
   -moz-appearance: none;
   display: inline-block;
   border: none;
   min-width: 48px;
   height: 32px;
@@ -142,17 +142,25 @@ menuitem[cmd="cmd_clearhistory"][disable
   background-position: left center;
 }
 
 .searchbar-engine-one-off-item:not(.last-row) {
   box-sizing: content-box;
   border-bottom: 1px solid rgba(0, 0, 0, 0.2);
 }
 
-.searchbar-engine-one-off-item.last-of-row {
+.search-setting-button-compact {
+  border-bottom: none !important;
+}
+
+.search-panel-one-offs:not([compact=true]) > .searchbar-engine-one-off-item.last-of-row,
+.search-panel-one-offs[compact=true] > .searchbar-engine-one-off-item.last-of-row:not(.dummy),
+.search-panel-one-offs[compact=true] > .searchbar-engine-one-off-item.dummy:not(.last-of-row),
+.search-panel-one-offs[compact=true] > .searchbar-engine-one-off-item.last-engine,
+.search-setting-button-compact {
   background-image: none;
 }
 
 .searchbar-engine-one-off-item[selected] {
   background-color: Highlight;
   background-image: none;
 }
 
@@ -247,8 +255,16 @@ menuitem[cmd="cmd_clearhistory"][disable
   min-height: 32px;
 }
 
 .search-setting-button[selected] {
   background-color: Highlight;
   color: HighlightText;
   border-top-color: #bdbebe;
 }
+
+.search-setting-button-compact {
+  list-style-image: url("chrome://browser/skin/gear.svg#gear");
+}
+
+.search-setting-button-compact[selected] {
+  list-style-image: url("chrome://browser/skin/gear.svg#gear-inverted");
+}
--- a/browser/themes/osx/searchbar.css
+++ b/browser/themes/osx/searchbar.css
@@ -168,17 +168,25 @@
   background-position: left center;
 }
 
 .searchbar-engine-one-off-item:not(.last-row) {
   box-sizing: content-box;
   border-bottom: 1px solid #ccc;
 }
 
-.searchbar-engine-one-off-item.last-of-row {
+.search-setting-button-compact {
+  border-bottom: none !important;
+}
+
+.search-panel-one-offs:not([compact=true]) > .searchbar-engine-one-off-item.last-of-row,
+.search-panel-one-offs[compact=true] > .searchbar-engine-one-off-item.last-of-row:not(.dummy),
+.search-panel-one-offs[compact=true] > .searchbar-engine-one-off-item.dummy:not(.last-of-row),
+.search-panel-one-offs[compact=true] > .searchbar-engine-one-off-item.last-engine,
+.search-setting-button-compact {
   background-image: none;
 }
 
 .searchbar-engine-one-off-item[selected] {
   background-color: Highlight;
   background-image: none;
 }
 
@@ -272,8 +280,16 @@
   border-radius: 0 0 4px 4px;
   min-height: 32px;
 }
 
 .search-setting-button[selected] {
   background-color: #d3d3d3;
   border-top-color: #bdbebe;
 }
+
+.search-setting-button-compact {
+  list-style-image: url("chrome://browser/skin/gear.svg#gear");
+}
+
+.search-setting-button-compact[selected] {
+  list-style-image: url("chrome://browser/skin/gear.svg#gear-inverted");
+}
--- a/browser/themes/shared/jar.inc.mn
+++ b/browser/themes/shared/jar.inc.mn
@@ -94,16 +94,17 @@
   skin/classic/browser/searchReset.css                         (../shared/searchReset.css)
   skin/classic/browser/badge-add-engine.png                    (../shared/search/badge-add-engine.png)
   skin/classic/browser/badge-add-engine@2x.png                 (../shared/search/badge-add-engine@2x.png)
   skin/classic/browser/search-indicator-badge-add.png          (../shared/search/search-indicator-badge-add.png)
   skin/classic/browser/search-indicator-badge-add@2x.png       (../shared/search/search-indicator-badge-add@2x.png)
   skin/classic/browser/search-history-icon.svg                 (../shared/search/history-icon.svg)
   skin/classic/browser/search-indicator-magnifying-glass.svg   (../shared/search/search-indicator-magnifying-glass.svg)
   skin/classic/browser/search-arrow-go.svg                     (../shared/search/search-arrow-go.svg)
+  skin/classic/browser/gear.svg                                (../shared/search/gear.svg)
   skin/classic/browser/social/chat-icons.svg                   (../shared/social/chat-icons.svg)
   skin/classic/browser/social/gear_default.png                 (../shared/social/gear_default.png)
   skin/classic/browser/social/gear_clicked.png                 (../shared/social/gear_clicked.png)
   skin/classic/browser/tabbrowser/crashed.svg                  (../shared/tabbrowser/crashed.svg)
   skin/classic/browser/tabbrowser/pendingpaint.png             (../shared/tabbrowser/pendingpaint.png)
 * skin/classic/browser/tabbrowser/tab-audio.svg                (../shared/tabbrowser/tab-audio.svg)
   skin/classic/browser/tabbrowser/tab-audio-small.svg          (../shared/tabbrowser/tab-audio-small.svg)
   skin/classic/browser/tabbrowser/tab-overflow-indicator.png   (../shared/tabbrowser/tab-overflow-indicator.png)
new file mode 100755
--- /dev/null
+++ b/browser/themes/shared/search/gear.svg
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 32 32">
+  <style>
+    use:not(:target) {
+      display: none;
+    }
+    use {
+      fill: GrayText;
+    }
+    use[id$="-inverted"] {
+      fill: highlighttext;
+    }
+  </style>
+  <defs>
+    <path id="glyphShape-gear" d="M28,16c0-1.7,0.9-3.1,2-3.3c-0.4-1.5-0.9-2.9-1.7-4.2c-0.9,0.7-2.6,0.3-3.8-0.9c-1.2-1.2-1.6-2.8-0.9-3.8 c-1.3-0.8-2.7-1.4-4.2-1.7c-0.2,1.1-1.6,2-3.3,2S13,3.1,12.8,2c-1.5,0.4-2.9,0.9-4.2,1.7c0.7,0.9,0.3,2.6-0.9,3.8 c-1.4,1.1-3,1.5-4,0.9C2.9,9.7,2.4,11.2,2,12.7c1.1,0.2,2,1.6,2,3.3s-0.9,3.1-2,3.3c0.4,1.5,0.9,2.9,1.7,4.2 c0.9-0.7,2.6-0.3,3.8,0.9c1.2,1.2,1.6,2.8,0.9,3.8c1.3,0.8,2.7,1.4,4.2,1.7c0.2-1.1,1.6-2,3.3-2s3.1,0.9,3.3,2 c1.5-0.4,2.9-0.9,4.2-1.7c-0.7-0.9-0.3-2.6,0.9-3.8c1.3-1.2,2.8-1.6,3.8-0.9c0.8-1.3,1.4-2.7,1.7-4.2C28.9,19.1,28,17.7,28,16z M16,24c-4.4,0-8-3.6-8-8s3.6-8,8-8s8,3.6,8,8S20.4,24,16,24z"/>
+  </defs>
+  <use id="gear" xlink:href="#glyphShape-gear"/>
+  <use id="gear-inverted" xlink:href="#glyphShape-gear"/>
+</svg>
--- a/browser/themes/windows/searchbar.css
+++ b/browser/themes/windows/searchbar.css
@@ -176,17 +176,25 @@
   background-position: left center;
 }
 
 .searchbar-engine-one-off-item:not(.last-row) {
   box-sizing: content-box;
   border-bottom: 1px solid #ccc;
 }
 
-.searchbar-engine-one-off-item.last-of-row {
+.search-setting-button-compact {
+  border-bottom: none !important;
+}
+
+.search-panel-one-offs:not([compact=true]) > .searchbar-engine-one-off-item.last-of-row,
+.search-panel-one-offs[compact=true] > .searchbar-engine-one-off-item.last-of-row:not(.dummy),
+.search-panel-one-offs[compact=true] > .searchbar-engine-one-off-item.dummy:not(.last-of-row),
+.search-panel-one-offs[compact=true] > .searchbar-engine-one-off-item.last-engine,
+.search-setting-button-compact {
   background-image: none;
 }
 
 .searchbar-engine-one-off-item[selected] {
   background-color: Highlight;
   background-image: none;
 }
 
@@ -286,8 +294,16 @@
   -moz-border-top-colors: none;
   min-height: 32px;
 }
 
 .search-setting-button[selected] {
   background-color: #d3d3d3;
   border-top-color: #bdbebe;
 }
+
+.search-setting-button-compact {
+  list-style-image: url("chrome://browser/skin/gear.svg#gear");
+}
+
+.search-setting-button-compact[selected] {
+  list-style-image: url("chrome://browser/skin/gear.svg#gear-inverted");
+}
--- a/toolkit/components/autocomplete/nsAutoCompleteController.cpp
+++ b/toolkit/components/autocomplete/nsAutoCompleteController.cpp
@@ -284,17 +284,19 @@ nsAutoCompleteController::HandleText()
   }
 
   StartSearches();
 
   return NS_OK;
 }
 
 NS_IMETHODIMP
-nsAutoCompleteController::HandleEnter(bool aIsPopupSelection, bool *_retval)
+nsAutoCompleteController::HandleEnter(bool aIsPopupSelection,
+                                      nsIDOMEvent *aEvent,
+                                      bool *_retval)
 {
   *_retval = false;
   if (!mInput)
     return NS_OK;
 
   nsCOMPtr<nsIAutoCompleteInput> input(mInput);
 
   // allow the event through unless there is something selected in the popup
@@ -307,17 +309,17 @@ nsAutoCompleteController::HandleEnter(bo
       int32_t selectedIndex;
       popup->GetSelectedIndex(&selectedIndex);
       *_retval = selectedIndex >= 0;
     }
   }
 
   // Stop the search, and handle the enter.
   StopSearch();
-  EnterMatch(aIsPopupSelection);
+  EnterMatch(aIsPopupSelection, aEvent);
 
   return NS_OK;
 }
 
 NS_IMETHODIMP
 nsAutoCompleteController::HandleEscape(bool *_retval)
 {
   *_retval = false;
@@ -383,17 +385,17 @@ nsAutoCompleteController::HandleEndCompo
   mCompositionState = eCompositionState_Committing;
   return NS_OK;
 }
 
 NS_IMETHODIMP
 nsAutoCompleteController::HandleTab()
 {
   bool cancel;
-  return HandleEnter(false, &cancel);
+  return HandleEnter(false, nullptr, &cancel);
 }
 
 NS_IMETHODIMP
 nsAutoCompleteController::HandleKeyNavigation(uint32_t aKey, bool *_retval)
 {
   // By default, don't cancel the event
   *_retval = false;
 
@@ -1351,17 +1353,18 @@ nsAutoCompleteController::ClearSearchTim
   if (mTimer) {
     mTimer->Cancel();
     mTimer = nullptr;
   }
   return NS_OK;
 }
 
 nsresult
-nsAutoCompleteController::EnterMatch(bool aIsPopupSelection)
+nsAutoCompleteController::EnterMatch(bool aIsPopupSelection,
+                                     nsIDOMEvent *aEvent)
 {
   nsCOMPtr<nsIAutoCompleteInput> input(mInput);
   nsCOMPtr<nsIAutoCompletePopup> popup;
   input->GetPopup(getter_AddRefs(popup));
   NS_ENSURE_TRUE(popup != nullptr, NS_ERROR_FAILURE);
 
   bool forceComplete;
   input->GetForceComplete(&forceComplete);
@@ -1487,17 +1490,17 @@ nsAutoCompleteController::EnterMatch(boo
     input->SelectTextRange(value.Length(), value.Length());
     mSearchString = value;
   }
 
   obsSvc->NotifyObservers(input, "autocomplete-did-enter-text", nullptr);
   ClosePopup();
 
   bool cancel;
-  input->OnTextEntered(&cancel);
+  input->OnTextEntered(aEvent, &cancel);
 
   return NS_OK;
 }
 
 nsresult
 nsAutoCompleteController::RevertTextValue()
 {
   // StopSearch() can call PostSearchCleanup() which might result
--- a/toolkit/components/autocomplete/nsAutoCompleteController.h
+++ b/toolkit/components/autocomplete/nsAutoCompleteController.h
@@ -50,17 +50,18 @@ protected:
   nsresult ClearSearchTimer();
   void MaybeCompletePlaceholder();
 
   void HandleSearchResult(nsIAutoCompleteSearch *aSearch,
                           nsIAutoCompleteResult *aResult);
   nsresult ProcessResult(int32_t aSearchIndex, nsIAutoCompleteResult *aResult);
   nsresult PostSearchCleanup();
 
-  nsresult EnterMatch(bool aIsPopupSelection);
+  nsresult EnterMatch(bool aIsPopupSelection,
+                      nsIDOMEvent *aEvent);
   nsresult RevertTextValue();
 
   nsresult CompleteDefaultIndex(int32_t aResultIndex);
   nsresult CompleteValue(nsString &aValue);
 
   nsresult GetResultAt(int32_t aIndex, nsIAutoCompleteResult** aResult,
                        int32_t* aRowIndex);
   nsresult GetResultValueAt(int32_t aIndex, bool aGetFinalValue,
--- a/toolkit/components/autocomplete/nsIAutoCompleteController.idl
+++ b/toolkit/components/autocomplete/nsIAutoCompleteController.idl
@@ -1,15 +1,16 @@
 /* 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/. */
 
 #include "nsISupports.idl"
 
 interface nsIAutoCompleteInput;
+interface nsIDOMEvent;
 
 [scriptable, uuid(ff9f8465-204a-47a6-b3c9-0628b3856684)]
 interface nsIAutoCompleteController : nsISupports
 {
   /*
    * Possible values for the searchStatus attribute
    */
   const unsigned short STATUS_NONE = 1;
@@ -58,19 +59,26 @@ interface nsIAutoCompleteController : ns
   void handleText();
 
   /*
    * Notify the controller that the user wishes to enter the current text. If
    * aIsPopupSelection is true, then a selection was made from the popup, so
    * fill this value into the input field before continuing. If false, just
    * use the current value of the input field.
    *
+   * @param aIsPopupSelection
+   *        Pass true if the selection was made from the popup.
+   * @param aEvent
+   *        The event that triggered the enter, like a key event if the user
+   *        pressed the Return key or a click event if the user clicked a popup
+   *        item.
    * @return True if the controller wishes to prevent event propagation and default event
    */
-  boolean handleEnter(in boolean aIsPopupSelection);
+  boolean handleEnter(in boolean aIsPopupSelection,
+                      [optional] in nsIDOMEvent aEvent);
 
   /*
    * Notify the controller that the user wishes to revert autocomplete
    *
    * @return True if the controller wishes to prevent event propagation and default event
    */
   boolean handleEscape();
 
--- a/toolkit/components/autocomplete/nsIAutoCompleteInput.idl
+++ b/toolkit/components/autocomplete/nsIAutoCompleteInput.idl
@@ -119,19 +119,21 @@ interface nsIAutoCompleteInput : nsISupp
   /*
    * Notification that the search concluded successfully
    */
   void onSearchComplete();
 
   /*
    * Notification that the user selected and entered a result item
    *
+   * @param aEvent
+   *        The event that triggered the enter.
    * @return True if the user wishes to prevent the enter
    */
-  boolean onTextEntered();
+  boolean onTextEntered([optional] in nsIDOMEvent aEvent);
 
   /*
    * Notification that the user cancelled the autocomplete session
    *
    * @return True if the user wishes to prevent the revert
    */
   boolean onTextReverted();
 
--- a/toolkit/components/places/PlacesUtils.jsm
+++ b/toolkit/components/places/PlacesUtils.jsm
@@ -322,16 +322,33 @@ this.PlacesUtils = {
     return bundle.formatStringFromName(key, params, params.length);
   },
 
   getString: function PU_getString(key) {
     return bundle.GetStringFromName(key);
   },
 
   /**
+   * Makes a moz-action URI for the given action and set of parameters.
+   *
+   * @param   type
+   *          The action type.
+   * @param   params
+   *          A JS object of action params.
+   * @returns A moz-action URI as a string.
+   */
+  mozActionURI(type, params) {
+    let encodedParams = {};
+    for (let key in params) {
+      encodedParams[key] = encodeURIComponent(params[key]);
+    }
+    return "moz-action:" + type + "," + JSON.stringify(encodedParams);
+  },
+
+  /**
    * Determines whether or not a ResultNode is a Bookmark folder.
    * @param   aNode
    *          A result node
    * @returns true if the node is a Bookmark folder, false otherwise
    */
   nodeIsFolder: function PU_nodeIsFolder(aNode) {
     return (aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER ||
             aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT);
--- a/toolkit/components/places/UnifiedComplete.js
+++ b/toolkit/components/places/UnifiedComplete.js
@@ -564,34 +564,16 @@ function stripHttpAndTrim(spec) {
   }
   if (spec.endsWith("/")) {
     spec = spec.slice(0, -1);
   }
   return spec;
 }
 
 /**
- * Make a moz-action: URL for a given action and set of parameters.
- *
- * @param action
- *        Name of the action
- * @param params
- *        Object, whose keys are parameter names and values are the
- *        corresponding parameter values.
- * @return String representation of the built moz-action: URL
- */
-function makeActionURL(action, params) {
-  let encodedParams = {};
-  for (let key in params) {
-    encodedParams[key] = encodeURIComponent(params[key]);
-  }
-  return "moz-action:" + action + "," + JSON.stringify(encodedParams);
-}
-
-/**
  * Returns the key to be used for a URL in a map for the purposes of removing
  * duplicate entries - any 2 URLs that should be considered the same should
  * return the same key. For some moz-action URLs this will unwrap the params
  * and return a key based on the wrapped URL.
  */
 function makeKeyForURL(actionUrl) {
   // At this stage we only consider moz-action URLs.
   if (!actionUrl.startsWith("moz-action:")) {
@@ -1120,18 +1102,20 @@ Search.prototype = {
     if (queryIndex != -1) {
       queryString = searchString.substring(queryIndex + 1);
     }
     // We need to escape the parameters as if they were the query in a URL
     queryString = encodeURIComponent(queryString).replace(/%20/g, "+");
     let escapedURL = entry.url.href.replace("%s", queryString);
 
     let style = (this._enableActions ? "action " : "") + "keyword";
-    let actionURL = makeActionURL("keyword", { url: escapedURL,
-                                               input: this._originalSearchString });
+    let actionURL = PlacesUtils.mozActionURI("keyword", {
+      url: escapedURL,
+      input: this._originalSearchString,
+    });
     let value = this._enableActions ? actionURL : escapedURL;
     // The title will end up being "host: queryString"
     let comment = entry.url.host;
 
     this._addMatch({ value, comment, style, frecency: FRECENCY_DEFAULT });
     return true;
   },
 
@@ -1219,17 +1203,17 @@ Search.prototype = {
       input: suggestion || this._originalSearchString,
       searchQuery: query,
     };
     if (suggestion)
       actionURLParams.searchSuggestion = suggestion;
     if (match.engineAlias) {
       actionURLParams.alias = match.engineAlias;
     }
-    let value = makeActionURL("searchengine", actionURLParams);
+    let value = PlacesUtils.mozActionURI("searchengine", actionURLParams);
 
     this._addMatch({
       value: value,
       comment: match.engineName,
       icon: match.iconUrl,
       style: "action searchengine",
       frecency: FRECENCY_DEFAULT,
       remote: !!suggestion
@@ -1251,17 +1235,17 @@ Search.prototype = {
       } else {
         icon = PlacesUtils.favicons
                           .getFaviconLinkForIcon(NetUtil.newURI(icon)).spec;
       }
 
       let match = {
         // We include the deviceName in the action URL so we can render it in
         // the URLBar.
-        value: makeActionURL("remotetab", { url, deviceName }),
+        value: PlacesUtils.mozActionURI("remotetab", { url, deviceName }),
         comment: title || url,
         style: "action remotetab",
         // we want frecency > FRECENCY_DEFAULT so it doesn't get pushed out
         // by "remote" matches.
         frecency: FRECENCY_DEFAULT + 1,
         icon,
       }
       this._addMatch(match);
@@ -1311,17 +1295,17 @@ Search.prototype = {
     // getFixupURIInfo() escaped the URI, so it may not be pretty.  Embed the
     // escaped URL in the action URI since that URL should be "canonical".  But
     // pass the pretty, unescaped URL as the match comment, since it's likely
     // to be displayed to the user, and in any case the front-end should not
     // rely on it being canonical.
     let escapedURL = uri.spec;
     let displayURL = textURIService.unEscapeURIForUI("UTF-8", uri.spec);
 
-    let value = makeActionURL("visiturl", {
+    let value = PlacesUtils.mozActionURI("visiturl", {
       url: escapedURL,
       input: this._originalSearchString,
     });
 
     let match = {
       value: value,
       comment: displayURL,
       style: "action visiturl",
@@ -1380,17 +1364,17 @@ Search.prototype = {
     // when searching for "Firefox".
     let terms = parseResult.terms.toLowerCase();
     if (this._searchTokens.length > 0 &&
         this._searchTokens.every(token => !terms.includes(token))) {
       return;
     }
 
     // Turn the match into a searchengine action with a favicon.
-    match.value = makeActionURL("searchengine", {
+    match.value = PlacesUtils.mozActionURI("searchengine", {
       engineName: parseResult.engineName,
       input: parseResult.terms,
       searchQuery: parseResult.terms,
     });
     match.comment = parseResult.engineName;
     match.icon = match.icon || match.iconUrl;
     match.style = "action searchengine favicon";
   },
@@ -1552,17 +1536,17 @@ Search.prototype = {
     let tags = row.getResultByIndex(QUERYINDEX_TAGS) || "";
     let frecency = row.getResultByIndex(QUERYINDEX_FRECENCY);
 
     // If actions are enabled and the page is open, add only the switch-to-tab
     // result.  Otherwise, add the normal result.
     let url = escapedURL;
     let action = null;
     if (this._enableActions && openPageCount > 0 && this.hasBehavior("openpage")) {
-      url = makeActionURL("switchtab", {url: escapedURL});
+      url = PlacesUtils.mozActionURI("switchtab", {url: escapedURL});
       action = "switchtab";
     }
 
     // Always prefer the bookmark title unless it is empty
     let title = bookmarkTitle || historyTitle;
 
     // We will always prefer to show tags if we have them.
     let showTags = !!tags;
--- a/toolkit/components/satchel/nsFormFillController.cpp
+++ b/toolkit/components/satchel/nsFormFillController.cpp
@@ -552,17 +552,18 @@ nsFormFillController::OnSearchBegin()
 
 NS_IMETHODIMP
 nsFormFillController::OnSearchComplete()
 {
   return NS_OK;
 }
 
 NS_IMETHODIMP
-nsFormFillController::OnTextEntered(bool* aPrevent)
+nsFormFillController::OnTextEntered(nsIDOMEvent* aEvent,
+                                    bool* aPrevent)
 {
   NS_ENSURE_ARG(aPrevent);
   NS_ENSURE_TRUE(mFocusedInput, NS_OK);
   // Fire off a DOMAutoComplete event
   nsCOMPtr<nsIDOMDocument> domDoc;
   nsCOMPtr<nsIDOMElement> element = do_QueryInterface(mFocusedInput);
   element->GetOwnerDocument(getter_AddRefs(domDoc));
   NS_ENSURE_STATE(domDoc);
@@ -1003,17 +1004,17 @@ nsFormFillController::KeyPress(nsIDOMEve
   case nsIDOMKeyEvent::DOM_VK_ESCAPE:
     mController->HandleEscape(&cancel);
     break;
   case nsIDOMKeyEvent::DOM_VK_TAB:
     mController->HandleTab();
     cancel = false;
     break;
   case nsIDOMKeyEvent::DOM_VK_RETURN:
-    mController->HandleEnter(false, &cancel);
+    mController->HandleEnter(false, aEvent, &cancel);
     break;
   }
 
   if (cancel) {
     aEvent->PreventDefault();
     // Don't let the page see the RETURN event when the popup is open
     // (indicated by cancel=true) so sites don't manually submit forms
     // (e.g. via submit.click()) without the autocompleted value being filled.
--- a/toolkit/content/widgets/autocomplete.xml
+++ b/toolkit/content/widgets/autocomplete.xml
@@ -39,17 +39,16 @@
 
       <children includes="toolbarbutton"/>
     </content>
 
     <implementation implements="nsIAutoCompleteInput, nsIDOMXULMenuListElement">
       <field name="mController">null</field>
       <field name="mSearchNames">null</field>
       <field name="mIgnoreInput">false</field>
-      <field name="mEnterEvent">null</field>
 
       <field name="_searchBeginHandler">null</field>
       <field name="_searchCompleteHandler">null</field>
       <field name="_textEnteredHandler">null</field>
       <field name="_textRevertedHandler">null</field>
 
       <constructor><![CDATA[
         this.mController = Components.classes["@mozilla.org/autocomplete/controller;1"].
@@ -227,21 +226,22 @@
           }
 
           if (this._searchCompleteHandler)
             this._searchCompleteHandler();
         ]]></body>
       </method>
 
       <method name="onTextEntered">
+        <parameter name="event"/>
         <body><![CDATA[
           let rv = false;
-          if (this._textEnteredHandler)
-            rv = this._textEnteredHandler(this.mEnterEvent);
-          this.mEnterEvent = null;
+          if (this._textEnteredHandler) {
+            rv = this._textEnteredHandler(event);
+          }
           return rv;
         ]]></body>
       </method>
 
       <method name="onTextReverted">
         <body><![CDATA[
           if (this._textRevertedHandler)
             return this._textRevertedHandler();
@@ -489,24 +489,23 @@
               cancel = this.mController.handleEscape();
               break;
             case KeyEvent.DOM_VK_RETURN:
               if (AppConstants.platform == "macosx") {
                 // Prevent the default action, since it will beep on Mac
                 if (aEvent.metaKey)
                   aEvent.preventDefault();
               }
-              this.mEnterEvent = aEvent;
               if (this.mController.selection) {
                 this._selectionDetails = {
                   index: this.mController.selection.currentIndex,
                   kind: "key"
                 };
               }
-              cancel = this.handleEnter();
+              cancel = this.handleEnter(aEvent);
               break;
             case KeyEvent.DOM_VK_DELETE:
               if (AppConstants.platform == "macosx" && !aEvent.shiftKey) {
                 break;
               }
               cancel = this.handleDelete();
               break;
             case KeyEvent.DOM_VK_BACK_SPACE:
@@ -531,18 +530,19 @@
             aEvent.preventDefault();
           }
 
           return true;
         ]]></body>
       </method>
 
       <method name="handleEnter">
+        <parameter name="event"/>
         <body><![CDATA[
-          return this.mController.handleEnter(false);
+          return this.mController.handleEnter(false, event || null);
         ]]></body>
       </method>
 
       <method name="handleDelete">
         <body><![CDATA[
           return this.mController.handleDelete();
         ]]></body>
       </method>
@@ -941,17 +941,17 @@ extends="chrome://global/content/binding
           return aIndex;
         ]]></body>
       </method>
 
       <method name="onPopupClick">
         <parameter name="aEvent"/>
         <body><![CDATA[
           var controller = this.view.QueryInterface(Components.interfaces.nsIAutoCompleteController);
-          controller.handleEnter(true);
+          controller.handleEnter(true, aEvent);
         ]]></body>
       </method>
     </implementation>
 
     <handlers>
       <handler event="popupshowing"><![CDATA[
         // If normalMaxRows wasn't already set by the input, then set it here
         // so that we restore the correct number when the popup is hidden.
@@ -1248,40 +1248,49 @@ extends="chrome://global/content/binding
               // due to new results, but only when: the item is the same, *OR*
               // we are about to replace the currently mouse-selected item, to
               // avoid surprising the user.
               let iface = Components.interfaces.nsIAutoCompletePopup;
               if (item.getAttribute("text") == trimmedSearchString &&
                   invalidateReason == iface.INVALIDATE_REASON_NEW_RESULT &&
                   (item.getAttribute("url") == url ||
                    this.richlistbox.mouseSelectedIndex === this._currentIndex)) {
-                item.collapsed = false;
-                // Call adjustSiteIconStart only after setting collapsed=false.
-                // The calculations it does may be wrong otherwise.
-                item.adjustSiteIconStart(this._siteIconStart);
-                // The popup may have changed size between now and the last time
-                // the item was shown, so always handle over/underflow.
-                item.handleOverUnderflow();
-                this._currentIndex++;
-                continue;
+                // Additionally, if the item is a searchengine action, then it
+                // should only be reused if the engine name is the same as the
+                // popup's override engine name, if any.
+                let action = item._parseActionUrl(url);
+                if (!action ||
+                    action.type != "searchengine" ||
+                    !this.overrideSearchEngineName ||
+                    action.params.engineName == this.overrideSearchEngineName) {
+                  item.collapsed = false;
+                  // Call adjustSiteIconStart only after setting collapsed=
+                  // false.  The calculations it does may be wrong otherwise.
+                  item.adjustSiteIconStart(this._siteIconStart);
+                  // The popup may have changed size between now and the last
+                  // time the item was shown, so always handle over/underflow.
+                  item.handleOverUnderflow();
+                  this._currentIndex++;
+                  continue;
+                }
               }
             }
             else {
               // need to create a new item
               item = document.createElementNS("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", "richlistitem");
               item.setAttribute("dir", this.style.direction);
             }
 
             // set these attributes before we set the class
             // so that we can use them from the constructor
             let iconURI = controller.getImageAt(this._currentIndex);
             item.setAttribute("image", iconURI);
             item.setAttribute("url", url);
             item.setAttribute("title", controller.getCommentAt(this._currentIndex));
-            item.setAttribute("type", controller.getStyleAt(this._currentIndex));
+            item.setAttribute("originaltype", controller.getStyleAt(this._currentIndex));
             item.setAttribute("text", trimmedSearchString);
 
             if (this._currentIndex < existingItemsCount) {
               // re-use the existing item
               item._adjustAcItem();
               item.collapsed = false;
             }
             else {
@@ -1846,17 +1855,17 @@ extends="chrome://global/content/binding
           }
 
           let title = this.getAttribute("title");
 
           let displayUrl;
           let originalUrl = this.getAttribute("url");
           let emphasiseUrl = true;
 
-          let type = this.getAttribute("type");
+          let type = this.getAttribute("originaltype");
           let types = new Set(type.split(/\s+/));
           let initialTypes = new Set(types);
           // Remove types that should ultimately not be in the `type` string.
           types.delete("action");
           types.delete("autofill");
           types.delete("heuristic");
           type = [...types][0] || "";
 
@@ -1893,16 +1902,27 @@ extends="chrome://global/content/binding
               emphasiseUrl = false;
 
               // The order here is not localizable, we default to appending
               // "- Search with Engine" to the search string, to be able to
               // properly generate emphasis pairs. That said, no localization
               // changed the order while it was possible, so doesn't look like
               // there's a strong need for that.
               let {engineName, searchSuggestion, searchQuery} = action.params;
+
+              // Override the engine name if the popup defines an override.
+              let override = popup.overrideSearchEngineName;
+              if (override && override != engineName) {
+                engineName = override;
+                action.params.engineName = override;
+                let newURL =
+                  PlacesUtils.mozActionURI(action.type, action.params);
+                this.setAttribute("url", newURL);
+              }
+
               let engineStr =
                 this._stringBundle.formatStringFromName("searchWithEngine",
                                                         [engineName], 1);
               this._setUpDescription(this._actionText, engineStr, true);
 
               // Make the title by generating an array of pairs and its
               // corresponding interpolation string (e.g., "%1$S") to pass to
               // _generateEmphasisPairs.