Bug 1466335: Automatically switch to the appropriate Firefox theme based on the macOS dark mode system preference. r=dao,mstange a=lizzard
authorStephen A Pohl <spohl.mozilla.bugs@gmail.com>
Fri, 29 Jun 2018 08:58:00 +0300
changeset 477797 287d12a9f2eab91769b92220df6fdf33768e015c
parent 477796 fb489313aa898d1cbe956df008c9b76d24425952
child 477798 90df1b0f7e85412415791f2f6f337822178c622b
push id9428
push useraiakab@mozilla.com
push dateTue, 03 Jul 2018 15:11:07 +0000
treeherdermozilla-beta@8f24de7f709f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdao, mstange, lizzard
bugs1466335
milestone62.0
Bug 1466335: Automatically switch to the appropriate Firefox theme based on the macOS dark mode system preference. r=dao,mstange a=lizzard
browser/base/content/browser-compacttheme.js
layout/style/nsMediaFeatures.cpp
toolkit/modules/LightweightThemeConsumer.jsm
toolkit/mozapps/extensions/LightweightThemeManager.jsm
widget/LookAndFeel.h
widget/cocoa/nsChildView.mm
widget/cocoa/nsLookAndFeel.h
widget/cocoa/nsLookAndFeel.mm
widget/cocoa/nsNativeThemeCocoa.mm
widget/nsXPLookAndFeel.cpp
xpcom/ds/nsGkAtomList.h
--- a/browser/base/content/browser-compacttheme.js
+++ b/browser/base/content/browser-compacttheme.js
@@ -17,17 +17,17 @@ var CompactTheme = {
     return this.styleSheet;
   },
 
   get isStyleSheetEnabled() {
     return this.styleSheet && !this.styleSheet.disabled;
   },
 
   get isThemeCurrentlyApplied() {
-    let theme = LightweightThemeManager.currentTheme;
+    let theme = LightweightThemeManager.currentThemeForDisplay;
     return theme && (
            theme.id == "firefox-compact-dark@mozilla.org" ||
            theme.id == "firefox-compact-light@mozilla.org");
   },
 
   init() {
     Services.obs.addObserver(this, "lightweight-theme-styling-update");
 
--- a/layout/style/nsMediaFeatures.cpp
+++ b/layout/style/nsMediaFeatures.cpp
@@ -626,16 +626,22 @@ nsMediaFeatures::InitSystemMetrics()
   }
 
   rv = LookAndFeel::GetInt(LookAndFeel::eIntID_GTKCSDCloseButton,
                            &metricResult);
   if (NS_SUCCEEDED(rv) && metricResult) {
     sSystemMetrics->AppendElement(nsGkAtoms::gtk_csd_close_button);
   }
 
