Metadata-Version: 2.4
Name: accessible-wxpython-darkmode
Version: 1.0.0
Summary: Accessible dark mode for wxPython: full coverage, native controls, screen-reader roles intact.
Author: Alex
License: MIT
Project-URL: Homepage, https://github.com/alexoloopios/accessible-wxpython-darkmode
Project-URL: Issues, https://github.com/alexoloopios/accessible-wxpython-darkmode/issues
Keywords: wxpython,wx,dark mode,darkmode,accessibility,a11y,screen reader,nvda,narrator,wcag,windows,theme
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Win32 (MS Windows)
Classifier: Environment :: X11 Applications :: GTK
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: Microsoft :: Windows :: Windows 10
Classifier: Operating System :: Microsoft :: Windows :: Windows 11
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Adaptive Technologies
Classifier: Topic :: Software Development :: User Interfaces
Classifier: Topic :: Software Development :: Widget Sets
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: wxPython>=4.1.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Dynamic: license-file

# 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:

```bash
pip install git+https://github.com/alexoloopios/accessible-wxpython-darkmode.git
```

```python
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:

```python
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:

```python
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:

```python
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:

```python
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](https://github.com/marticliment/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:

```python
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

```bash
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.
