Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Windows 10/11 Dark Mode #16908

Draft
wants to merge 35 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9584208
first draft at dark mode support for settings dialog
TristanBurchett Jul 20, 2024
c1abae3
respect custom themes that have bg darker than fg, even if they aren'…
TristanBurchett Jul 20, 2024
af23692
generalize dark mode beyond the settings dialogs to several other dia…
TristanBurchett Jul 20, 2024
5fe2f7e
tidy up the diff
TristanBurchett Jul 20, 2024
74c8134
lint
TristanBurchett Jul 20, 2024
127dd93
Added darkmode to changes.md.
Jul 25, 2024
8ca57af
The python console, add-on store, braille viewer, and speech viewer a…
Jul 28, 2024
ae9599e
link to wxPython issue for RadioButton not supporting SetForegroundCo…
TristanBurchett Jul 28, 2024
e627f94
Merge branch 'darkmode' of https://github.com/TristanBurchett/nvda in…
TristanBurchett Jul 28, 2024
81db549
Added dark mode capabilities for create portable copy and NVDA instal…
Jul 28, 2024
754e6cd
Make dark mode configurable
TristanBurchett Jul 29, 2024
4bcc01b
Merge branch 'darkmode' of https://github.com/TristanBurchett/nvda in…
TristanBurchett Jul 29, 2024
ea49b57
use FilterEvent to apply color theme, rather than explicitly enabling…
TristanBurchett Aug 3, 2024
bf83e69
Merge branch 'master' into darkmode
TristanBurchett Aug 3, 2024
76fd24d
Pre-commit auto-fix
pre-commit-ci[bot] Aug 3, 2024
e16e79d
Change speechDict.py so it works in dark mode
TristanBurchett Aug 3, 2024
faaf99a
make the Apply button work again when changing color theme
TristanBurchett Aug 3, 2024
d71db37
Pre-commit auto-fix
pre-commit-ci[bot] Aug 3, 2024
28ea900
update user guide
TristanBurchett Aug 4, 2024
4d60121
Merge branch 'darkmode' of https://github.com/TristanBurchett/nvda in…
TristanBurchett Aug 4, 2024
35cfaa1
update changes.md to indicate that dark mode is now configurable
TristanBurchett Aug 4, 2024
e5e4654
Major refactor to use actual windows APIs
TristanBurchett Aug 18, 2024
7c7c844
tidy up code, include TODOs
TristanBurchett Aug 18, 2024
9c723ac
Pre-commit auto-fix
pre-commit-ci[bot] Aug 18, 2024
9bf17c0
Fix TextCtrl foreground colors
TristanBurchett Aug 25, 2024
65ed8b8
Add comment for translators
TristanBurchett Aug 25, 2024
b3fefa9
ListCtrl needs the same foregroundColor logic as TextCtrl
TristanBurchett Aug 25, 2024
900f9b5
Add (probably unnecessary) error handling, to make CodeRabbit happy
TristanBurchett Aug 25, 2024
3fb781a
Pre-commit auto-fix
pre-commit-ci[bot] Aug 25, 2024
d0efee7
more exception handling, to make CodeRabbit happy
TristanBurchett Aug 25, 2024
3563f2d
Merge branch 'nvaccess:master' into darkmode
TristanBurchett Sep 14, 2024
b083b5c
revert changes to speechDict.py
TristanBurchett Nov 13, 2024
245f058
missed a spot in previous reversion of speechDict.py
TristanBurchett Nov 13, 2024
d3ad7cd
revert changes to configProfiles.py
TristanBurchett Nov 13, 2024
b0f1355
Merge branch 'nvaccess:master' into darkmode
TristanBurchett Nov 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions source/config/configFlags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = " "
Expand Down
2 changes: 2 additions & 0 deletions source/config/configSpec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 13 additions & 0 deletions source/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__}")

Expand All @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions source/gui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,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:
Expand Down
13 changes: 6 additions & 7 deletions source/gui/configProfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,14 +460,13 @@ def __init__(self, parent):
# in the new configuration profile dialog.
self.triggers = triggers = [(None, _("Manual activation"), True)]
triggers.extend(parent.getSimpleTriggers())
self.triggerChoice = sHelper.addItem(
wx.RadioBox(
self,
label=_("Use this profile for:"),
choices=[trig[1] for trig in triggers],
),
self.triggerChoice = sHelper.addLabeledControl(
_("Use this profile for:"),
wx.Choice,
choices=[trig[1] for trig in triggers],
)
self.triggerChoice.Bind(wx.EVT_RADIOBOX, self.onTriggerChoice)
self.triggerChoice.Bind(wx.EVT_CHOICE, self.onTriggerChoice)
self.triggerChoice.SetSelection(0)
self.autoProfileName = ""
self.onTriggerChoice(None)

Expand Down
208 changes: 208 additions & 0 deletions source/gui/darkMode.py
Original file line number Diff line number Diff line change
@@ -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()
TristanBurchett marked this conversation as resolved.
Show resolved Hide resolved
45 changes: 42 additions & 3 deletions source/gui/settingsDialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import config
from config.configFlags import (
AddonsAutomaticUpdate,
ColorTheme,
NVDAKey,
ShowMessages,
TetherTo,
Expand All @@ -42,6 +43,7 @@
import systemUtils
import gui
import gui.contextHelp
import gui.darkMode
import globalVars
from logHandler import log
import nvwave
Expand Down Expand Up @@ -392,7 +394,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.
"""
Expand Down Expand Up @@ -4724,10 +4726,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(
Expand All @@ -4744,6 +4750,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],
Expand Down Expand Up @@ -4817,6 +4852,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(
Expand Down
Loading