+  metricResult =
+    LookAndFeel::GetInt(LookAndFeel::eIntID_SystemUsesDarkTheme);
+  if (metricResult) {
+    sSystemMetrics->AppendElement(nsGkAtoms::system_dark_theme);
+  }
+
 #ifdef XP_WIN
   if (NS_SUCCEEDED(
         LookAndFeel::GetInt(LookAndFeel::eIntID_WindowsThemeIdentifier,
                             &metricResult))) {
     sWinThemeId = metricResult;
     switch (metricResult) {
       case LookAndFeel::eWindowsTheme_Aero:
         sSystemMetrics->AppendElement(nsGkAtoms::windows_theme_aero);
@@ -1027,16 +1033,25 @@ nsMediaFeatures::features[] = {
     &nsGkAtoms::_moz_gtk_csd_close_button,
     nsMediaFeature::eMinMaxNotAllowed,
     nsMediaFeature::eBoolInteger,
     nsMediaFeature::eUserAgentAndChromeOnly,
     { &nsGkAtoms::gtk_csd_close_button },
     GetSystemMetric
   },
 
+  {
+    &nsGkAtoms::_moz_system_dark_theme,
+    nsMediaFeature::eMinMaxNotAllowed,
+    nsMediaFeature::eBoolInteger,
+    nsMediaFeature::eUserAgentAndChromeOnly,
+    { &nsGkAtoms::system_dark_theme },
+    GetSystemMetric
+  },
+
   // Internal -moz-is-glyph media feature: applies only inside SVG glyphs.
   // Internal because it is really only useful in the user agent anyway
   //  and therefore not worth standardizing.
   {
     &nsGkAtoms::_moz_is_glyph,
     nsMediaFeature::eMinMaxNotAllowed,
     nsMediaFeature::eBoolInteger,
     nsMediaFeature::eUserAgentAndChromeOnly,
--- a/toolkit/modules/LightweightThemeConsumer.jsm
+++ b/toolkit/modules/LightweightThemeConsumer.jsm
@@ -111,16 +111,20 @@ function LightweightThemeConsumer(aDocum
   Services.obs.addObserver(this, "lightweight-theme-styling-update");
 
   var temp = {};
   ChromeUtils.import("resource://gre/modules/LightweightThemeManager.jsm", temp);
   this._update(temp.LightweightThemeManager.currentThemeForDisplay);
 
   this._win.addEventListener("resolutionchange", this);
   this._win.addEventListener("unload", this, { once: true });
+
+  let darkThemeMediaQuery = this._win.matchMedia("(-moz-system-dark-theme)");
+  darkThemeMediaQuery.addListener(temp.LightweightThemeManager);
+  temp.LightweightThemeManager.systemThemeChanged(darkThemeMediaQuery);
 }
 
 LightweightThemeConsumer.prototype = {
   _lastData: null,
   // Whether a lightweight theme is enabled.
   _active: false,
 
   observe(aSubject, aTopic, aData) {
--- a/toolkit/mozapps/extensions/LightweightThemeManager.jsm
+++ b/toolkit/mozapps/extensions/LightweightThemeManager.jsm
@@ -12,16 +12,17 @@ ChromeUtils.import("resource://gre/modul
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 const ID_SUFFIX              = "@personas.mozilla.org";
 const ADDON_TYPE             = "theme";
 const ADDON_TYPE_WEBEXT      = "webextension-theme";
 
 const URI_EXTENSION_STRINGS  = "chrome://mozapps/locale/extensions/extensions.properties";
 
+const DARK_THEME_ID    = "firefox-compact-dark@mozilla.org";
 const DEFAULT_THEME_ID = "default-theme@mozilla.org";
 const DEFAULT_MAX_USED_THEMES_COUNT = 30;
 
 const MAX_PREVIEW_SECONDS = 30;
 
 const MANDATORY = ["id", "name"];
 const OPTIONAL = ["headerURL", "footerURL", "textcolor", "accentcolor",
                   "iconURL", "previewURL", "author", "description",
@@ -67,16 +68,20 @@ XPCOMUtils.defineLazyPreferenceGetter(th
 // permissions and pendingOperations
 var _themeIDBeingEnabled = null;
 var _themeIDBeingDisabled = null;
 
 // Holds optional fallback theme data that will be returned when no data for an
 // active theme can be found. This the case for WebExtension Themes, for example.
 var _fallbackThemeData = null;
 
+// Holds whether or not the default theme should display in dark mode. This is
+// typically the case when the OS has a dark system appearance.
+var _defaultThemeIsInDarkMode = false;
+
 // Convert from the old storage format (in which the order of usedThemes
 // was combined with isThemeSelected to determine which theme was selected)
 // to the new one (where a selectedThemeID determines which theme is selected).
 (function() {
   let wasThemeSelected = _prefs.getBoolPref("isThemeSelected", false);
 
   if (wasThemeSelected) {
     _prefs.clearUserPref("isThemeSelected");
@@ -130,18 +135,25 @@ var LightweightThemeManager = {
     if (selectedThemeID) {
       data = this.getUsedTheme(selectedThemeID);
     }
     return data;
   },
 
   get currentThemeForDisplay() {
     var data = this.currentTheme;
-    if ((!data || data.id == DEFAULT_THEME_ID) && _fallbackThemeData)
-      data = _fallbackThemeData;
+
+    if (!data || data.id == DEFAULT_THEME_ID) {
+      if (_fallbackThemeData) {
+        return _fallbackThemeData;
+      }
+      if (_defaultThemeIsInDarkMode) {
+        return this.getUsedTheme(DARK_THEME_ID);
+      }
+    }
 
     if (data && PERSIST_ENABLED) {
       for (let key in PERSIST_FILES) {
         try {
           if (data[key] && _prefs.getBoolPref("persisted." + key))
             data[key] = _getLocalImageURI(PERSIST_FILES[key]).spec
                         + "?" + data.id + ";" + _version(data);
         } catch (e) {}
@@ -357,33 +369,39 @@ var LightweightThemeManager = {
    */
   themeChanged(aData) {
     if (_previewTimer) {
       _previewTimer.cancel();
       _previewTimer = null;
     }
 
     if (aData) {
-      let usedThemes = _usedThemesExceptId(aData.id);
-      usedThemes.unshift(aData);
+      _prefs.setCharPref("selectedThemeID", aData.id);
+    } else {
+      _prefs.setCharPref("selectedThemeID", "");
+    }
+
+    let themeToSwitchTo = aData;
+    if (aData && aData.id == DEFAULT_THEME_ID && _defaultThemeIsInDarkMode) {
+      themeToSwitchTo = LightweightThemeManager.getUsedTheme(DARK_THEME_ID);
+    }
+
+    if (themeToSwitchTo) {
+      let usedThemes = _usedThemesExceptId(themeToSwitchTo.id);
+      usedThemes.unshift(themeToSwitchTo);
       _updateUsedThemes(usedThemes);
       if (PERSIST_ENABLED) {
         LightweightThemeImageOptimizer.purge();
-        _persistImages(aData, () => {
+        _persistImages(themeToSwitchTo, () => {
           _notifyWindows(this.currentThemeForDisplay);
         });
       }
     }
 
-    if (aData)
-      _prefs.setCharPref("selectedThemeID", aData.id);
-    else
-      _prefs.setCharPref("selectedThemeID", "");
-
-    _notifyWindows(aData);
+    _notifyWindows(themeToSwitchTo);
     Services.obs.notifyObservers(null, "lightweight-theme-changed");
   },
 
   /**
    * Starts the Addons provider and enables the new lightweight theme if
    * necessary.
    */
   startup() {
@@ -443,16 +461,68 @@ var LightweightThemeManager = {
       this.themeChanged(theme);
       AddonManagerPrivate.callAddonListeners("onEnabled", wrapper);
 
       _themeIDBeingEnabled = null;
     }
   },
 
   /**
+   * Called when the system has either switched to, or switched away from a dark
+   * theme.
+   *
+   * @param  aEvent
+   *         The MediaQueryListEvent associated with the system theme change.
+   */
+  systemThemeChanged(aEvent) {
+    let themeToSwitchTo = null;
+    if (aEvent.matches && !_defaultThemeIsInDarkMode) {
+      themeToSwitchTo = this.getUsedTheme(DARK_THEME_ID);
+      _defaultThemeIsInDarkMode = true;
+    } else if (!aEvent.matches && _defaultThemeIsInDarkMode) {
+      themeToSwitchTo = this.getUsedTheme(DEFAULT_THEME_ID);
+      _defaultThemeIsInDarkMode = false;
+    } else {
+      // We are already set to the correct mode. Bail out early.
+      return;
+    }
+
+    if (_prefs.getStringPref("selectedThemeID", "") != DEFAULT_THEME_ID) {
+      return;
+    }
+
+    if (themeToSwitchTo) {
+      let usedThemes = _usedThemesExceptId(themeToSwitchTo.id);
+      usedThemes.unshift(themeToSwitchTo);
+      _updateUsedThemes(usedThemes);
+      if (PERSIST_ENABLED) {
+        LightweightThemeImageOptimizer.purge();
+        _persistImages(themeToSwitchTo, () => {
+          _notifyWindows(this.currentThemeForDisplay);
+        });
+      }
+    }
+
+    _notifyWindows(themeToSwitchTo);
+    Services.obs.notifyObservers(null, "lightweight-theme-changed");
+  },
+
+  /**
+   * Handles system theme changes.
+   *
+   * @param  aEvent
+   *         The MediaQueryListEvent associated with the system theme change.
+   */
+  handleEvent(aEvent) {
+    if (aEvent.media == "(-moz-system-dark-theme)") {
+      this.systemThemeChanged(aEvent);
+    }
+  },
+
+  /**
    * Called to get an Addon with a particular ID.
    *
    * @param  aId
    *         The ID of the add-on to retrieve
    */
   async getAddonByID(aId) {
     let id = _getInternalID(aId);
     if (!id) {
--- a/widget/LookAndFeel.h
+++ b/widget/LookAndFeel.h
@@ -426,17 +426,23 @@ public:
       * contain a maximize button.
       */
      eIntID_GTKCSDMaximizeButton,
 
      /*
       * A boolean value indicating whether client-side decorations should
       * contain a close button.
       */
-     eIntID_GTKCSDCloseButton
+     eIntID_GTKCSDCloseButton,
+
+     /*
+      * A boolean value indicating whether or not the OS is using a dark theme,
+      * which we may want to switch to as well if not overridden by the user.
+      */
+     eIntID_SystemUsesDarkTheme
   };
 
   /**
    * Windows themes we currently detect.
    */
   enum WindowsTheme {
     eWindowsTheme_Generic = 0, // unrecognized theme
     eWindowsTheme_Classic,
--- a/widget/cocoa/nsChildView.mm
+++ b/widget/cocoa/nsChildView.mm
@@ -3339,16 +3339,23 @@ NSEvent* gLastDragMouseDownEvent = nil;
                                                           name:@"AppleAquaScrollBarVariantChanged"
                                                         object:nil
                                             suspensionBehavior:NSNotificationSuspensionBehaviorDeliverImmediately];
   [[NSNotificationCenter defaultCenter] addObserver:self
                                            selector:@selector(_surfaceNeedsUpdate:)
                                                name:NSViewGlobalFrameDidChangeNotification
                                              object:self];
 
+  [[NSDistributedNotificationCenter defaultCenter]
+           addObserver:self
+              selector:@selector(systemMetricsChanged)
+                  name:@"AppleInterfaceThemeChangedNotification"
+                object:nil
+    suspensionBehavior:NSNotificationSuspensionBehaviorDeliverImmediately];
+
   return self;
 
   NS_OBJC_END_TRY_ABORT_BLOCK_NIL;
 }
 
 // ComplexTextInputPanel's interpretKeyEvent hack won't work without this.
 // It makes calls to +[NSTextInputContext currentContext], deep in system
 // code, return the appropriate context.
--- a/widget/cocoa/nsLookAndFeel.h
+++ b/widget/cocoa/nsLookAndFeel.h
@@ -32,16 +32,18 @@ public:
 
   virtual nsTArray<LookAndFeelInt> GetIntCacheImpl() override;
   virtual void SetIntCacheImpl(const nsTArray<LookAndFeelInt>& aLookAndFeelIntCache) override;
 
 protected:
   static bool SystemWantsOverlayScrollbars();
   static bool AllowOverlayScrollbarsOverlap();
 
+  static bool SystemWantsDarkTheme();
+
 private:
   int32_t mUseOverlayScrollbars;
   bool mUseOverlayScrollbarsCached;
 
   int32_t mAllowOverlayScrollbarsOverlap;
   bool mAllowOverlayScrollbarsOverlapCached;
 
   nscolor mColorTextSelectBackground;
--- a/widget/cocoa/nsLookAndFeel.mm
+++ b/widget/cocoa/nsLookAndFeel.mm
@@ -536,16 +536,19 @@ nsLookAndFeel::GetIntImpl(IntID aID, int
       }
       break;
     case eIntID_ContextMenuOffsetVertical:
       aResult = -6;
       break;
     case eIntID_ContextMenuOffsetHorizontal:
       aResult = 1;
       break;
+    case eIntID_SystemUsesDarkTheme:
+      aResult = SystemWantsDarkTheme();
+      break;
     default:
       aResult = 0;
       res = NS_ERROR_FAILURE;
   }
   return res;
 
   NS_OBJC_END_TRY_ABORT_BLOCK_NSRESULT;
 }
@@ -584,16 +587,24 @@ bool nsLookAndFeel::SystemWantsOverlaySc
           [NSScroller preferredScrollerStyle] == mozNSScrollerStyleOverlay);
 }
 
 bool nsLookAndFeel::AllowOverlayScrollbarsOverlap()
 {
   return (UseOverlayScrollbars());
 }
 
+bool nsLookAndFeel::SystemWantsDarkTheme()
+{
+  // This returns true if the macOS system appearance is set to dark mode, false
+  // otherwise.
+  return !![[NSUserDefaults standardUserDefaults]
+             stringForKey:@"AppleInterfaceStyle"];
+}
+
 bool
 nsLookAndFeel::GetFontImpl(FontID aID, nsString &aFontName,
                            gfxFontStyle &aFontStyle,
                            float aDevPixPerCSSPixel)
 {
     NS_OBJC_BEGIN_TRY_ABORT_BLOCK_RETURN;
 
     // hack for now
--- a/widget/cocoa/nsNativeThemeCocoa.mm
+++ b/widget/cocoa/nsNativeThemeCocoa.mm
@@ -4358,17 +4358,18 @@ nsNativeThemeCocoa::WidgetStateChanged(n
 
   return NS_OK;
 }
 
 NS_IMETHODIMP
 nsNativeThemeCocoa::ThemeChanged()
 {
   // This is unimplemented because we don't care if gecko changes its theme
-  // and Mac OS X doesn't have themes.
+  // and macOS system appearance changes are handled by
+  // nsLookAndFeel::SystemWantsDarkTheme.
   return NS_OK;
 }
 
 bool
 nsNativeThemeCocoa::ThemeSupportsWidget(nsPresContext* aPresContext, nsIFrame* aFrame,
                                       uint8_t aWidgetType)
 {
   // if this is a dropdown button in a combobox the answer is always no
--- a/widget/nsXPLookAndFeel.cpp
+++ b/widget/nsXPLookAndFeel.cpp
@@ -129,16 +129,19 @@ nsLookAndFeelIntPref nsXPLookAndFeel::sI
   { "ui.GtkCSDMinimizeButton",
     eIntID_GTKCSDMinimizeButton,
     false, 0 },
   { "ui.GtkCSDMaximizeButton",
     eIntID_GTKCSDMaximizeButton,
     false, 0 },
   { "ui.GtkCSDCloseButton",
     eIntID_GTKCSDCloseButton,
+    false, 0 },
+  { "ui.systemUsesDarkTheme",
+    eIntID_SystemUsesDarkTheme,
     false, 0 }
 };
 
 nsLookAndFeelFloatPref nsXPLookAndFeel::sFloatPrefs[] =
 {
   { "ui.IMEUnderlineRelativeSize",
     eFloatID_IMEUnderlineRelativeSize,
     false, 0 },
--- a/xpcom/ds/nsGkAtomList.h
+++ b/xpcom/ds/nsGkAtomList.h
@@ -2002,16 +2002,17 @@ GK_ATOM(windows_compositor, "windows-com
 GK_ATOM(windows_glass, "windows-glass")
 GK_ATOM(touch_enabled, "touch-enabled")
 GK_ATOM(menubar_drag, "menubar-drag")
 GK_ATOM(swipe_animation_enabled, "swipe-animation-enabled")
 GK_ATOM(gtk_csd_available, "gtk-csd-available")
 GK_ATOM(gtk_csd_minimize_button, "gtk-csd-minimize-button")
 GK_ATOM(gtk_csd_maximize_button, "gtk-csd-maximize-button")
 GK_ATOM(gtk_csd_close_button, "gtk-csd-close-button")
+GK_ATOM(system_dark_theme, "system-dark-theme")
 
 // windows theme selector metrics
 GK_ATOM(windows_classic, "windows-classic")
 GK_ATOM(windows_theme_aero, "windows-theme-aero")
 GK_ATOM(windows_theme_aero_lite, "windows-theme-aero-lite")
 GK_ATOM(windows_theme_luna_blue, "windows-theme-luna-blue")
 GK_ATOM(windows_theme_luna_olive, "windows-theme-luna-olive")
 GK_ATOM(windows_theme_luna_silver, "windows-theme-luna-silver")
@@ -2052,16 +2053,17 @@ GK_ATOM(_moz_menubar_drag, "-moz-menubar
 GK_ATOM(_moz_device_pixel_ratio, "-moz-device-pixel-ratio")
 GK_ATOM(_moz_device_orientation, "-moz-device-orientation")
 GK_ATOM(_moz_is_resource_document, "-moz-is-resource-document")
 GK_ATOM(_moz_swipe_animation_enabled, "-moz-swipe-animation-enabled")
 GK_ATOM(_moz_gtk_csd_available, "-moz-gtk-csd-available")
 GK_ATOM(_moz_gtk_csd_minimize_button, "-moz-gtk-csd-minimize-button")
 GK_ATOM(_moz_gtk_csd_maximize_button, "-moz-gtk-csd-maximize-button")
 GK_ATOM(_moz_gtk_csd_close_button, "-moz-gtk-csd-close-button")
+GK_ATOM(_moz_system_dark_theme, "-moz-system-dark-theme")
 
 // application commands
 GK_ATOM(Back, "Back")
 GK_ATOM(Forward, "Forward")
 GK_ATOM(Reload, "Reload")
 GK_ATOM(Stop, "Stop")
 GK_ATOM(Search, "Search")
 GK_ATOM(Bookmarks, "Bookmarks")