Bug 1466335: Automatically switch to the appropriate Firefox theme based on the macOS dark mode system preference. r=dao,mstange
authorStephen A Pohl <spohl.mozilla.bugs@gmail.com>
Wed, 27 Jun 2018 13:59:21 -0400
changeset 481771 24fe98c45aae15108532297e7fceb70486888e24
parent 481770 8904c9386abf5316d72be0457d1651f7af9d0865
child 481772 760705f94da446d77749544cd5bd8602cb7367a9
push id1815
push userffxbld-merge
push dateMon, 15 Oct 2018 10:40:45 +0000
treeherdermozilla-release@18d4c09e9378 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdao, mstange
bugs1466335
milestone63.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1466335: Automatically switch to the appropriate Firefox theme based on the macOS dark mode system preference. r=dao,mstange
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/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");
@@ -357,33 +362,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 +454,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")