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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs",
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"touchBarUpdater",
"@mozilla.org/widget/touchbarupdater;1",
"nsITouchBarUpdater"
);
// For accessing TouchBarHelper methods from static contexts in this file.
XPCOMUtils.defineLazyServiceGetter(
lazy,
"touchBarHelper",
"@mozilla.org/widget/touchbarhelper;1",
"nsITouchBarHelper"
);
/**
* Executes a XUL command on the top window. Called by the callbacks in each
* TouchBarInput.
*
* @param {string} commandName
* A XUL command.
*/
function execCommand(commandName) {
if (!TouchBarHelper.window) {
return;
}
let command = TouchBarHelper.window.document.getElementById(commandName);
if (command) {
command.doCommand();
}
}
/**
* Static helper function to convert a hexadecimal string to its integer
* value. Used to convert colours to a format accepted by Apple's NSColor code.
*
* @param {string} hexString
* A hexadecimal string, optionally beginning with '#'.
*/
function hexToInt(hexString) {
if (!hexString) {
return null;
}
if (hexString.charAt(0) == "#") {
hexString = hexString.slice(1);
}
let val = parseInt(hexString, 16);
return isNaN(val) ? null : val;
}
const kInputTypes = {
BUTTON: "button",
LABEL: "label",
MAIN_BUTTON: "mainButton",
POPOVER: "popover",
SCROLLVIEW: "scrollView",
SCRUBBER: "scrubber",
};
/**
* An object containing all implemented TouchBarInput objects.
*/
var gBuiltInInputs = {
Back: {
title: "back",
image: "chrome://browser/skin/back.svg",
type: kInputTypes.BUTTON,
callback: () => {
lazy.touchBarHelper.unfocusUrlbar();
execCommand("Browser:Back");
},
},
Forward: {
title: "forward",
image: "chrome://browser/skin/forward.svg",
type: kInputTypes.BUTTON,
callback: () => {
lazy.touchBarHelper.unfocusUrlbar();
execCommand("Browser:Forward");
},
},
Reload: {
title: "reload",
image: "chrome://global/skin/icons/reload.svg",
type: kInputTypes.BUTTON,
callback: () => {
lazy.touchBarHelper.unfocusUrlbar();
execCommand("Browser:Reload");
},
},
Home: {
title: "home",
image: "chrome://browser/skin/home.svg",
type: kInputTypes.BUTTON,
callback: () => {
let win = lazy.BrowserWindowTracker.getTopWindow();
win.BrowserCommands.home();
},
},
Fullscreen: {
title: "fullscreen",
image: "chrome://browser/skin/fullscreen.svg",
type: kInputTypes.BUTTON,
callback: () => execCommand("View:FullScreen"),
},
Find: {
title: "find",
image: "chrome://global/skin/icons/search-glass.svg",
type: kInputTypes.BUTTON,
callback: () => execCommand("cmd_find"),
},
NewTab: {
title: "new-tab",
image: "chrome://global/skin/icons/plus.svg",
type: kInputTypes.BUTTON,
callback: () => execCommand("cmd_newNavigatorTabNoEvent"),
},
Sidebar: {
title: "open-sidebar",
image: "chrome://browser/skin/sidebars.svg",
type: kInputTypes.BUTTON,
callback: () => {
let win = lazy.BrowserWindowTracker.getTopWindow();
win.SidebarController.toggle();
},
},
AddBookmark: {
title: "add-bookmark",
image: "chrome://browser/skin/bookmark-hollow.svg",
type: kInputTypes.BUTTON,
callback: () => execCommand("Browser:AddBookmarkAs"),
},
ReaderView: {
title: "reader-view",
image: "chrome://browser/skin/reader-mode.svg",
type: kInputTypes.BUTTON,
callback: () => execCommand("View:ReaderView"),
disabled: true, // Updated when the page is found to be Reader View-able.
},
OpenLocation: {
key: "open-location",
title: "open-location",
image: "chrome://global/skin/icons/search-glass.svg",
type: kInputTypes.MAIN_BUTTON,
callback: () => lazy.touchBarHelper.toggleFocusUrlbar(),
},
// This is a special-case `type: kInputTypes.SCRUBBER` element.
// Scrubbers are not yet generally implemented.
Share: {
title: "share",
image: "chrome://browser/skin/share.svg",
type: kInputTypes.SCRUBBER,
callback: () => execCommand("cmd_share"),
},
SearchPopover: {
title: "search-popover",
image: "chrome://global/skin/icons/search-glass.svg",
type: kInputTypes.POPOVER,
children: {
SearchScrollViewLabel: {
title: "search-search-in",
type: kInputTypes.LABEL,
},
SearchScrollView: {
key: "search-scrollview",
type: kInputTypes.SCROLLVIEW,
children: {
Bookmarks: {
title: "search-bookmarks",
type: kInputTypes.BUTTON,
callback: () =>
lazy.touchBarHelper.insertRestrictionInUrlbar(
lazy.UrlbarTokenizer.RESTRICT.BOOKMARK
),
},
OpenTabs: {
title: "search-opentabs",
type: kInputTypes.BUTTON,
callback: () =>
lazy.touchBarHelper.insertRestrictionInUrlbar(
lazy.UrlbarTokenizer.RESTRICT.OPENPAGE
),
},
History: {
title: "search-history",
type: kInputTypes.BUTTON,
callback: () =>
lazy.touchBarHelper.insertRestrictionInUrlbar(
lazy.UrlbarTokenizer.RESTRICT.HISTORY
),
},
Tags: {
title: "search-tags",
type: kInputTypes.BUTTON,
callback: () =>
lazy.touchBarHelper.insertRestrictionInUrlbar(
lazy.UrlbarTokenizer.RESTRICT.TAG
),
},
},
},
},
},
};
// We create a new flat object to cache strings. Since gBuiltInInputs is a
// tree, caching/retrieval of localized strings would otherwise require tree
// traversal.
var localizedStrings = {};
const kHelperObservers = new Set([
"bookmark-icon-updated",
"fullscreen-painted",
"reader-mode-available",
"touchbar-location-change",
"quit-application",
"intl:app-locales-changed",
"urlbar-focus",
"urlbar-blur",
]);
/**
* JS-implemented TouchBarHelper class.
* Provides services to the Mac Touch Bar.
*/
export class TouchBarHelper {
constructor() {
for (let topic of kHelperObservers) {
Services.obs.addObserver(this, topic);
}
// We cache our search popover since otherwise it is frequently
// created/destroyed for the urlbar-focus/blur events.
this._searchPopover = this.getTouchBarInput("SearchPopover");
this._inputsNotUpdated = new Set();
}
destructor() {
this._searchPopover = null;
for (let topic of kHelperObservers) {
Services.obs.removeObserver(this, topic);
}
}
get activeTitle() {
if (!TouchBarHelper.window) {
return "";
}
let tabbrowser = TouchBarHelper.window.ownerGlobal.gBrowser;
let activeTitle;
if (tabbrowser) {
activeTitle = tabbrowser.selectedBrowser.contentTitle;
}
return activeTitle;
}
get allItems() {
let layoutItems = Cc["@mozilla.org/array;1"].createInstance(
Ci.nsIMutableArray
);
let window = TouchBarHelper.window;
if (
!window ||
!window.isChromeWindow ||
window.document.documentElement.getAttribute("windowtype") !=
"navigator:browser"
) {
return layoutItems;
}
// Every input must be updated at least once so that all assets (titles,
// icons) are loaded. We keep track of which inputs haven't updated and
// run an update on them ASAP.
this._inputsNotUpdated.clear();
for (let inputName of Object.keys(gBuiltInInputs)) {
let input = this.getTouchBarInput(inputName);
if (!input) {
continue;
}
this._inputsNotUpdated.add(inputName);
layoutItems.appendElement(input);
}
return layoutItems;
}
static get window() {
return lazy.BrowserWindowTracker.getTopWindow();
}
get document() {
if (!TouchBarHelper.window) {
return null;
}
return TouchBarHelper.window.document;
}
get isUrlbarFocused() {
if (!TouchBarHelper.window || !TouchBarHelper.window.gURLBar) {
return false;
}
return TouchBarHelper.window.gURLBar.focused;
}
toggleFocusUrlbar() {
if (this.isUrlbarFocused) {
this.unfocusUrlbar();
} else {
execCommand("Browser:OpenLocation");
}
}
unfocusUrlbar() {
if (!this.isUrlbarFocused) {
return;
}
TouchBarHelper.window.gURLBar.blur();
}
static get baseWindow() {
return TouchBarHelper.window
? TouchBarHelper.window.docShell.treeOwner.QueryInterface(
Ci.nsIBaseWindow
)
: null;
}
getTouchBarInput(inputName) {
if (inputName == "SearchPopover" && this._searchPopover) {
return this._searchPopover;
}
if (!inputName || !gBuiltInInputs.hasOwnProperty(inputName)) {
return null;
}
let inputData = gBuiltInInputs[inputName];
let item = new TouchBarInput(inputData);
// Skip localization if there is already a cached localized title or if
// no title is needed.
if (
!inputData.hasOwnProperty("title") ||
localizedStrings[inputData.title]
) {
return item;
}
// Async l10n fills in the localized input labels after the initial load.
this._l10n.formatValue(inputData.title).then(result => {
item.title = result;
localizedStrings[inputData.title] = result; // Cache result.
// Checking TouchBarHelper.window since this callback can fire after all windows are closed.
if (TouchBarHelper.window) {
if (this._inputsNotUpdated) {
this._inputsNotUpdated.delete(inputName);
}
lazy.touchBarUpdater.updateTouchBarInputs(TouchBarHelper.baseWindow, [
item,
]);
}
});
return item;
}
/**
* Fetches a specific Touch Bar Input by name and updates it on the Touch Bar.
*
* @param {...*} inputNames
* A key/keys to a value/values in the gBuiltInInputs object in this file.
*/
_updateTouchBarInputs(...inputNames) {
if (!TouchBarHelper.window || !inputNames.length) {
return;
}
let inputs = [];
for (let inputName of new Set([...inputNames, ...this._inputsNotUpdated])) {
let input = this.getTouchBarInput(inputName);
if (!input) {
continue;
}
this._inputsNotUpdated.delete(inputName);
inputs.push(input);
}
lazy.touchBarUpdater.updateTouchBarInputs(
TouchBarHelper.baseWindow,
inputs
);
}
/**
* Inserts a restriction token into the Urlbar ahead of the current typed
* search term.
*
* @param {string} restrictionToken
* The restriction token to be inserted into the Urlbar. Preferably
* sourced from UrlbarTokenizer.RESTRICT.
*/
insertRestrictionInUrlbar(restrictionToken) {
if (!TouchBarHelper.window) {
return;
}
let searchString = "";
if (
TouchBarHelper.window.gURLBar.getAttribute("pageproxystate") != "valid"
) {
searchString = TouchBarHelper.window.gURLBar.lastSearchString.trimStart();
if (
Object.values(lazy.UrlbarTokenizer.RESTRICT).includes(searchString[0])
) {
searchString = searchString.substring(1).trimStart();
}
}
TouchBarHelper.window.gURLBar.search(
`${restrictionToken} ${searchString}`,
{ searchModeEntry: "touchbar" }
);
}
observe(subject, topic, data) {
switch (topic) {
case "touchbar-location-change":
let updatedInputs = ["Back", "Forward"];
gBuiltInInputs.Back.disabled =
!TouchBarHelper.window.gBrowser.canGoBack;
gBuiltInInputs.Forward.disabled =
!TouchBarHelper.window.gBrowser.canGoForward;
if (subject.QueryInterface(Ci.nsIWebProgress)?.isTopLevel) {
this.activeUrl = data;
// ReaderView button is disabled on every toplevel location change
// since Reader View must determine if the new page can be Reader
// Viewed.
updatedInputs.push("ReaderView");
gBuiltInInputs.ReaderView.disabled = !data.startsWith("about:reader");
}
this._updateTouchBarInputs(...updatedInputs);
break;
case "fullscreen-painted":
if (TouchBarHelper.window.document.fullscreenElement) {
gBuiltInInputs.OpenLocation.title = "touchbar-fullscreen-exit";
gBuiltInInputs.OpenLocation.image =
"chrome://browser/skin/fullscreen-exit.svg";
gBuiltInInputs.OpenLocation.callback = () => {
TouchBarHelper.window.windowUtils.exitFullscreen();
};
} else {
gBuiltInInputs.OpenLocation.title = "open-location";
gBuiltInInputs.OpenLocation.image =
"chrome://global/skin/icons/search-glass.svg";
gBuiltInInputs.OpenLocation.callback = () =>
execCommand("Browser:OpenLocation", "OpenLocation");
}
this._updateTouchBarInputs("OpenLocation");
break;
case "bookmark-icon-updated":
gBuiltInInputs.AddBookmark.image =
data == "starred"
? "chrome://browser/skin/bookmark.svg"
: "chrome://browser/skin/bookmark-hollow.svg";
this._updateTouchBarInputs("AddBookmark");
break;
case "reader-mode-available":
gBuiltInInputs.ReaderView.disabled = false;
this._updateTouchBarInputs("ReaderView");
break;
case "urlbar-focus":
if (!this._searchPopover) {
this._searchPopover = this.getTouchBarInput("SearchPopover");
}
lazy.touchBarUpdater.showPopover(
TouchBarHelper.baseWindow,
this._searchPopover,
true
);
break;
case "urlbar-blur":
if (!this._searchPopover) {
this._searchPopover = this.getTouchBarInput("SearchPopover");
}
lazy.touchBarUpdater.showPopover(
TouchBarHelper.baseWindow,
this._searchPopover,
false
);
break;
case "intl:app-locales-changed":
this._searchPopover = null;
localizedStrings = {};
// This event can fire before this._l10n updates to switch languages,
// so all the new translations are in the old language. To avoid this,
// we need to reinitialize this._l10n.
this._l10n = new Localization(["browser/touchbar/touchbar.ftl"]);
helperProto._l10n = this._l10n;
this._updateTouchBarInputs(...Object.keys(gBuiltInInputs));
break;
case "quit-application":
this.destructor();
break;
}
}
}
const helperProto = TouchBarHelper.prototype;
helperProto.QueryInterface = ChromeUtils.generateQI(["nsITouchBarHelper"]);
helperProto._l10n = new Localization(["browser/touchbar/touchbar.ftl"]);
/**
* A representation of a Touch Bar input.
*
* @param {object} input
* An object representing a Touch Bar Input.
* Contains listed properties.
* @param {string} input.title
* The lookup key for the button's localized text title.
* @param {string} input.image
* A URL pointing to an SVG internal to Firefox.
* @param {string} input.type
* The type of Touch Bar input represented by the object.
* Must be a value from kInputTypes.
* @param {Function} input.callback
* A callback invoked when a touchbar item is touched.
* @param {string} [input.color]
* A string in hex format specifying the button's background color.
* If omitted, the default background color is used.
* @param {bool} [input.disabled]
* If `true`, the Touch Bar input is greyed out and inoperable.
* @param {Array} [input.children]
* An array of input objects that will be displayed as children of
* this input. Available only for types KInputTypes.POPOVER and
* kInputTypes.SCROLLVIEW.
*/
export class TouchBarInput {
constructor(input) {
this._key = input.key || input.title;
this._title = localizedStrings[input.title] || "";
this._image = input.image;
this._type = input.type;
this._callback = input.callback;
this._color = hexToInt(input.color);
this._disabled = input.hasOwnProperty("disabled") ? input.disabled : false;
if (input.children) {
this._children = [];
let toLocalize = [];
for (let childData of Object.values(input.children)) {
let initializedChild = new TouchBarInput(childData);
if (!initializedChild) {
continue;
}
// Children's types are prepended by the parent's type. This is so we
// can uniquely identify a child input from a standalone input with
// the same name. (e.g. a button called "back" in a popover would be a
// "popover-button.back" vs. a "button.back").
initializedChild.type = input.type + "-" + initializedChild.type;
this._children.push(initializedChild);
// Skip l10n for inputs without a title or those already localized.
if (childData.title && !localizedStrings[childData.title]) {
toLocalize.push(initializedChild);
}
}
this._localizeChildren(toLocalize);
}
}
get key() {
return this._key;
}
get title() {
return this._title;
}
set title(title) {
this._title = title;
}
get image() {
return this._image ? Services.io.newURI(this._image) : null;
}
set image(image) {
this._image = image;
}
get type() {
return this._type == "" ? "button" : this._type;
}
set type(type) {
this._type = type;
}
get callback() {
return this._callback;
}
set callback(callback) {
this._callback = callback;
}
get color() {
return this._color;
}
set color(color) {
this._color = this.hexToInt(color);
}
get disabled() {
return this._disabled || false;
}
set disabled(disabled) {
this._disabled = disabled;
}
get children() {
if (!this._children) {
return null;
}
let children = Cc["@mozilla.org/array;1"].createInstance(
Ci.nsIMutableArray
);
for (let child of this._children) {
children.appendElement(child);
}
return children;
}
/**
* Apply Fluent l10n to child inputs.
*
* @param {Array} children
* An array of initialized TouchBarInputs.
*/
async _localizeChildren(children) {
if (!children || !children.length) {
return;
}
let titles = await helperProto._l10n.formatValues(
children.map(child => ({ id: child.key }))
);
// In the TouchBarInput constuctor, we filtered so children contains only
// those inputs with titles to be localized. We can be confident that the
// results in titles match up with the inputs to be localized.
children.forEach(function (child, index) {
child.title = titles[index];
localizedStrings[child.key] = child.title;
});
lazy.touchBarUpdater.updateTouchBarInputs(
TouchBarHelper.baseWindow,
children
);
}
}
TouchBarInput.prototype.QueryInterface = ChromeUtils.generateQI([
"nsITouchBarInput",
]);