Skip to content

About

Dark mode for wxPython that covers the whole window and does not break your app for screen reader users.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

4 Commits

Folders and files

Repository files navigation

Accessible WxPython Dark Mode

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.git
import 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.auto

Why this exists

Dark 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) via SetWindowTheme. A wx.CheckBox stays a Win32 BUTTON with BS_AUTOCHECKBOX and keeps reporting ROLE_SYSTEM_CHECKBUTTON with a real checked state.
  • Colours are applied explicitly to every widget, using the SetOwn*Colour variants 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.

Verify it, don't take my word for it

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.

Contrast is checked, not assumed

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).

Your accessibility settings win

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.

Windows created later

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.

Following the system theme

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.

API

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.

On backdrop

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.

Custom-drawn widgets of your own

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 support

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.

Known limitations

These are wxWidgets/Win32 constraints, listed plainly rather than papered over:

  • Notebook tab strips stay light. SysTabControl32 has no dark theme parts on any current Windows build. Darkening it would mean owner-drawing the tabs, which costs them their ROLE_SYSTEM_PAGETAB. Use wx.Simplebook or wx.lib.agw.aui if you need dark tabs.
  • Native dialogs are not themable. wx.MessageBox, wx.FileDialog and friends are drawn by Windows and follow the OS theme only. Use wxdarkmode.message_box() for messages; file dialogs are unavoidable.
  • wx.SpinCtrl needs an explicit accessible name. Its exposed handle is the spin button, so a preceding label cannot reach it. Use set_accessible_name().
  • wx.html.HtmlWindow colours come from the markup. Only the background is ours; set colours in your HTML or CSS.
  • wx.stc.StyledTextCtrl with a lexer keeps its own scheme. Only the caret, selection and margins are themed, so your syntax highlighting is not trampled.

The undocumented parts

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.

Development

git clone https://github.com/alexoloopios/accessible-wxpython-darkmode.git
cd accessible-wxpython-darkmode
pip install -e ".[dev]"
pytest
python examples/demo.py

The 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.

Licence

MIT.

About

Dark mode for wxPython that covers the whole window and does not break your app for screen reader users.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages