diff --git a/source/config/configFlags.py b/source/config/configFlags.py index ed9ca62586f..a975f0942b8 100644 --- a/source/config/configFlags.py +++ b/source/config/configFlags.py @@ -253,6 +253,27 @@ def _displayStringLabels(self): } +@unique +class ColorTheme(DisplayStringStrEnum): + """Enumeration for what foreground and background colors to use.""" + + AUTO = "auto" + DARK = "dark" + LIGHT = "light" + + @property + def _displayStringLabels(self): + return { + # Translators: One of the color theme choices in the visual settings category panel (this choice uses the system's Dark Mode setting). + self.AUTO: _("Auto"), + # Translators: One of the color theme choices in the visual settings category panel (this choice uses light background with dark text). + self.LIGHT: _("Light"), + # Translators: One of the color theme choices in the visual settings category panel (this choice uses dark background with light text). + self.DARK: _("Dark"), + } + + +@unique class ParagraphStartMarker(DisplayStringStrEnum): NONE = "" SPACE = " " diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 296b773a020..55195428942 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -27,6 +27,8 @@ #possible log levels are DEBUG, IO, DEBUGWARNING, INFO loggingLevel = string(default="INFO") showWelcomeDialogAtStartup = boolean(default=true) + colorTheme = option("auto", "light", "dark", default="light") + darkModeCanUseUndocumentedAPIs = boolean(default=false) # Speech settings [speech] diff --git a/source/core.py b/source/core.py index 7cbe4fa7a12..bfc876b96d7 100644 --- a/source/core.py +++ b/source/core.py @@ -588,6 +588,7 @@ def _setUpWxApp() -> "wx.App": import config import nvwave import speech + import gui.guiHelper log.info(f"Using wx version {wx.version()} with six version {six.__version__}") @@ -611,6 +612,18 @@ def InitLocale(self): """ pass + def FilterEvent(self, event: wx.Event): + """FilterEvent is called for every UI event in the entire application. Keep it quick to + avoid slowing everything down.""" + try: + if isinstance(event, wx.WindowCreateEvent): + gui.darkMode.handleEvent(event.EventObject, event.EventType) + elif isinstance(event, wx.ShowEvent) and event.IsShown: + gui.darkMode.handleEvent(event.EventObject, event.EventType) + except Exception: + log.exception("Error applying dark mode") + return -1 + app = App(redirect=False) # We support queryEndSession events, but in general don't do anything for them. diff --git a/source/gui/__init__.py b/source/gui/__init__.py index 7360c83106e..b87cbe07f7a 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -858,6 +858,13 @@ def initialize(): global mainFrame if mainFrame: raise RuntimeError("GUI already initialized") + + from gui import darkMode + + # Dark mode must be initialized before creating main frame + # otherwise context menus will not be styled correctly + darkMode.initialize() + mainFrame = MainFrame() wxLang = core.getWxLangOrNone() if wxLang: diff --git a/source/gui/darkMode.py b/source/gui/darkMode.py new file mode 100644 index 00000000000..eff81bd0d89 --- /dev/null +++ b/source/gui/darkMode.py @@ -0,0 +1,208 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2016-2024 NV Access Limited, Ɓukasz Golonka +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Dark mode makes UI elements have a dark background with light text. + +This is a best-effort attempt to implement dark mode. There are some remaining known issues: + +1) MessageBox'es are not themed. An example is the NVDA About dialog. These dialogs +are extremely modal, and there is no way to gain control until after the user dismisses +the message box. + +2) Menu bars are not themed. An example can be seen in the Debug Log. Supporting themed +menu bars would require intercepting several undocumented events and drawing the menu items +ourselves. An example implementation is described in +https://github.com/adzm/win32-custom-menubar-aero-theme + +3) Column titles are not themed. An example can be seen in the Dictionary dialogs. +This is implemented by the wx.ListCtrl class. The C++ implementation of +wxListCtrl::OnPaint hardcodes penColour, and there is no way to override it. +See https://github.com/wxWidgets/wxWidgets/blob/master/src/msw/listctrl.cpp + +4) Tab controls are not themed. An example can be seen at the top of the Add-In Store. +This is implemented by the wx.Notebook class. I have not been able to figure out how +to influence the colors it uses. + +Note: Config settings must be in a non-profile-specific config section (e.g. "general"). +Profile-specific config sections (e.g. "vision") aren't available to read until after +the main app window is created. But _SetPreferredAppMode must be called BEFORE the main +window is created in order for popup context menus to be properly styled. +""" + +import ctypes.wintypes +from typing import ( + Generator, +) + +import config +from config.configFlags import ColorTheme +import ctypes +import ctypes.wintypes as wintypes +import logging +import wx + + +_initialized = False + + +# Documented windows APIs +_DwmSetWindowAttribute = None +_SetWindowTheme = None +_SendMessageW = None +DWMWA_USE_IMMERSIVE_DARK_MODE = 20 +WM_THEMECHANGED = 0x031A + + +# Undocumented windows APIs adapted from https://github.com/ysc3839/win32-darkmode +_SetPreferredAppMode = None + + +def initialize(): + global _initialized + _initialized = True + + try: + global _SetWindowTheme + uxtheme = ctypes.cdll.LoadLibrary("uxtheme") + _SetWindowTheme = uxtheme.SetWindowTheme + _SetWindowTheme.restype = ctypes.HRESULT + _SetWindowTheme.argtypes = [wintypes.HWND, wintypes.LPCWSTR, wintypes.LPCWSTR] + + global _DwmSetWindowAttribute + dwmapi = ctypes.cdll.LoadLibrary("dwmapi") + _DwmSetWindowAttribute = dwmapi.DwmSetWindowAttribute + _DwmSetWindowAttribute.restype = ctypes.HRESULT + _DwmSetWindowAttribute.argtypes = [wintypes.HWND, wintypes.DWORD, wintypes.LPCVOID, wintypes.DWORD] + + global _SendMessageW + user32 = ctypes.cdll.LoadLibrary("user32") + _SendMessageW = user32.SendMessageW + _SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] + except Exception as err: + logging.debug("Error initializing dark mode: " + str(err)) + + try: + global _SetPreferredAppMode + _SetPreferredAppMode = uxtheme[135] + _SetPreferredAppMode.restype = wintypes.INT + _SetPreferredAppMode.argtypes = [wintypes.INT] + except Exception as err: + logging.debug("Will not use undocumented windows api SetPreferredAppMode: " + str(err)) + + +def DwmSetWindowAttribute_ImmersiveDarkMode(window: wx.Window, isDark: bool): + """This makes title bars dark""" + if _DwmSetWindowAttribute: + try: + useDarkMode = ctypes.wintypes.BOOL(isDark) + _DwmSetWindowAttribute( + window.Handle, + DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(useDarkMode), + ctypes.sizeof(ctypes.c_int32), + ) + except Exception as err: + logging.debug("Error calling DwmSetWindowAttribute: " + str(err)) + + +def SetPreferredAppMode(curTheme: ColorTheme): + """This makes popup context menus dark""" + if _SetPreferredAppMode and config.conf["general"]["darkModeCanUseUndocumentedAPIs"]: + try: + if curTheme == ColorTheme.AUTO: + _SetPreferredAppMode(1) + elif curTheme == ColorTheme.DARK: + _SetPreferredAppMode(2) + else: + _SetPreferredAppMode(0) + except Exception as err: + logging.debug("Error calling SetPreferredAppMode: " + str(err)) + + +def SetWindowTheme(window: wx.Window, theme: str): + if _SetWindowTheme and _SendMessageW: + try: + _SetWindowTheme(window.Handle, theme, None) + _SendMessageW(window.Handle, WM_THEMECHANGED, 0, 0) + except Exception as err: + logging.debug("Error calling SetWindowTheme: " + str(err)) + + +def _getDescendants(window: wx.Window) -> Generator[wx.Window, None, None]: + yield window + if hasattr(window, "GetChildren"): + for child in window.GetChildren(): + for descendant in _getDescendants(child): + yield descendant + + +def handleEvent(window: wx.Window, eventType): + if not _initialized: + return + curTheme = config.conf["general"]["colorTheme"] + if curTheme == ColorTheme.AUTO: + systemAppearance: wx.SystemAppearance = wx.SystemSettings.GetAppearance() + isDark = systemAppearance.IsDark() or systemAppearance.IsUsingDarkBackground() + else: + isDark = curTheme == ColorTheme.DARK + if isDark: + fgColor, bgColor, themePrefix = "White", "Dark Grey", "DarkMode" + else: + fgColor, bgColor, themePrefix = "Black", "Very Light Grey", "LightMode" + + if eventType == wx.wxEVT_CREATE: + SetPreferredAppMode(curTheme) + + # For some controls, colors must be set in EVT_CREATE otherwise it has no effect. + if isinstance(window, wx.CheckListBox): + # Unfortunately CheckListBoxes always seem to use a black foreground color for the labels, + # which means they become illegible if you make the background too dark. So we compromise + # by setting the background to be a little bit darker while still being readable. + if isDark: + window.SetBackgroundColour("Light Grey") + else: + window.SetBackgroundColour("White") + window.SetForegroundColour(fgColor) + elif isinstance(window, wx.TextCtrl) or isinstance(window, wx.ListCtrl): + window.SetBackgroundColour(bgColor) + # Foreground colors for TextCtrls are surprisingly tricky, because their behavior is + # inconsistent. In particular, the Add-On Store Details pane behaves differently than + # the Debug Log, Python Console, etc. Here is a table of what happens with different + # possibilites: + # + # Color Add-on Store Debug Log Usable? + # ----- ------------ --------- ------- + # white white black no + # light grey black white no + # yellow yellow white no + # 0xFEFEFE white white YES + # black black black YES + if isDark: + window.SetForegroundColour(wx.Colour(254, 254, 254)) + else: + window.SetForegroundColour("Black") + + elif eventType == wx.wxEVT_SHOW: + for child in _getDescendants(window): + child.SetBackgroundColour(bgColor) + child.SetForegroundColour(fgColor) + + if isinstance(child, wx.Frame) or isinstance(child, wx.Dialog): + DwmSetWindowAttribute_ImmersiveDarkMode(child, isDark) + elif ( + isinstance(child, wx.Button) + or isinstance(child, wx.ScrolledWindow) + or isinstance(child, wx.ToolTip) + or isinstance(child, wx.TextEntry) + ): + SetWindowTheme(child, themePrefix + "_Explorer") + elif isinstance(child, wx.Choice): + SetWindowTheme(child, themePrefix + "_CFD") + elif isinstance(child, wx.ListCtrl): + SetWindowTheme(child, themePrefix + "_ItemsView") + else: + SetWindowTheme(child, themePrefix) + + window.Refresh() diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 0bf76fbe12c..1421a9fff44 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -30,6 +30,7 @@ import config from config.configFlags import ( AddonsAutomaticUpdate, + ColorTheme, NVDAKey, ShowMessages, TetherTo, @@ -44,6 +45,7 @@ import systemUtils import gui import gui.contextHelp +import gui.darkMode import globalVars from logHandler import log import nvwave @@ -394,7 +396,7 @@ def makeSettings(self, sizer: wx.BoxSizer): raise NotImplementedError def onPanelActivated(self): - """Called after the panel has been activated (i.e. de corresponding category is selected in the list of categories). + """Called after the panel has been activated (i.e. the corresponding category is selected in the list of categories). For example, this might be used for resource intensive tasks. Sub-classes should extend this method. """ @@ -4988,10 +4990,14 @@ def _createProviderSettingsPanel( return None def makeSettings(self, settingsSizer: wx.BoxSizer): - self.initialProviders = vision.handler.getActiveProviderInfos() - self.providerPanelInstances = [] self.settingsSizerHelper = guiHelper.BoxSizerHelper(self, sizer=settingsSizer) self.settingsSizerHelper.addItem(wx.StaticText(self, label=self.panelDescription)) + self.makeDarkModeSettings(settingsSizer) + self.makeVisionProviderSettings(settingsSizer) + + def makeVisionProviderSettings(self, settingsSizer: wx.BoxSizer): + self.initialProviders = vision.handler.getActiveProviderInfos() + self.providerPanelInstances = [] for providerInfo in vision.handler.getProviderList(reloadFromSystem=True): providerSizer = self.settingsSizerHelper.addItem( @@ -5008,6 +5014,35 @@ def makeSettings(self, settingsSizer: wx.BoxSizer): providerSizer.Add(settingsPanel, flag=wx.EXPAND) self.providerPanelInstances.append(settingsPanel) + def makeDarkModeSettings(self, settingsSizer: wx.BoxSizer): + sizer = self.settingsSizerHelper.addItem( + # Translators: this is a label for a group of controls appearing on + # the vision settings panel. + wx.StaticBoxSizer(wx.VERTICAL, self, label=_("Dark Mode")), + flag=wx.EXPAND, + ) + sHelper = guiHelper.BoxSizerHelper(self, sizer=sizer) + self.colorThemeList = sHelper.addLabeledControl( + # Translators: label for a choice in the vision settings category panel + _("&Color theme"), + wx.Choice, + choices=[theme.displayString for theme in ColorTheme], + ) + self.darkModeCanUseUnsupportedAPIs = wx.CheckBox( + sizer.GetStaticBox(), + # Translators: label for a checkbox in the vision settings category panel + label=_("Allow use of undocumented windows APIs (unsafe)"), + ) + self.darkModeCanUseUnsupportedAPIs.Value = config.conf["general"]["darkModeCanUseUndocumentedAPIs"] + sHelper.addItem(self.darkModeCanUseUnsupportedAPIs) + self.bindHelpEvent("VisionSettingsColorTheme", self.colorThemeList) + curTheme = config.conf["general"]["colorTheme"] + for i, theme in enumerate(ColorTheme): + if theme == curTheme: + self.colorThemeList.SetSelection(i) + else: + log.debugWarning("Could not set color theme list to current theme") + def safeInitProviders( self, providers: List[vision.providerInfo.ProviderInfo], @@ -5081,6 +5116,10 @@ def onSave(self): except Exception: log.debug(f"Error saving providerPanel: {panel.__class__!r}", exc_info=True) self.initialProviders = vision.handler.getActiveProviderInfos() + colorTheme = list(ColorTheme)[self.colorThemeList.GetSelection()] + config.conf["general"]["colorTheme"] = colorTheme.value + config.conf["general"]["darkModeCanUseUndocumentedAPIs"] = self.darkModeCanUseUnsupportedAPIs.Value + gui.darkMode.handleEvent(self.TopLevelParent, wx.wxEVT_SHOW) class VisionProviderSubPanel_Settings( diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 7742c3ca1fd..93157a83ab8 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -270,6 +270,9 @@ Unicode CLDR has also been updated. * NVDA checks daily for add-on updates. * Only updates within the same channel will be checked (e.g. installed beta add-ons will only notify for updates in the beta channel). * Added support for the Help Tech Activator Pro displays. (#16668) +* Added support for Light and Dark color themes. (#16683) + * The default behavior is to match your system's "dark mode" setting. + * You can change this behavior in the Visual category of the NVDA Settings dialog. ### Changes diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 97ae2216ea3..55bcf07a9b8 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2544,7 +2544,7 @@ The check boxes in the Visual Highlight grouping control the behaviour of NVDA's * Highlight navigator object: toggles whether the [navigator object](#ObjectNavigation) will be highlighted. * Highlight browse mode cursor: Toggles whether the [virtual browse mode cursor](#BrowseMode) will be highlighted. -Note that checking and unchecking the "Enable Highlighting" check box wil also change the state of the tree other check boxes accordingly. +Note that checking and unchecking the "Enable Highlighting" check box will also change the state of the tree other check boxes accordingly. Therefore, if "Enable Highlighting" is off and you check this check box, the other tree check boxes will also be checked automatically. If you only want to highlight the focus and leave the navigator object and browse mode check boxes unchecked, the state of the "Enable Highlighting" check box will be half checked. @@ -2561,6 +2561,14 @@ You can always restore the warning by checking the "Always show a warning when l By default, sounds are played when the Screen Curtain is toggled. When you want to change this behaviour, you can uncheck the "Play sound when toggling Screen Curtain" check box. +##### Color Theme {#VisionSettingsColorTheme} + +You can control whether NVDA dialogs use a light background (the "Light" theme) or a dark background (the "Dark" theme). +The default theme is "Auto", which causes NVDA to match your operating system's "dark mode" setting. + +You can try out a different theme by changing the selected theme and then pressing the "Apply" button. +This will cause the color of the settings dialog to update immediately. + ##### Settings for third party visual aids {#VisionSettingsThirdPartyVisualAids} Additional vision enhancement providers can be provided in [NVDA add-ons](#AddonsManager).