no bug - Bumping Firefox l10n changesets r=release a=l10n-bump DONTBUILD CLOSED TREE
[gecko.git] / toolkit / modules / LightweightThemeConsumer.sys.mjs
blobcf388eb3d393403c077f2a01c3e670cefad7db59
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
7 const lazy = {};
8 // Get the theme variables from the app resource directory.
9 // This allows per-app variables.
10 ChromeUtils.defineESModuleGetters(lazy, {
11   NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
12   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
13   ThemeContentPropertyList: "resource:///modules/ThemeVariableMap.sys.mjs",
14   ThemeVariableMap: "resource:///modules/ThemeVariableMap.sys.mjs",
15 });
17 // Whether the content and chrome areas should always use the same color
18 // scheme (unless user-overridden). Thunderbird uses this.
19 XPCOMUtils.defineLazyPreferenceGetter(
20   lazy,
21   "BROWSER_THEME_UNIFIED_COLOR_SCHEME",
22   "browser.theme.unified-color-scheme",
23   false
26 const DEFAULT_THEME_ID = "default-theme@mozilla.org";
28 const toolkitVariableMap = [
29   [
30     "--lwt-accent-color",
31     {
32       lwtProperty: "accentcolor",
33       processColor(rgbaChannels, element) {
34         if (!rgbaChannels || rgbaChannels.a == 0) {
35           return "white";
36         }
37         // Remove the alpha channel
38         const { r, g, b } = rgbaChannels;
39         return `rgb(${r}, ${g}, ${b})`;
40       },
41     },
42   ],
43   [
44     "--lwt-text-color",
45     {
46       lwtProperty: "textcolor",
47       processColor(rgbaChannels, element) {
48         if (!rgbaChannels) {
49           rgbaChannels = { r: 0, g: 0, b: 0 };
50         }
51         // Remove the alpha channel
52         const { r, g, b } = rgbaChannels;
53         return `rgba(${r}, ${g}, ${b})`;
54       },
55     },
56   ],
57   [
58     "--arrowpanel-background",
59     {
60       lwtProperty: "popup",
61     },
62   ],
63   [
64     "--arrowpanel-color",
65     {
66       lwtProperty: "popup_text",
67     },
68   ],
69   [
70     "--arrowpanel-border-color",
71     {
72       lwtProperty: "popup_border",
73     },
74   ],
75   [
76     "--toolbar-field-background-color",
77     {
78       lwtProperty: "toolbar_field",
79       fallbackColor: "rgba(255, 255, 255, 0.8)",
80     },
81   ],
82   [
83     "--toolbar-bgcolor",
84     {
85       lwtProperty: "toolbarColor",
86     },
87   ],
88   [
89     "--toolbar-color",
90     {
91       lwtProperty: "toolbar_text",
92     },
93   ],
94   [
95     "--toolbar-field-color",
96     {
97       lwtProperty: "toolbar_field_text",
98       fallbackColor: "black",
99     },
100   ],
101   [
102     "--toolbar-field-border-color",
103     {
104       lwtProperty: "toolbar_field_border",
105       fallbackColor: "transparent",
106     },
107   ],
108   [
109     "--toolbar-field-focus-background-color",
110     {
111       lwtProperty: "toolbar_field_focus",
112       fallbackProperty: "toolbar_field",
113       fallbackColor: "white",
114       processColor(rgbaChannels, element, propertyOverrides) {
115         if (!rgbaChannels) {
116           return null;
117         }
118         // Ensure minimum opacity as this is used behind address bar results.
119         const min_opacity = 0.9;
120         let { r, g, b, a } = rgbaChannels;
121         if (a < min_opacity) {
122           propertyOverrides.set(
123             "toolbar_field_text_focus",
124             _isColorDark(r, g, b) ? "white" : "black"
125           );
126           return `rgba(${r}, ${g}, ${b}, ${min_opacity})`;
127         }
128         return `rgba(${r}, ${g}, ${b}, ${a})`;
129       },
130     },
131   ],
132   [
133     "--toolbar-field-focus-color",
134     {
135       lwtProperty: "toolbar_field_text_focus",
136       fallbackProperty: "toolbar_field_text",
137       fallbackColor: "black",
138     },
139   ],
140   [
141     "--toolbar-field-focus-border-color",
142     {
143       lwtProperty: "toolbar_field_border_focus",
144     },
145   ],
146   [
147     "--lwt-toolbar-field-highlight",
148     {
149       lwtProperty: "toolbar_field_highlight",
150       processColor(rgbaChannels, element) {
151         if (!rgbaChannels) {
152           return null;
153         }
154         const { r, g, b, a } = rgbaChannels;
155         return `rgba(${r}, ${g}, ${b}, ${a})`;
156       },
157     },
158   ],
159   [
160     "--lwt-toolbar-field-highlight-text",
161     {
162       lwtProperty: "toolbar_field_highlight_text",
163     },
164   ],
165   // The following 3 are given to the new tab page by contentTheme.js. They are
166   // also exposed here, in the browser chrome, so popups anchored on top of the
167   // new tab page can use them to avoid clashing with the new tab page content.
168   [
169     "--newtab-background-color",
170     {
171       lwtProperty: "ntp_background",
172       processColor(rgbaChannels) {
173         if (!rgbaChannels) {
174           return null;
175         }
176         const { r, g, b } = rgbaChannels;
177         // Drop alpha channel
178         return `rgb(${r}, ${g}, ${b})`;
179       },
180     },
181   ],
182   [
183     "--newtab-background-color-secondary",
184     { lwtProperty: "ntp_card_background" },
185   ],
186   ["--newtab-text-primary-color", { lwtProperty: "ntp_text" }],
189 export function LightweightThemeConsumer(aDocument) {
190   this._doc = aDocument;
191   this._win = aDocument.defaultView;
192   this._winId = this._win.docShell.outerWindowID;
194   Services.obs.addObserver(this, "lightweight-theme-styling-update");
196   this.darkThemeMediaQuery = this._win.matchMedia("(-moz-system-dark-theme)");
197   this.darkThemeMediaQuery.addListener(this);
199   const { LightweightThemeManager } = ChromeUtils.importESModule(
200     "resource://gre/modules/LightweightThemeManager.sys.mjs"
201   );
202   this._update(LightweightThemeManager.themeData);
204   this._win.addEventListener("unload", this, { once: true });
207 LightweightThemeConsumer.prototype = {
208   _lastData: null,
210   observe(aSubject, aTopic, aData) {
211     if (aTopic != "lightweight-theme-styling-update") {
212       return;
213     }
215     let data = aSubject.wrappedJSObject;
216     if (data.window && data.window !== this._winId) {
217       return;
218     }
220     this._update(data);
221   },
223   handleEvent(aEvent) {
224     if (aEvent.target == this.darkThemeMediaQuery) {
225       this._update(this._lastData);
226       return;
227     }
229     switch (aEvent.type) {
230       case "unload":
231         Services.obs.removeObserver(this, "lightweight-theme-styling-update");
232         Services.ppmm.sharedData.delete(`theme/${this._winId}`);
233         this._win = this._doc = null;
234         if (this.darkThemeMediaQuery) {
235           this.darkThemeMediaQuery.removeListener(this);
236           this.darkThemeMediaQuery = null;
237         }
238         break;
239     }
240   },
242   _update(themeData) {
243     this._lastData = themeData;
245     const hasDarkTheme = !!themeData.darkTheme;
246     let updateGlobalThemeData = true;
247     let useDarkTheme = (() => {
248       if (!hasDarkTheme) {
249         return false;
250       }
252       if (this.darkThemeMediaQuery?.matches) {
253         return themeData.darkTheme.id != DEFAULT_THEME_ID;
254       }
256       // If enabled, apply the dark theme variant to private browsing windows.
257       if (
258         !lazy.NimbusFeatures.majorRelease2022.getVariable(
259           "feltPrivacyPBMDarkTheme"
260         ) ||
261         !lazy.PrivateBrowsingUtils.isWindowPrivate(this._win) ||
262         lazy.PrivateBrowsingUtils.permanentPrivateBrowsing
263       ) {
264         return false;
265       }
266       // When applying the dark theme for a PBM window we need to skip calling
267       // _determineToolbarAndContentTheme, because it applies the color scheme
268       // globally for all windows. Skipping this method also means we don't
269       // switch the content theme to dark.
270       //
271       // TODO: On Linux we most likely need to apply the dark theme, but on
272       // Windows and macOS we should be able to render light and dark windows
273       // with the default theme at the same time.
274       updateGlobalThemeData = false;
275       return true;
276     })();
278     // If this is a per-window dark theme, set the color scheme override so
279     // child BrowsingContexts, such as embedded prompts, get themed
280     // appropriately.
281     // If not, reset the color scheme override field. This is required to reset
282     // the color scheme on theme switch.
283     if (this._win.browsingContext == this._win.browsingContext.top) {
284       if (useDarkTheme && !updateGlobalThemeData) {
285         this._win.browsingContext.prefersColorSchemeOverride = "dark";
286       } else {
287         this._win.browsingContext.prefersColorSchemeOverride = "none";
288       }
289     }
291     let theme = useDarkTheme ? themeData.darkTheme : themeData.theme;
292     if (!theme) {
293       theme = { id: DEFAULT_THEME_ID };
294     }
296     let active = (this._active = Object.keys(theme).length);
298     let root = this._doc.documentElement;
300     if (active && theme.headerURL) {
301       root.setAttribute("lwtheme-image", "true");
302     } else {
303       root.removeAttribute("lwtheme-image");
304     }
306     let hasTheme = theme.id != DEFAULT_THEME_ID || useDarkTheme;
308     this._setExperiment(active, themeData.experiment, theme.experimental);
309     _setImage(this._win, root, active, "--lwt-header-image", theme.headerURL);
310     _setImage(
311       this._win,
312       root,
313       active,
314       "--lwt-additional-images",
315       theme.additionalBackgrounds
316     );
317     _setProperties(root, active, theme, hasTheme);
319     if (hasTheme) {
320       if (updateGlobalThemeData) {
321         _determineToolbarAndContentTheme(
322           this._doc,
323           theme,
324           hasDarkTheme,
325           useDarkTheme
326         );
327       }
328       root.setAttribute("lwtheme", "true");
329     } else {
330       _determineToolbarAndContentTheme(this._doc, null);
331       root.removeAttribute("lwtheme");
332     }
334     _setDarkModeAttributes(this._doc, root, theme._processedColors, hasTheme);
336     let contentThemeData = _getContentProperties(this._doc, active, theme);
337     Services.ppmm.sharedData.set(`theme/${this._winId}`, contentThemeData);
338     // We flush sharedData because contentThemeData can be responsible for
339     // painting large background surfaces. If this data isn't delivered to the
340     // content process before about:home is painted, we will paint a default
341     // background and then replace it when sharedData syncs, causing flashing.
342     Services.ppmm.sharedData.flush();
344     this._win.dispatchEvent(new CustomEvent("windowlwthemeupdate"));
345   },
347   _setExperiment(active, experiment, properties) {
348     const root = this._doc.documentElement;
349     if (this._lastExperimentData) {
350       const { stylesheet, usedVariables } = this._lastExperimentData;
351       if (stylesheet) {
352         stylesheet.remove();
353       }
354       if (usedVariables) {
355         for (const [variable] of usedVariables) {
356           _setProperty(root, false, variable);
357         }
358       }
359     }
361     this._lastExperimentData = {};
363     if (!active || !experiment) {
364       return;
365     }
367     let usedVariables = [];
368     if (properties.colors) {
369       for (const property in properties.colors) {
370         const cssVariable = experiment.colors[property];
371         const value = _rgbaToString(
372           _cssColorToRGBA(root.ownerDocument, properties.colors[property])
373         );
374         usedVariables.push([cssVariable, value]);
375       }
376     }
378     if (properties.images) {
379       for (const property in properties.images) {
380         const cssVariable = experiment.images[property];
381         usedVariables.push([
382           cssVariable,
383           `url(${properties.images[property]})`,
384         ]);
385       }
386     }
387     if (properties.properties) {
388       for (const property in properties.properties) {
389         const cssVariable = experiment.properties[property];
390         usedVariables.push([cssVariable, properties.properties[property]]);
391       }
392     }
393     for (const [variable, value] of usedVariables) {
394       _setProperty(root, true, variable, value);
395     }
396     this._lastExperimentData.usedVariables = usedVariables;
398     if (experiment.stylesheet) {
399       /* Stylesheet URLs are validated using WebExtension schemas */
400       let stylesheetAttr = `href="${experiment.stylesheet}" type="text/css"`;
401       let stylesheet = this._doc.createProcessingInstruction(
402         "xml-stylesheet",
403         stylesheetAttr
404       );
405       this._doc.insertBefore(stylesheet, root);
406       this._lastExperimentData.stylesheet = stylesheet;
407     }
408   },
411 function _getContentProperties(doc, active, data) {
412   if (!active) {
413     return {};
414   }
415   let properties = {};
416   for (let property in data) {
417     if (lazy.ThemeContentPropertyList.includes(property)) {
418       properties[property] = _cssColorToRGBA(doc, data[property]);
419     }
420   }
421   if (data.experimental) {
422     for (const property in data.experimental.colors) {
423       if (lazy.ThemeContentPropertyList.includes(property)) {
424         properties[property] = _cssColorToRGBA(
425           doc,
426           data.experimental.colors[property]
427         );
428       }
429     }
430     for (const property in data.experimental.images) {
431       if (lazy.ThemeContentPropertyList.includes(property)) {
432         properties[property] = `url(${data.experimental.images[property]})`;
433       }
434     }
435     for (const property in data.experimental.properties) {
436       if (lazy.ThemeContentPropertyList.includes(property)) {
437         properties[property] = data.experimental.properties[property];
438       }
439     }
440   }
441   return properties;
444 function _setImage(aWin, aRoot, aActive, aVariableName, aURLs) {
445   if (aURLs && !Array.isArray(aURLs)) {
446     aURLs = [aURLs];
447   }
448   _setProperty(
449     aRoot,
450     aActive,
451     aVariableName,
452     aURLs && aURLs.map(v => `url(${aWin.CSS.escape(v)})`).join(", ")
453   );
456 function _setProperty(elem, active, variableName, value) {
457   if (active && value) {
458     elem.style.setProperty(variableName, value);
459   } else {
460     elem.style.removeProperty(variableName);
461   }
464 function _isToolbarDark(aDoc, aColors) {
465   // We prefer looking at toolbar background first (if it's opaque) because
466   // some text colors can be dark enough for our heuristics, but still
467   // contrast well enough with a dark background, see bug 1743010.
468   if (aColors.toolbarColor) {
469     let color = _cssColorToRGBA(aDoc, aColors.toolbarColor);
470     if (color.a == 1) {
471       return _isColorDark(color.r, color.g, color.b);
472     }
473   }
474   if (aColors.toolbar_text) {
475     let color = _cssColorToRGBA(aDoc, aColors.toolbar_text);
476     return !_isColorDark(color.r, color.g, color.b);
477   }
478   // It'd seem sensible to try looking at the "frame" background (accentcolor),
479   // but we don't because some themes that use background images leave it to
480   // black, see bug 1741931.
481   //
482   // Fall back to black as per the textcolor processing above.
483   let color = _cssColorToRGBA(aDoc, aColors.textcolor || "black");
484   return !_isColorDark(color.r, color.g, color.b);
487 function _determineToolbarAndContentTheme(
488   aDoc,
489   aTheme,
490   aHasDarkTheme = false,
491   aIsDarkTheme = false
492 ) {
493   const kDark = 0;
494   const kLight = 1;
495   const kSystem = 2;
497   const colors = aTheme?._processedColors;
498   function colorSchemeValue(aColorScheme) {
499     if (!aColorScheme) {
500       return null;
501     }
502     switch (aColorScheme) {
503       case "light":
504         return kLight;
505       case "dark":
506         return kDark;
507       case "system":
508         return kSystem;
509       case "auto":
510       default:
511         break;
512     }
513     return null;
514   }
516   let toolbarTheme = (function () {
517     if (!aTheme) {
518       return kSystem;
519     }
520     let themeValue = colorSchemeValue(aTheme.color_scheme);
521     if (themeValue !== null) {
522       return themeValue;
523     }
524     if (aHasDarkTheme) {
525       return aIsDarkTheme ? kDark : kLight;
526     }
527     return _isToolbarDark(aDoc, colors) ? kDark : kLight;
528   })();
530   let contentTheme = (function () {
531     if (lazy.BROWSER_THEME_UNIFIED_COLOR_SCHEME) {
532       return toolbarTheme;
533     }
534     if (!aTheme) {
535       return kSystem;
536     }
537     let themeValue = colorSchemeValue(
538       aTheme.content_color_scheme || aTheme.color_scheme
539     );
540     if (themeValue !== null) {
541       return themeValue;
542     }
543     return kSystem;
544   })();
546   Services.prefs.setIntPref("browser.theme.toolbar-theme", toolbarTheme);
547   Services.prefs.setIntPref("browser.theme.content-theme", contentTheme);
551  * Sets dark mode attributes on root, if required. We must do this here,
552  * instead of in each color's processColor function, because multiple colors
553  * are considered.
554  * @param {Document} doc
555  * @param {Element} root
556  * @param {object} colors
557  *   The `_processedColors` object from the object created for our theme.
558  * @param {boolean} hasTheme
559  */
560 function _setDarkModeAttributes(doc, root, colors, hasTheme) {
561   {
562     let textColor = _cssColorToRGBA(doc, colors.textcolor);
563     if (textColor && !_isColorDark(textColor.r, textColor.g, textColor.b)) {
564       root.setAttribute("lwtheme-brighttext", "true");
565     } else {
566       root.removeAttribute("lwtheme-brighttext");
567     }
568   }
570   if (hasTheme) {
571     root.setAttribute(
572       "lwt-toolbar",
573       _isToolbarDark(doc, colors) ? "dark" : "light"
574     );
575   } else {
576     root.removeAttribute("lwt-toolbar");
577   }
579   const setAttribute = function (
580     attribute,
581     textPropertyName,
582     backgroundPropertyName
583   ) {
584     let dark = _determineIfColorPairIsDark(
585       doc,
586       colors,
587       textPropertyName,
588       backgroundPropertyName
589     );
590     if (dark === null) {
591       root.removeAttribute(attribute);
592     } else {
593       root.setAttribute(attribute, dark ? "dark" : "light");
594     }
595   };
597   setAttribute("lwt-tab-selected", "tab_text", "tab_selected");
598   setAttribute("lwt-toolbar-field", "toolbar_field_text", "toolbar_field");
599   setAttribute(
600     "lwt-toolbar-field-focus",
601     "toolbar_field_text_focus",
602     "toolbar_field_focus"
603   );
604   setAttribute("lwt-popup", "popup_text", "popup");
605   setAttribute("lwt-sidebar", "sidebar_text", "sidebar");
609  * Determines if a themed color pair should be considered to have a dark color
610  * scheme. We consider both the background and foreground (i.e. usually text)
611  * colors because some text colors can be dark enough for our heuristics, but
612  * still contrast well enough with a dark background
613  * @param {Document} doc
614  * @param {object} colors
615  * @param {string} foregroundElementId
616  *   The key for the foreground element in `colors`.
617  * @param {string} backgroundElementId
618  *   The key for the background element in `colors`.
619  * @returns {boolean | null} True if the element should be considered dark, false
620  *   if light, null for preferred scheme.
621  */
622 function _determineIfColorPairIsDark(
623   doc,
624   colors,
625   textPropertyName,
626   backgroundPropertyName
627 ) {
628   if (!colors[backgroundPropertyName] && !colors[textPropertyName]) {
629     // Handles the system theme.
630     return null;
631   }
633   let color = _cssColorToRGBA(doc, colors[backgroundPropertyName]);
634   if (color && color.a == 1) {
635     return _isColorDark(color.r, color.g, color.b);
636   }
638   color = _cssColorToRGBA(doc, colors[textPropertyName]);
639   if (!color) {
640     // Handles the case where a theme only provides a background color and it is
641     // semi-transparent.
642     return null;
643   }
645   return !_isColorDark(color.r, color.g, color.b);
648 function _setProperties(root, active, themeData, hasTheme) {
649   let propertyOverrides = new Map();
650   let doc = root.ownerDocument;
652   // Copy the theme into _processedColors. We'll replace values with processed
653   // colors if necessary. We copy because some colors (such as those used in
654   // content) are not processed here, but are referenced in places that check
655   // _processedColors. Copying means _processedColors will contain irrelevant
656   // properties like `id`. There aren't too many, so that's OK.
657   themeData._processedColors = { ...themeData };
658   for (let map of [toolkitVariableMap, lazy.ThemeVariableMap]) {
659     for (let [cssVarName, definition] of map) {
660       const {
661         lwtProperty,
662         fallbackProperty,
663         fallbackColor,
664         optionalElementID,
665         processColor,
666         isColor = true,
667       } = definition;
668       let elem = optionalElementID
669         ? doc.getElementById(optionalElementID)
670         : root;
671       let val = propertyOverrides.get(lwtProperty) || themeData[lwtProperty];
672       if (isColor) {
673         val = _cssColorToRGBA(doc, val);
674         if (!val && fallbackProperty) {
675           val = _cssColorToRGBA(doc, themeData[fallbackProperty]);
676         }
677         if (!val && hasTheme && fallbackColor) {
678           val = _cssColorToRGBA(doc, fallbackColor);
679         }
680         if (processColor) {
681           val = processColor(val, elem, propertyOverrides);
682         } else {
683           val = _rgbaToString(val);
684         }
685       }
687       // Add processed color to themeData.
688       themeData._processedColors[lwtProperty] = val;
690       _setProperty(elem, active, cssVarName, val);
691     }
692   }
695 const kInvalidColor = { r: 0, g: 0, b: 0, a: 1 };
697 function _cssColorToRGBA(doc, cssColor) {
698   if (!cssColor) {
699     return null;
700   }
701   return (
702     doc.defaultView.InspectorUtils.colorToRGBA(cssColor, doc) || kInvalidColor
703   );
706 function _rgbaToString(parsedColor) {
707   if (!parsedColor) {
708     return null;
709   }
710   let { r, g, b, a } = parsedColor;
711   if (a == 1) {
712     return `rgb(${r}, ${g}, ${b})`;
713   }
714   return `rgba(${r}, ${g}, ${b}, ${a})`;
717 function _isColorDark(r, g, b) {
718   return 0.2125 * r + 0.7154 * g + 0.0721 * b <= 127;