Source code
Revision control
Copy as Markdown
Other Tools
/* 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
/**
* @import MozButton from "chrome://global/content/elements/moz-button.mjs";
* @import { SearchEngine } from "moz-src:///toolkit/components/search/SearchEngine.sys.mjs"
* @import { OpenSearchData } from "moz-src:///browser/components/search/OpenSearchManager.sys.mjs"
* @import { PanelItem, PanelList } from "chrome://global/content/elements/panel-list.mjs"
*/
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
OpenSearchManager:
"moz-src:///browser/components/search/OpenSearchManager.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
SearchService: "moz-src:///toolkit/components/search/SearchService.sys.mjs",
SearchUIUtils: "moz-src:///browser/components/search/SearchUIUtils.sys.mjs",
UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
UrlbarSearchUtils:
"moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs",
UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "SearchModeSwitcherL10n", () => {
return new Localization(["browser/browser.ftl"]);
});
// Default icon used for engines that do not have icons loaded.
const DEFAULT_ENGINE_ICON =
"chrome://browser/skin/search-engine-placeholder@2x.png";
/**
* Implements the SearchModeSwitcher in the urlbar.
*/
export class SearchModeSwitcher {
static ICON_GLASS = lazy.UrlbarUtils.ICON.SEARCH_GLASS;
static ICON_GLOBE = lazy.UrlbarUtils.ICON.GLOBE;
/**
* The maximum number of openSearch engines available to install
* to display.
*/
static MAX_OPENSEARCH_ENGINES = 3;
/** @type {PanelList} */
#panelList;
/** @type {UrlbarInput} */
#input;
/** @type {MozButton} */
#button;
/** @type {HTMLButtonElement} */
#closebutton;
// Keep a cache of the engine list as the keyboard functionality
// needs sync access to them.
#engines = [];
// Keep track of the currently selected engine when the user is cycling
// through them with Accel+Up/Down.
#selectedIndex = 0;
/**
* @param {UrlbarInput} input
*/
constructor(input) {
this.#input = input;
this.QueryInterface = ChromeUtils.generateQI([
"nsIObserver",
"nsISupportsWeakReference",
]);
lazy.UrlbarPrefs.addObserver(this);
this.#panelList = input.querySelector(".searchmode-switcher-panel-list");
this.#button = input.querySelector(".searchmode-switcher");
this.#closebutton = input.querySelector(".searchmode-switcher-close");
// MozButton and PanelList have to be hooked up via id.
this.#panelList.id = "searchmode-switcher-panel-list-" + input.sapName;
this.#button.setAttribute("menuid", this.#panelList.id);
// In XUL documents, wrap in a XUL panel to make sure it's
// on top of the overflow panel and catches all keypresses.
let doc = this.#panelList.ownerDocument;
if (doc.createXULElement) {
let panel = doc.createXULElement("panel");
panel.setAttribute("level", "top");
panel.setAttribute("consumeoutsideclicks", "false");
panel.classList.add("searchmode-switcher-panel", "toolbar-menupopup");
this.#panelList.replaceWith(panel);
panel.appendChild(this.#panelList);
}
if (this.#isEnabled) {
this.#enableObservers();
}
}
#isEnabled() {
return (
lazy.UrlbarPrefs.get("scotchBonnet.enableOverride") ||
this.#input.sapName == "searchbar"
);
}
async #onPopupShowing() {
// Discard event to avoid recording an abandonment.
this.#input.controller.engagementEvent.discard();
await this.#buildSearchModeList();
this.#input.view.close({ showFocusBorder: false });
if (this.#input.sapName == "urlbar") {
Glean.urlbarUnifiedsearchbutton.opened.add(1);
}
}
/**
* Close the SearchSwitcher popup.
*/
closePanel() {
this.#panelList.hide(null, { force: true });
}
#openPreferences() {
this.#input.window.openPreferences("paneSearch");
if (this.#input.sapName == "urlbar") {
Glean.urlbarUnifiedsearchbutton.picked.settings.add(1);
}
}
/**
* Exit the engine specific searchMode.
*
* @param {Event} event
* The event that triggered the searchMode exit.
*/
exitSearchMode(event) {
event.preventDefault();
this.#input.searchMode = null;
this.#selectedIndex = 0;
// Update the result by the default engine.
this.#input.startQuery();
}
/**
* Called when the value of the searchMode attribute on UrlbarInput is changed.
*/
onSearchModeChanged() {
if (!this.#input.window || this.#input.window.closed) {
return;
}
if (this.#isEnabled()) {
this.updateSearchIcon();
let engine = lazy.UrlbarSearchUtils.getEngineByName(
this.#input.searchMode?.engineName
);
if (engine && engine.isConfigEngine && !engine.hasBeenUsed) {
engine.markAsUsed();
}
}
}
handleEvent(event) {
if (event.currentTarget.localName == "panel-item") {
this.#handlePanelItemEvent(event);
return;
}
if (event.currentTarget == this.#closebutton) {
// Prevent click and mousedown from bubbling up
// to #button which would open the popup.
event.stopPropagation();
if (event.type == "click") {
this.#input.focus();
this.exitSearchMode(event);
}
return;
}
if (event.type == "focus") {
this.#input.setUnifiedSearchButtonAvailability(true);
return;
}
if (event.type == "showing") {
this.#onPopupShowing();
return;
}
if (event.type == "hidden") {
if (this.#input.document.activeElement == this.#button) {
// This moves the focus to the urlbar when the popup is closed.
this.#input.focus();
}
return;
}
if (event.type == "keydown") {
if (this.#input.view.isOpen) {
// The urlbar view is open, which means the unified search button got
// focus by tab key from urlbar.
switch (event.keyCode) {
case KeyEvent.DOM_VK_TAB: {
// Move the focus to urlbar view to make cyclable.
this.#input.focus();
this.#input.view.selectBy(1, {
reverse: event.shiftKey,
userPressedTab: true,
});
event.preventDefault();
return;
}
case KeyEvent.DOM_VK_ESCAPE: {
this.#input.view.close();
this.#input.focus();
event.preventDefault();
return;
}
}
}
// Manually open the popup on down.
if (event.keyCode == KeyEvent.DOM_VK_DOWN) {
this.#panelList.show(event);
}
}
}
/**
* @param {MouseEvent|KeyboardEvent} event
* A mouseup, click or keydown event.
* Click is used for regular mouse clicks.
* Auxclick is used for middle clicks.
*/
#handlePanelItemEvent(event) {
switch (event.type) {
case "click": {
let mouseEvent = /** @type {MouseEvent} */ (event);
// Prevent the panel from closing. We handle that manually.
mouseEvent.stopPropagation();
if (mouseEvent.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
// For now, we handle them on keydown instead.
return;
}
break;
}
case "keydown": {
let keyboardEvent = /** @type {KeyboardEvent} */ (event);
if (
keyboardEvent.keyCode != KeyEvent.DOM_VK_SPACE &&
keyboardEvent.keyCode != KeyEvent.DOM_VK_RETURN
) {
return;
}
break;
}
case "auxclick": {
let mouseEvent = /** @type {MouseEvent} */ (event);
if (mouseEvent.button != 1) {
// Ignore non-middle-auxclicks.
return;
}
break;
}
}
let panelItem = /** @type {PanelItem} */ (event.currentTarget);
switch (panelItem.dataset.action) {
case "openpreferences": {
this.closePanel();
this.#openPreferences();
break;
}
case "searchmode": {
// #remoteSearch() decides whether to close the panel or keep it open.
let engineId = panelItem.dataset.engineId;
this.#remoteSearch(lazy.SearchService.getEngineById(engineId), event);
break;
}
case "localsearchmode": {
this.closePanel();
let restrict = panelItem.dataset.restrict;
this.#localSearch(restrict);
break;
}
case "installopensearch": {
this.closePanel();
// @ts-expect-error
let engine = panelItem._engine;
this.#installOpenSearchEngine(engine);
break;
}
}
}
/**
* @param {PanelItem} panelItem
*/
#addCommandListeners(panelItem) {
panelItem.addEventListener("click", this);
panelItem.addEventListener("keydown", this);
panelItem.addEventListener("auxclick", this);
}
observe(_subject, topic, data) {
if (
!this.#input.window ||
this.#input.window.closed ||
!this.#input.isConnected
) {
return;
}
switch (topic) {
case "browser-search-engine-modified": {
if (
data === "engine-default" ||
data === "engine-default-private" ||
data === "engine-icon-changed"
) {
this.updateSearchIcon();
}
break;
}
}
}
/**
* Called when a urlbar pref changes.
*
* @param {string} pref
* The name of the pref relative to `browser.urlbar`.
*/
onPrefChanged(pref) {
if (!this.#input.window || this.#input.window.closed) {
return;
}
if (this.#input.sapName == "searchbar") {
// The searchbar cares about neither of the two prefs.
return;
}
switch (pref) {
case "scotchBonnet.enableOverride": {
if (lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) {
this.#enableObservers();
this.updateSearchIcon();
} else {
this.#disableObservers();
}
break;
}
case "keyword.enabled": {
if (lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) {
this.updateSearchIcon();
}
break;
}
}
}
/**
* If the user presses Option+Up or Option+Down we open the engine list.
*
* @param {KeyboardEvent} event
* The key down event.
*/
handleKeyDown(event) {
if (
(event.keyCode == KeyEvent.DOM_VK_UP ||
event.keyCode == KeyEvent.DOM_VK_DOWN) &&
(event.altKey || event.getModifierState("Accel"))
) {
if (event.altKey) {
this.#handleAltUpDown(event);
} else if (event.getModifierState("Accel")) {
this.#handleAccelUpDown(event);
}
event.stopPropagation();
event.preventDefault();
return true;
}
return false;
}
#handleAltUpDown(event) {
this.#input.controller.focusOnUnifiedSearchButton();
this.#panelList.show(event, this.#button);
}
async #handleAccelUpDown(event) {
await this.#populateEngines();
this.#selectedIndex += event.keyCode == KeyEvent.DOM_VK_UP ? -1 : 1;
if (this.#selectedIndex > this.#engines.length - 1) {
this.#selectedIndex = 0;
}
if (this.#selectedIndex < 0) {
this.#selectedIndex = this.#engines.length - 1;
}
let selectedEngine = this.#engines[this.#selectedIndex];
this.#input.setSearchMode(
{
entry: "searchbutton",
isPreview: false,
source: selectedEngine?.source || lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
engineName: selectedEngine?.name,
},
this.#input.window.gBrowser.selectedBrowser
);
}
async #populateEngines() {
let searchEngines = (await lazy.SearchService.getVisibleEngines()).filter(
engine => !engine.hideOneOffButton
);
this.#engines = searchEngines.concat(
lazy.UrlbarUtils.LOCAL_SEARCH_MODES.filter(
engine =>
this.#input.sapName == "urlbar" && lazy.UrlbarPrefs.get(engine.pref)
)
);
}
async updateSearchIcon() {
let searchMode = this.#input.searchMode;
try {
await lazy.UrlbarSearchUtils.init();
} catch {
console.error("Search service failed to init");
}
let { label, icon } = await this.#getDisplayedEngineDetails(
this.#input.searchMode
);
if (searchMode?.source != this.#input.searchMode?.source) {
return;
}
const inSearchMode = this.#input.searchMode;
if (
this.#input.sapName != "searchbar" &&
!lazy.UrlbarPrefs.get("keyword.enabled") &&
!inSearchMode
) {
icon = SearchModeSwitcher.ICON_GLOBE;
}
// If the pref is enabled, then update urlbar icons as user types.
if (lazy.UrlbarPrefs.get("unifiedSearchButton.always")) {
if (this.#input.focused && this.#input.value.length) {
let result = this.#input.view?.getResultAtIndex(0);
if (
result &&
(result.type == lazy.UrlbarUtils.RESULT_TYPE.URL ||
result.type == lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH)
) {
// If the user has typed a url then indicate that ENTER will visit
// that address.
icon = SearchModeSwitcher.ICON_GLOBE;
}
}
}
this.#button.setAttribute("iconsrc", icon);
if (label) {
this.#input.document.l10n.setAttributes(
this.#button,
"urlbar-searchmode-button3",
{ engine: label }
);
} else {
this.#input.document.l10n.setAttributes(
this.#button,
"urlbar-searchmode-button-no-engine2"
);
}
let labelEl = this.#input.querySelector(".searchmode-switcher-title");
if (!inSearchMode) {
labelEl.replaceChildren();
} else {
labelEl.textContent = label;
}
if (
!lazy.UrlbarPrefs.get("keyword.enabled") &&
this.#input.sapName != "searchbar"
) {
this.#input.document.l10n.setAttributes(
this.#button,
"urlbar-searchmode-no-keyword2"
);
}
}
async #getSearchModeLabel(source) {
let mode = lazy.UrlbarUtils.LOCAL_SEARCH_MODES.find(
m => m.source == source
);
let [str] = await lazy.SearchModeSwitcherL10n.formatMessages([
{ id: mode.uiLabel },
]);
return str.value;
}
async #getDisplayedEngineDetails(searchMode = null) {
if (!lazy.SearchService.hasSuccessfullyInitialized) {
return { label: null, icon: SearchModeSwitcher.ICON_GLASS };
}
if (!searchMode || searchMode.engineName) {
let engine = searchMode
? lazy.UrlbarSearchUtils.getEngineByName(searchMode.engineName)
: lazy.UrlbarSearchUtils.getDefaultEngine(
lazy.PrivateBrowsingUtils.isWindowPrivate(this.#input.window)
);
if (!engine) {
return { label: null, icon: SearchModeSwitcher.ICON_GLASS };
}
let icon = (await engine.getIconURL()) ?? SearchModeSwitcher.ICON_GLASS;
return { label: engine.name, icon };
}
let mode = lazy.UrlbarUtils.LOCAL_SEARCH_MODES.find(
m => m.source == searchMode.source
);
return {
label: await this.#getSearchModeLabel(searchMode.source),
icon: mode.icon,
};
}
/**
* Builds the popup and dispatches a rebuild event on the popup when finished.
*/
async #buildSearchModeList() {
for (let item of this.#panelList.querySelectorAll("panel-item")) {
item.remove();
}
let browser = this.#input.window.gBrowser;
let installedEngineSeparator = this.#panelList.querySelector(
".searchmode-switcher-panel-installed-engine-separator"
);
let footerSeparator = this.#panelList.querySelector(
".searchmode-switcher-panel-footer-separator"
);
await this.#populateEngines();
for (let engine of this.#engines) {
if (engine.source) {
footerSeparator.before(await this.#buildLocalSearchButton(engine));
} else if (engine.name) {
let menuitem = await this.#buildEngineSearchButton(engine);
installedEngineSeparator.before(menuitem);
}
}
this.#buildSettingsButton();
// Add engines that can be installed.
let openSearchEngines = lazy.OpenSearchManager.getEngines(
browser.selectedBrowser
);
openSearchEngines = openSearchEngines.slice(
0,
SearchModeSwitcher.MAX_OPENSEARCH_ENGINES
);
for (let engine of openSearchEngines) {
let menuitem = this.#createButton(engine.icon);
this.#input.document.l10n.setAttributes(
menuitem,
"urlbar-searchmode-popup-add-engine",
{
engineName: engine.title,
}
);
menuitem.classList.add("searchmode-switcher-addEngine");
menuitem.dataset.action = "installopensearch";
// This attribute is for testing.
menuitem.dataset.engineName = engine.title;
this.#addCommandListeners(menuitem);
// @ts-expect-error
menuitem._engine = engine;
footerSeparator.after(menuitem);
}
if (this.#panelList.wasOpenedByKeyboard) {
// Focus will not be on first item anymore because new
// items were added after the panel list was shown.
this.#panelList.focusWalker.currentNode = this.#panelList;
this.#panelList.focusWalker.nextNode();
}
this.#panelList.dispatchEvent(new Event("rebuild"));
}
/**
* @param {MouseEvent|KeyboardEvent} event
* @returns {string}
* Where the search engine result page should be opened.
*/
#whereToOpenSerp(event) {
let where = lazy.BrowserUtils.whereToOpenLink(event, false, true);
// Usually, shift means "open in new window", but in the search
// mode switcher it means "open SERP even if urlbar is empty",
// so we just return tab, tabshifted or current but never window.
if (where.startsWith("tab")) {
return where;
}
return "current";
}
/**
* Ideally the settings button would be in the markup because it never
* changes but that causes an an assertion error in BindingUtils.cpp.
*/
#buildSettingsButton() {
// Icon is set via css based on the class.
let menuitem = this.#createButton(undefined);
menuitem.classList.add("searchmode-switcher-panel-search-settings-button");
menuitem.dataset.action = "openpreferences";
this.#input.document.l10n.setAttributes(
menuitem,
Services.prefs.getBoolPref("browser.nova.enabled", false)
? "urlbar-searchmode-popup-settings-panelitem"
: "urlbar-searchmode-popup-search-settings-panelitem"
);
this.#addCommandListeners(menuitem);
this.#panelList.appendChild(menuitem);
}
async #buildEngineSearchButton(engine) {
let icon = await engine.getIconURL();
let menuitem = this.#createButton(icon, engine.name);
menuitem.classList.add("searchmode-switcher-installed");
menuitem.setAttribute("label", engine.name);
menuitem.setAttribute("title", engine.name);
menuitem.setAttribute("closemenu", "none");
if (engine.isNew() && engine.isAppProvided) {
menuitem.setAttribute("badge-type", "new");
}
menuitem.dataset.engineId = engine.id;
// This attribute is for testing.
menuitem.dataset.engineName = engine.name;
menuitem.dataset.action = "searchmode";
this.#addCommandListeners(menuitem);
return menuitem;
}
async #buildLocalSearchButton(mode) {
let sourceName = lazy.UrlbarUtils.getResultSourceName(mode.source);
let { icon } = await this.#getDisplayedEngineDetails(mode);
let menuitem = this.#createButton(icon);
menuitem.classList.add(
"searchmode-switcher-local",
`search-button-${sourceName}`
);
menuitem.dataset.action = "localsearchmode";
menuitem.dataset.restrict = mode.restrict;
this.#addCommandListeners(menuitem);
this.#input.document.l10n.setAttributes(
menuitem,
`urlbar-searchmode-${sourceName}2`
);
return menuitem;
}
/**
* Enables a local search mode based on the restrict token.
*
* @param {string} restrict
* The restrict token
*/
#localSearch(restrict) {
this.#input.search(restrict + " " + this.#getSearchString(), {
searchModeEntry: "searchbutton",
});
if (this.#input.sapName == "urlbar") {
Glean.urlbarUnifiedsearchbutton.picked.local_search.add(1);
}
}
/**
* Enters searchmode in the urlbar or opens a SERP, depending
* on whether the urlbar is empty.
* Shift can be used to force the SERP.
* Also handles closing the panel.
*
* @param {SearchEngine} searchEngine
* The engine to search with.
* @param {KeyboardEvent|MouseEvent} event
* The event that triggered the search.
*/
#remoteSearch(searchEngine, event) {
let whereToOpenSerp = this.#whereToOpenSerp(event);
let searchString = this.#getSearchString();
if (!event.shiftKey && whereToOpenSerp == "current") {
// Go into searchmode.
this.closePanel();
this.#input.search(searchString, {
searchEngine,
searchModeEntry: "searchbutton",
});
} else {
// Go directly to SERP.
if (whereToOpenSerp == "current") {
this.closePanel();
}
this.#input.openSearchEnginePage(searchString, {
event,
searchEngine,
where: whereToOpenSerp,
inBackground: true,
});
}
if (this.#input.sapName == "urlbar") {
// TODO do we really need to distinguish here?
Glean.urlbarUnifiedsearchbutton.picked[
searchEngine.isConfigEngine ? "builtin_search" : "addon_search"
].add(1);
}
}
/**
* The string to use when starting a search via the search mode switcher.
*
* @returns {string}
*/
#getSearchString() {
if (this.#input.getAttribute("pageproxystate") == "valid") {
return "";
}
return this.#input.value;
}
/**
* Returns whether the event's target is an item
* in the search mode switcher popup.
*
* @param {Event|null|undefined} event
* @returns {boolean}
*/
eventTargetIsPanelItem(event) {
let target = event?.target;
if (!target || !("classList" in target)) {
return false;
}
let classList = /** @type {DOMTokenList}*/ (target.classList);
return (
classList.contains("searchmode-switcher-addEngine") ||
classList.contains("searchmode-switcher-installed") ||
classList.contains("searchmode-switcher-local")
);
}
#enableObservers() {
Services.obs.addObserver(this, "browser-search-engine-modified", true);
this.#button.addEventListener("focus", this);
this.#button.addEventListener("keydown", this);
this.#panelList.addEventListener("showing", this);
this.#panelList.addEventListener("hidden", this);
this.#closebutton.addEventListener("click", this);
this.#closebutton.addEventListener("mousedown", this);
}
#disableObservers() {
Services.obs.removeObserver(this, "browser-search-engine-modified");
this.#button.removeEventListener("focus", this);
this.#button.removeEventListener("keydown", this);
this.#panelList.removeEventListener("showing", this);
this.#panelList.removeEventListener("hidden", this);
this.#closebutton.removeEventListener("click", this);
this.#closebutton.removeEventListener("mousedown", this);
}
/**
* @param {string|undefined} icon
* The icon. Pass undefined to use the default engine icon.
* @param {string} [label]
* The label. Can be omitted when setting it via fluent.
*/
#createButton(icon, label) {
let panelitem = /**@type {PanelItem} */ (
this.#input.document.createElementNS(
"panel-item"
)
);
if (label) {
panelitem.textContent = label;
}
panelitem.style.setProperty(
"--icon-url",
`url(${icon ?? DEFAULT_ENGINE_ICON})`
);
return panelitem;
}
/**
* Installs open search engine and enters search mode.
*
* @param {OpenSearchData} engine
* The engine to install.
*/
async #installOpenSearchEngine(engine) {
let topic = "browser-search-engine-modified";
/** @type {(subject: {wrappedJSObject: SearchEngine}) => void} */
let observer = subject => {
Services.obs.removeObserver(observer, topic);
this.#input.search(this.#getSearchString(), {
searchEngine: subject.wrappedJSObject,
searchModeEntry: "searchbutton",
});
};
Services.obs.addObserver(observer, topic);
if (this.#input.sapName == "urlbar") {
Glean.urlbarUnifiedsearchbutton.picked.addon_search.add(1);
}
await lazy.SearchUIUtils.addOpenSearchEngine(
engine.uri,
engine.icon,
this.#input.window.gBrowser.selectedBrowser.browsingContext
);
}
}