fune/toolkit/modules/LightweightThemeConsumer.sys.mjs
Shane Hughes 81b755329b Bug 1892402 - Make newtab feature callouts handle old themes more gracefully. r=omc-reviewers,negin
The newtab page is styled according to a `lwt-newtab-brighttext`
attribute, which until now is only present in the newtab page, and not
exposed to the chrome. This is a problem for elements like feature
callouts, which we want to fit in with the newtab content, even though
they exist in the chrome. That's okay in many cases, because the
callouts also use theme properties, and there's usually a concordance
between `lwt-newtab-brighttext` and the content color scheme. But in a
few older (but very popular) themes, there are missing theme properties,
and it's possible for the newtab page to be very dark even though
prefers-color-scheme is not dark.

This patch resolves the issue by exposing `lwt-newtab-brighttext` to the
chrome, and using that attribute to theme newtab callouts.

Differential Revision: https://phabricator.services.mozilla.com/D208256
2024-04-24 20:54:02 +00:00

731 lines
20 KiB
JavaScript

/* 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, {
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) {
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) {
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) {
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",
processColor(rgbaChannels, element) {
if (!rgbaChannels) {
element.removeAttribute("lwt-newtab-brighttext");
return null;
}
const { r, g, b } = rgbaChannels;
element.toggleAttribute(
"lwt-newtab-brighttext",
0.2125 * r + 0.7154 * g + 0.0721 * b > 110
);
return _rgbaToString(rgbaChannels);
},
},
],
];
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) {
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 (
!Services.prefs.getBoolPref("browser.theme.dark-private-windows") ||
!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 hasTheme = theme.id != DEFAULT_THEME_ID || useDarkTheme;
let root = this._doc.documentElement;
if (hasTheme && theme.headerURL) {
root.setAttribute("lwtheme-image", "true");
} else {
root.removeAttribute("lwtheme-image");
}
this._setExperiment(hasTheme, themeData.experiment, theme.experimental);
_setImage(this._win, root, hasTheme, "--lwt-header-image", theme.headerURL);
_setImage(
this._win,
root,
hasTheme,
"--lwt-additional-images",
theme.additionalBackgrounds
);
_setProperties(root, hasTheme, theme);
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, hasTheme, 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(hasTheme, 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 (!hasTheme || !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, hasTheme, data) {
let properties = { hasTheme };
if (!hasTheme) {
return 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, hasTheme, variableName, value) {
if (hasTheme && 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, hasTheme, themeData) {
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, hasTheme, 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;
}