Dark mode for wxPython that covers the whole window and does not break your app for screen reader users.
Not on PyPI yet — install it straight from the repository:
pip install git+https://github.com/alexoloopios/accessible-wxpython-darkmode.gitimport wx
import wxdarkmode
wxdarkmode.enable()That is the entire integration. No colour values, no per-widget calls, no subclassing. If you would rather not even write the call:
import wxdarkmode.autoDark mode in wxPython usually goes wrong in two specific ways.
Screen readers stop describing your controls correctly. wxWidgets on
Windows can only honour a custom colour on a checkbox, radio button or push
button by owner-drawing it. wxCheckBox::SetForegroundColour() calls
MSWMakeOwnerDrawn() internally, which sets the BS_OWNERDRAW style — and an
owner-drawn BUTTON reports ROLE_SYSTEM_PUSHBUTTON to MSAA no matter what it
was before. NVDA and Narrator then announce your checkbox as "button", with no
checked or unchecked state. You did not replace the widget; one colour
assignment was enough.
It is worse than it looks, because wxWidgets will do this to you on its own.
wxWindowBase::InheritAttributes() copies a parent's foreground colour onto
each new child by calling SetForegroundColour() on it. Colour a panel, add a
checkbox to it afterwards, and that checkbox is owner-drawn before your code
ever touches it.
And it only covers part of the window. wx's background inheritance on MSW is partial and version-dependent, so a colour set on the frame reaches some widgets and not others. The usual fix is to hand-write colour values for every control type, which is exactly the work a dark mode library should be doing.
This library solves both:
- Nothing is ever replaced, owner-drawn, or hand-painted. Dark rendering
comes from pointing real native controls at the OS's own dark theme classes
(
DarkMode_Explorer,DarkMode_CFD,DarkMode_ItemsView) viaSetWindowTheme. Awx.CheckBoxstays a Win32BUTTONwithBS_AUTOCHECKBOXand keeps reportingROLE_SYSTEM_CHECKBUTTONwith a real checked state. - Colours are applied explicitly to every widget, using the
SetOwn*Colourvariants so nothing is marked inheritable and the trap above never springs. - You never name a colour. Containers, item views, headers, grids, scrollbars and the caption bar are all handled.
The library can read back the same MSAA information a screen reader uses, and tell you whether your window survived:
findings = wxdarkmode.audit(my_frame)
print(wxdarkmode.format_report(findings))2 finding(s): 1 error, 1 warning
[ERROR ] CheckBox 'Enable sync': announced as 'push button', expected 'check box'
(a screen reader will describe this control wrongly)
[WARNING] StaticText 'Hint': contrast 3.10:1 is below 4.5:1 (#8A8A8A on #202020)
It checks three things on every widget: the MSAA role against what the wx class should report, the accessible name of every interactive control, and the real measured contrast of the colours actually in place. Wire it into your own test suite:
def test_ui_is_accessible(my_frame):
errors = [f for f in wxdarkmode.audit(my_frame) if f.severity == "error"]
assert not errors, wxdarkmode.format_report(errors)This is also useful before you adopt the library, to see which of your controls are already mislabelled.
Every built-in palette is verified against WCAG 2.1 AA — 4.5:1 for body text, 3:1 for control borders and focus indicators — by the test suite, for each pair the applier actually puts on screen.
Custom palettes are repaired rather than trusted:
theme = wxdarkmode.DARK.derive(accent="#FF8A00", window="#1A1A1A")
wxdarkmode.enable(theme)derive() re-runs the contrast pass, so a too-dim custom colour is nudged until
it passes instead of silently shipping an unreadable window. A misspelled field
name raises TypeError rather than being ignored.
Three palettes ship: dark (default, sits next to Windows 11's own surfaces),
dark-high-contrast (pure black, AAA body text), and dark-dim (lower glare).
If a Windows High Contrast theme is active, the library does nothing at all. The user has already told the OS exactly what contrast they need, and painting a palette over that would override an assistive setting they deliberately chose.
On Windows builds older than 10 1809 (17763) the dark theme APIs do not exist,
so the only way to darken a checkbox would be to owner-draw it. Rather than
quietly break every toggle in your app, enable() warns and does nothing. A
light-but-correct window beats a dark-but-mislabelled one. Override with
preserve_roles=False if you disagree.
Dialogs, wizards and windows built long after enable() are themed
automatically, including ones opened from inside third-party code, via a global
wx.EventFilter. Windows are themed on show — before their first paint, so
there is no light flash. Widgets added to an existing window are picked up on
the next idle.
Calling enable() before your wx.App exists is worth doing: the
process-wide opt-in then happens before the first HWND is created, so your main
window is dark from its very first frame.
By default the library tracks the OS light/dark switch and re-themes when the
user flips it. Windows signals this with WM_SETTINGCHANGE/ImmersiveColorSet,
which wxWidgets 3.2 does not surface as a wx event, so a wx.Timer polls a
cached flag once a second on the main thread. Pass follow_system=False to turn
it off, or mode="dark" to ignore the OS entirely.
| Call | What it does |
|---|---|
enable(theme=None, **options) |
Turn on dark mode application-wide. Returns whether dark styling is now in force. |
disable() |
Restore the colours the app started with. |
apply(window, theme=None) |
Theme one window explicitly (for auto_apply=False). |
refresh() |
Re-sweep every top-level window. |
set_theme(theme) |
Swap palettes at runtime. |
audit(window) |
Report role, name and contrast problems. |
format_report(findings) |
Render findings as text. |
set_accessible_name(window, name) |
Name a control a label cannot reach. |
message_box(...) |
Themed, accessible stand-in for wx.MessageBox. |
is_dark(), is_enabled(), current_theme() |
State. |
system_uses_dark_mode(), is_high_contrast() |
OS state. |
Useful enable() options:
| Option | Default | Meaning |
|---|---|---|
mode |
"auto" |
"auto" follows the OS, "dark" forces dark, "light" forces original colours. |
follow_system |
True |
Re-apply when the user flips the OS switch. |
titlebar |
True |
Darken caption bars via DWM. |
auto_apply |
True |
Theme windows created later. |
preserve_roles |
True |
Never do anything that would cost a control its screen-reader role. |
respect_high_contrast |
True |
Stand down under a High Contrast theme. |
backdrop |
None |
Windows 11 material: "mica", "acrylic", "tabbed". |
on_change |
None |
Called with True/False when dark styling turns on or off. |
Mica and acrylic are off by default on purpose. A translucent window lets desktop content show through, which makes the effective contrast of every label depend on the user's wallpaper — unmeasurable, and able to drop below AA without anything in your code changing. Turn it on only where the text sits on an opaque surface.
If you want the material without the risk, wxdarkmode pairs fine with
win32mica, which is where the DWM
attribute numbers and build gates used here were cross-checked.
If you owner-draw something yourself, ask for the palette rather than hardcoding:
def on_paint(self, event):
theme = wxdarkmode.current_theme()
dc = wx.PaintDC(self)
dc.SetBackground(wx.Brush(theme.colour("surface")))
dc.SetTextForeground(theme.colour("text"))
...
wxdarkmode.enable(on_change=lambda dark: self.Refresh())theme.colour(role) returns a wx.Colour for any role: window, surface,
surface_alt, text, text_secondary, text_disabled, border,
border_subtle, accent, accent_text, selection, selection_text, error.
| Platform | Status |
|---|---|
| Windows 10 1809+ / Windows 11 | Full support: native dark controls, dark caption bars, roles preserved. |
| Windows older than 1809 | enable() declines, with a warning (see above). |
| Linux (wxGTK) | Colours applied; GTK controls have no owner-draw hazard. GTK's own dark theme is generally the better route. |
| macOS (wxOSX) | Colours applied; macOS handles dark mode natively, so this is mostly redundant. |
The MSAA role checks in audit() need 64-bit Python on Windows and report an
info finding rather than failing elsewhere.
These are wxWidgets/Win32 constraints, listed plainly rather than papered over:
- Notebook tab strips stay light.
SysTabControl32has no dark theme parts on any current Windows build. Darkening it would mean owner-drawing the tabs, which costs them theirROLE_SYSTEM_PAGETAB. Usewx.Simplebookorwx.lib.agw.auiif you need dark tabs. - Native dialogs are not themable.
wx.MessageBox,wx.FileDialogand friends are drawn by Windows and follow the OS theme only. Usewxdarkmode.message_box()for messages; file dialogs are unavoidable. wx.SpinCtrlneeds an explicit accessible name. Its exposed handle is the spin button, so a preceding label cannot reach it. Useset_accessible_name().wx.html.HtmlWindowcolours come from the markup. Only the background is ours; set colours in your HTML or CSS.wx.stc.StyledTextCtrlwith a lexer keeps its own scheme. Only the caret, selection and margins are themed, so your syntax highlighting is not trampled.
Dark native controls on Windows require uxtheme entry points that Microsoft
exports by ordinal only and has never documented — the same ones Notepad++ and
Windows Terminal use (SetPreferredAppMode, AllowDarkModeForWindow,
RefreshImmersiveColorPolicyState, FlushMenuThemes). Every call is bound
lazily, guarded by a build-number check, and wrapped so that a future Windows
release that moves them degrades to plain colour assignment rather than crashing
your app.
Set WXDARKMODE_NO_UNDOCUMENTED=1 to skip them entirely.
git clone https://github.com/alexoloopios/accessible-wxpython-darkmode.git
cd accessible-wxpython-darkmode
pip install -e ".[dev]"
pytest
python examples/demo.pyThe test suite asserts the accessibility promises directly: it builds real
controls, themes them, and checks the live MSAA role and the BS_OWNERDRAW
style bit — including the regression test for the inheritance trap described
above.
MIT.