summaryrefslogtreecommitdiffstats
path: root/toolkit/modules/LightweightThemeConsumer.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/modules/LightweightThemeConsumer.sys.mjs')
-rw-r--r--toolkit/modules/LightweightThemeConsumer.sys.mjs719
1 files changed, 719 insertions, 0 deletions
diff --git a/toolkit/modules/LightweightThemeConsumer.sys.mjs b/toolkit/modules/LightweightThemeConsumer.sys.mjs
new file mode 100644
index 0000000000..cf388eb3d3
--- /dev/null
+++ b/toolkit/modules/LightweightThemeConsumer.sys.mjs
@@ -0,0 +1,719 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+// Get the theme variables from the app resource directory.
+// This allows per-app variables.
+ChromeUtils.defineESModuleGetters(lazy, {
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ ThemeContentPropertyList: "resource:///modules/ThemeVariableMap.sys.mjs",
+ ThemeVariableMap: "resource:///modules/ThemeVariableMap.sys.mjs",
+});
+
+// Whether the content and chrome areas should always use the same color
+// scheme (unless user-overridden). Thunderbird uses this.
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "BROWSER_THEME_UNIFIED_COLOR_SCHEME",
+ "browser.theme.unified-color-scheme",
+ false
+);
+
+const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+
+const toolkitVariableMap = [
+ [
+ "--lwt-accent-color",
+ {
+ lwtProperty: "accentcolor",
+ processColor(rgbaChannels, element) {
+ if (!rgbaChannels || rgbaChannels.a == 0) {
+ return "white";
+ }
+ // Remove the alpha channel
+ const { r, g, b } = rgbaChannels;
+ return `rgb(${r}, ${g}, ${b})`;
+ },
+ },
+ ],
+ [
+ "--lwt-text-color",
+ {
+ lwtProperty: "textcolor",
+ processColor(rgbaChannels, element) {
+ if (!rgbaChannels) {
+ rgbaChannels = { r: 0, g: 0, b: 0 };
+ }
+ // Remove the alpha channel
+ const { r, g, b } = rgbaChannels;
+ return `rgba(${r}, ${g}, ${b})`;
+ },
+ },
+ ],
+ [
+ "--arrowpanel-background",
+ {
+ lwtProperty: "popup",
+ },
+ ],
+ [
+ "--arrowpanel-color",
+ {
+ lwtProperty: "popup_text",
+ },
+ ],
+ [
+ "--arrowpanel-border-color",
+ {
+ lwtProperty: "popup_border",
+ },
+ ],
+ [
+ "--toolbar-field-background-color",
+ {
+ lwtProperty: "toolbar_field",
+ fallbackColor: "rgba(255, 255, 255, 0.8)",
+ },
+ ],
+ [
+ "--toolbar-bgcolor",
+ {
+ lwtProperty: "toolbarColor",
+ },
+ ],
+ [
+ "--toolbar-color",
+ {
+ lwtProperty: "toolbar_text",
+ },
+ ],
+ [
+ "--toolbar-field-color",
+ {
+ lwtProperty: "toolbar_field_text",
+ fallbackColor: "black",
+ },
+ ],
+ [
+ "--toolbar-field-border-color",
+ {
+ lwtProperty: "toolbar_field_border",
+ fallbackColor: "transparent",
+ },
+ ],
+ [
+ "--toolbar-field-focus-background-color",
+ {
+ lwtProperty: "toolbar_field_focus",
+ fallbackProperty: "toolbar_field",
+ fallbackColor: "white",
+ processColor(rgbaChannels, element, propertyOverrides) {
+ if (!rgbaChannels) {
+ return null;
+ }
+ // Ensure minimum opacity as this is used behind address bar results.
+ const min_opacity = 0.9;
+ let { r, g, b, a } = rgbaChannels;
+ if (a < min_opacity) {
+ propertyOverrides.set(
+ "toolbar_field_text_focus",
+ _isColorDark(r, g, b) ? "white" : "black"
+ );
+ return `rgba(${r}, ${g}, ${b}, ${min_opacity})`;
+ }
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+ },
+ },
+ ],
+ [
+ "--toolbar-field-focus-color",
+ {
+ lwtProperty: "toolbar_field_text_focus",
+ fallbackProperty: "toolbar_field_text",
+ fallbackColor: "black",
+ },
+ ],
+ [
+ "--toolbar-field-focus-border-color",
+ {
+ lwtProperty: "toolbar_field_border_focus",
+ },
+ ],
+ [
+ "--lwt-toolbar-field-highlight",
+ {
+ lwtProperty: "toolbar_field_highlight",
+ processColor(rgbaChannels, element) {
+ if (!rgbaChannels) {
+ return null;
+ }
+ const { r, g, b, a } = rgbaChannels;
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+ },
+ },
+ ],
+ [
+ "--lwt-toolbar-field-highlight-text",
+ {
+ lwtProperty: "toolbar_field_highlight_text",
+ },
+ ],
+ // The following 3 are given to the new tab page by contentTheme.js. They are
+ // also exposed here, in the browser chrome, so popups anchored on top of the
+ // new tab page can use them to avoid clashing with the new tab page content.
+ [
+ "--newtab-background-color",
+ {
+ lwtProperty: "ntp_background",
+ processColor(rgbaChannels) {
+ if (!rgbaChannels) {
+ return null;
+ }
+ const { r, g, b } = rgbaChannels;
+ // Drop alpha channel
+ return `rgb(${r}, ${g}, ${b})`;
+ },
+ },
+ ],
+ [
+ "--newtab-background-color-secondary",
+ { lwtProperty: "ntp_card_background" },
+ ],
+ ["--newtab-text-primary-color", { lwtProperty: "ntp_text" }],
+];
+
+export function LightweightThemeConsumer(aDocument) {
+ this._doc = aDocument;
+ this._win = aDocument.defaultView;
+ this._winId = this._win.docShell.outerWindowID;
+
+ Services.obs.addObserver(this, "lightweight-theme-styling-update");
+
+ this.darkThemeMediaQuery = this._win.matchMedia("(-moz-system-dark-theme)");
+ this.darkThemeMediaQuery.addListener(this);
+
+ const { LightweightThemeManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/LightweightThemeManager.sys.mjs"
+ );
+ this._update(LightweightThemeManager.themeData);
+
+ this._win.addEventListener("unload", this, { once: true });
+}
+
+LightweightThemeConsumer.prototype = {
+ _lastData: null,
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "lightweight-theme-styling-update") {
+ return;
+ }
+
+ let data = aSubject.wrappedJSObject;
+ if (data.window && data.window !== this._winId) {
+ return;
+ }
+
+ this._update(data);
+ },
+
+ handleEvent(aEvent) {
+ if (aEvent.target == this.darkThemeMediaQuery) {
+ this._update(this._lastData);
+ return;
+ }
+
+ switch (aEvent.type) {
+ case "unload":
+ Services.obs.removeObserver(this, "lightweight-theme-styling-update");
+ Services.ppmm.sharedData.delete(`theme/${this._winId}`);
+ this._win = this._doc = null;
+ if (this.darkThemeMediaQuery) {
+ this.darkThemeMediaQuery.removeListener(this);
+ this.darkThemeMediaQuery = null;
+ }
+ break;
+ }
+ },
+
+ _update(themeData) {
+ this._lastData = themeData;
+
+ const hasDarkTheme = !!themeData.darkTheme;
+ let updateGlobalThemeData = true;
+ let useDarkTheme = (() => {
+ if (!hasDarkTheme) {
+ return false;
+ }
+
+ if (this.darkThemeMediaQuery?.matches) {
+ return themeData.darkTheme.id != DEFAULT_THEME_ID;
+ }
+
+ // If enabled, apply the dark theme variant to private browsing windows.
+ if (
+ !lazy.NimbusFeatures.majorRelease2022.getVariable(
+ "feltPrivacyPBMDarkTheme"
+ ) ||
+ !lazy.PrivateBrowsingUtils.isWindowPrivate(this._win) ||
+ lazy.PrivateBrowsingUtils.permanentPrivateBrowsing
+ ) {
+ return false;
+ }
+ // When applying the dark theme for a PBM window we need to skip calling
+ // _determineToolbarAndContentTheme, because it applies the color scheme
+ // globally for all windows. Skipping this method also means we don't
+ // switch the content theme to dark.
+ //
+ // TODO: On Linux we most likely need to apply the dark theme, but on
+ // Windows and macOS we should be able to render light and dark windows
+ // with the default theme at the same time.
+ updateGlobalThemeData = false;
+ return true;
+ })();
+
+ // If this is a per-window dark theme, set the color scheme override so
+ // child BrowsingContexts, such as embedded prompts, get themed
+ // appropriately.
+ // If not, reset the color scheme override field. This is required to reset
+ // the color scheme on theme switch.
+ if (this._win.browsingContext == this._win.browsingContext.top) {
+ if (useDarkTheme && !updateGlobalThemeData) {
+ this._win.browsingContext.prefersColorSchemeOverride = "dark";
+ } else {
+ this._win.browsingContext.prefersColorSchemeOverride = "none";
+ }
+ }
+
+ let theme = useDarkTheme ? themeData.darkTheme : themeData.theme;
+ if (!theme) {
+ theme = { id: DEFAULT_THEME_ID };
+ }
+
+ let active = (this._active = Object.keys(theme).length);
+
+ let root = this._doc.documentElement;
+
+ if (active && theme.headerURL) {
+ root.setAttribute("lwtheme-image", "true");
+ } else {
+ root.removeAttribute("lwtheme-image");
+ }
+
+ let hasTheme = theme.id != DEFAULT_THEME_ID || useDarkTheme;
+
+ this._setExperiment(active, themeData.experiment, theme.experimental);
+ _setImage(this._win, root, active, "--lwt-header-image", theme.headerURL);
+ _setImage(
+ this._win,
+ root,
+ active,
+ "--lwt-additional-images",
+ theme.additionalBackgrounds
+ );
+ _setProperties(root, active, theme, hasTheme);
+
+ if (hasTheme) {
+ if (updateGlobalThemeData) {
+ _determineToolbarAndContentTheme(
+ this._doc,
+ theme,
+ hasDarkTheme,
+ useDarkTheme
+ );
+ }
+ root.setAttribute("lwtheme", "true");
+ } else {
+ _determineToolbarAndContentTheme(this._doc, null);
+ root.removeAttribute("lwtheme");
+ }
+
+ _setDarkModeAttributes(this._doc, root, theme._processedColors, hasTheme);
+
+ let contentThemeData = _getContentProperties(this._doc, active, theme);
+ Services.ppmm.sharedData.set(`theme/${this._winId}`, contentThemeData);
+ // We flush sharedData because contentThemeData can be responsible for
+ // painting large background surfaces. If this data isn't delivered to the
+ // content process before about:home is painted, we will paint a default
+ // background and then replace it when sharedData syncs, causing flashing.
+ Services.ppmm.sharedData.flush();
+
+ this._win.dispatchEvent(new CustomEvent("windowlwthemeupdate"));
+ },
+
+ _setExperiment(active, experiment, properties) {
+ const root = this._doc.documentElement;
+ if (this._lastExperimentData) {
+ const { stylesheet, usedVariables } = this._lastExperimentData;
+ if (stylesheet) {
+ stylesheet.remove();
+ }
+ if (usedVariables) {
+ for (const [variable] of usedVariables) {
+ _setProperty(root, false, variable);
+ }
+ }
+ }
+
+ this._lastExperimentData = {};
+
+ if (!active || !experiment) {
+ return;
+ }
+
+ let usedVariables = [];
+ if (properties.colors) {
+ for (const property in properties.colors) {
+ const cssVariable = experiment.colors[property];
+ const value = _rgbaToString(
+ _cssColorToRGBA(root.ownerDocument, properties.colors[property])
+ );
+ usedVariables.push([cssVariable, value]);
+ }
+ }
+
+ if (properties.images) {
+ for (const property in properties.images) {
+ const cssVariable = experiment.images[property];
+ usedVariables.push([
+ cssVariable,
+ `url(${properties.images[property]})`,
+ ]);
+ }
+ }
+ if (properties.properties) {
+ for (const property in properties.properties) {
+ const cssVariable = experiment.properties[property];
+ usedVariables.push([cssVariable, properties.properties[property]]);
+ }
+ }
+ for (const [variable, value] of usedVariables) {
+ _setProperty(root, true, variable, value);
+ }
+ this._lastExperimentData.usedVariables = usedVariables;
+
+ if (experiment.stylesheet) {
+ /* Stylesheet URLs are validated using WebExtension schemas */
+ let stylesheetAttr = `href="${experiment.stylesheet}" type="text/css"`;
+ let stylesheet = this._doc.createProcessingInstruction(
+ "xml-stylesheet",
+ stylesheetAttr
+ );
+ this._doc.insertBefore(stylesheet, root);
+ this._lastExperimentData.stylesheet = stylesheet;
+ }
+ },
+};
+
+function _getContentProperties(doc, active, data) {
+ if (!active) {
+ return {};
+ }
+ let properties = {};
+ for (let property in data) {
+ if (lazy.ThemeContentPropertyList.includes(property)) {
+ properties[property] = _cssColorToRGBA(doc, data[property]);
+ }
+ }
+ if (data.experimental) {
+ for (const property in data.experimental.colors) {
+ if (lazy.ThemeContentPropertyList.includes(property)) {
+ properties[property] = _cssColorToRGBA(
+ doc,
+ data.experimental.colors[property]
+ );
+ }
+ }
+ for (const property in data.experimental.images) {
+ if (lazy.ThemeContentPropertyList.includes(property)) {
+ properties[property] = `url(${data.experimental.images[property]})`;
+ }
+ }
+ for (const property in data.experimental.properties) {
+ if (lazy.ThemeContentPropertyList.includes(property)) {
+ properties[property] = data.experimental.properties[property];
+ }
+ }
+ }
+ return properties;
+}
+
+function _setImage(aWin, aRoot, aActive, aVariableName, aURLs) {
+ if (aURLs && !Array.isArray(aURLs)) {
+ aURLs = [aURLs];
+ }
+ _setProperty(
+ aRoot,
+ aActive,
+ aVariableName,
+ aURLs && aURLs.map(v => `url(${aWin.CSS.escape(v)})`).join(", ")
+ );
+}
+
+function _setProperty(elem, active, variableName, value) {
+ if (active && value) {
+ elem.style.setProperty(variableName, value);
+ } else {
+ elem.style.removeProperty(variableName);
+ }
+}
+
+function _isToolbarDark(aDoc, aColors) {
+ // We prefer looking at toolbar background first (if it's opaque) because
+ // some text colors can be dark enough for our heuristics, but still
+ // contrast well enough with a dark background, see bug 1743010.
+ if (aColors.toolbarColor) {
+ let color = _cssColorToRGBA(aDoc, aColors.toolbarColor);
+ if (color.a == 1) {
+ return _isColorDark(color.r, color.g, color.b);
+ }
+ }
+ if (aColors.toolbar_text) {
+ let color = _cssColorToRGBA(aDoc, aColors.toolbar_text);
+ return !_isColorDark(color.r, color.g, color.b);
+ }
+ // It'd seem sensible to try looking at the "frame" background (accentcolor),
+ // but we don't because some themes that use background images leave it to
+ // black, see bug 1741931.
+ //
+ // Fall back to black as per the textcolor processing above.
+ let color = _cssColorToRGBA(aDoc, aColors.textcolor || "black");
+ return !_isColorDark(color.r, color.g, color.b);
+}
+
+function _determineToolbarAndContentTheme(
+ aDoc,
+ aTheme,
+ aHasDarkTheme = false,
+ aIsDarkTheme = false
+) {
+ const kDark = 0;
+ const kLight = 1;
+ const kSystem = 2;
+
+ const colors = aTheme?._processedColors;
+ function colorSchemeValue(aColorScheme) {
+ if (!aColorScheme) {
+ return null;
+ }
+ switch (aColorScheme) {
+ case "light":
+ return kLight;
+ case "dark":
+ return kDark;
+ case "system":
+ return kSystem;
+ case "auto":
+ default:
+ break;
+ }
+ return null;
+ }
+
+ let toolbarTheme = (function () {
+ if (!aTheme) {
+ return kSystem;
+ }
+ let themeValue = colorSchemeValue(aTheme.color_scheme);
+ if (themeValue !== null) {
+ return themeValue;
+ }
+ if (aHasDarkTheme) {
+ return aIsDarkTheme ? kDark : kLight;
+ }
+ return _isToolbarDark(aDoc, colors) ? kDark : kLight;
+ })();
+
+ let contentTheme = (function () {
+ if (lazy.BROWSER_THEME_UNIFIED_COLOR_SCHEME) {
+ return toolbarTheme;
+ }
+ if (!aTheme) {
+ return kSystem;
+ }
+ let themeValue = colorSchemeValue(
+ aTheme.content_color_scheme || aTheme.color_scheme
+ );
+ if (themeValue !== null) {
+ return themeValue;
+ }
+ return kSystem;
+ })();
+
+ Services.prefs.setIntPref("browser.theme.toolbar-theme", toolbarTheme);
+ Services.prefs.setIntPref("browser.theme.content-theme", contentTheme);
+}
+
+/**
+ * Sets dark mode attributes on root, if required. We must do this here,
+ * instead of in each color's processColor function, because multiple colors
+ * are considered.
+ * @param {Document} doc
+ * @param {Element} root
+ * @param {object} colors
+ * The `_processedColors` object from the object created for our theme.
+ * @param {boolean} hasTheme
+ */
+function _setDarkModeAttributes(doc, root, colors, hasTheme) {
+ {
+ let textColor = _cssColorToRGBA(doc, colors.textcolor);
+ if (textColor && !_isColorDark(textColor.r, textColor.g, textColor.b)) {
+ root.setAttribute("lwtheme-brighttext", "true");
+ } else {
+ root.removeAttribute("lwtheme-brighttext");
+ }
+ }
+
+ if (hasTheme) {
+ root.setAttribute(
+ "lwt-toolbar",
+ _isToolbarDark(doc, colors) ? "dark" : "light"
+ );
+ } else {
+ root.removeAttribute("lwt-toolbar");
+ }
+
+ const setAttribute = function (
+ attribute,
+ textPropertyName,
+ backgroundPropertyName
+ ) {
+ let dark = _determineIfColorPairIsDark(
+ doc,
+ colors,
+ textPropertyName,
+ backgroundPropertyName
+ );
+ if (dark === null) {
+ root.removeAttribute(attribute);
+ } else {
+ root.setAttribute(attribute, dark ? "dark" : "light");
+ }
+ };
+
+ setAttribute("lwt-tab-selected", "tab_text", "tab_selected");
+ setAttribute("lwt-toolbar-field", "toolbar_field_text", "toolbar_field");
+ setAttribute(
+ "lwt-toolbar-field-focus",
+ "toolbar_field_text_focus",
+ "toolbar_field_focus"
+ );
+ setAttribute("lwt-popup", "popup_text", "popup");
+ setAttribute("lwt-sidebar", "sidebar_text", "sidebar");
+}
+
+/**
+ * Determines if a themed color pair should be considered to have a dark color
+ * scheme. We consider both the background and foreground (i.e. usually text)
+ * colors because some text colors can be dark enough for our heuristics, but
+ * still contrast well enough with a dark background
+ * @param {Document} doc
+ * @param {object} colors
+ * @param {string} foregroundElementId
+ * The key for the foreground element in `colors`.
+ * @param {string} backgroundElementId
+ * The key for the background element in `colors`.
+ * @returns {boolean | null} True if the element should be considered dark, false
+ * if light, null for preferred scheme.
+ */
+function _determineIfColorPairIsDark(
+ doc,
+ colors,
+ textPropertyName,
+ backgroundPropertyName
+) {
+ if (!colors[backgroundPropertyName] && !colors[textPropertyName]) {
+ // Handles the system theme.
+ return null;
+ }
+
+ let color = _cssColorToRGBA(doc, colors[backgroundPropertyName]);
+ if (color && color.a == 1) {
+ return _isColorDark(color.r, color.g, color.b);
+ }
+
+ color = _cssColorToRGBA(doc, colors[textPropertyName]);
+ if (!color) {
+ // Handles the case where a theme only provides a background color and it is
+ // semi-transparent.
+ return null;
+ }
+
+ return !_isColorDark(color.r, color.g, color.b);
+}
+
+function _setProperties(root, active, themeData, hasTheme) {
+ let propertyOverrides = new Map();
+ let doc = root.ownerDocument;
+
+ // Copy the theme into _processedColors. We'll replace values with processed
+ // colors if necessary. We copy because some colors (such as those used in
+ // content) are not processed here, but are referenced in places that check
+ // _processedColors. Copying means _processedColors will contain irrelevant
+ // properties like `id`. There aren't too many, so that's OK.
+ themeData._processedColors = { ...themeData };
+ for (let map of [toolkitVariableMap, lazy.ThemeVariableMap]) {
+ for (let [cssVarName, definition] of map) {
+ const {
+ lwtProperty,
+ fallbackProperty,
+ fallbackColor,
+ optionalElementID,
+ processColor,
+ isColor = true,
+ } = definition;
+ let elem = optionalElementID
+ ? doc.getElementById(optionalElementID)
+ : root;
+ let val = propertyOverrides.get(lwtProperty) || themeData[lwtProperty];
+ if (isColor) {
+ val = _cssColorToRGBA(doc, val);
+ if (!val && fallbackProperty) {
+ val = _cssColorToRGBA(doc, themeData[fallbackProperty]);
+ }
+ if (!val && hasTheme && fallbackColor) {
+ val = _cssColorToRGBA(doc, fallbackColor);
+ }
+ if (processColor) {
+ val = processColor(val, elem, propertyOverrides);
+ } else {
+ val = _rgbaToString(val);
+ }
+ }
+
+ // Add processed color to themeData.
+ themeData._processedColors[lwtProperty] = val;
+
+ _setProperty(elem, active, cssVarName, val);
+ }
+ }
+}
+
+const kInvalidColor = { r: 0, g: 0, b: 0, a: 1 };
+
+function _cssColorToRGBA(doc, cssColor) {
+ if (!cssColor) {
+ return null;
+ }
+ return (
+ doc.defaultView.InspectorUtils.colorToRGBA(cssColor, doc) || kInvalidColor
+ );
+}
+
+function _rgbaToString(parsedColor) {
+ if (!parsedColor) {
+ return null;
+ }
+ let { r, g, b, a } = parsedColor;
+ if (a == 1) {
+ return `rgb(${r}, ${g}, ${b})`;
+ }
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+}
+
+function _isColorDark(r, g, b) {
+ return 0.2125 * r + 0.7154 * g + 0.0721 * b <= 127;
+}