|
1 | 1 | # DarkTaskDialog-Native |
2 | | -Win32 TaskDialogIndirect with complete dark mode support — documented APIs, zero dependencies, no hooking. Works on Windows 10 (UIA + subclassing path) and Windows 11 / 25H2 (native DarkMode_* UxTheme path). |
| 2 | + |
| 3 | +> Win32 `TaskDialogIndirect` with complete dark mode support — |
| 4 | +> **zero dependencies · zero hooks · documented APIs only · MIT licensed.** |
| 5 | +> Windows 10 (UIA + subclassing + owner-draw) and Windows 11 (native `DarkMode_TaskDialog` panels + owner-drawn text). |
| 6 | +
|
| 7 | +[](https://docs.microsoft.com/windows) |
| 8 | +[](https://en.cppreference.com) |
| 9 | +[](LICENSE) |
| 10 | +[]() |
| 11 | +[]() |
| 12 | +[]() |
| 13 | + |
| 14 | +--- |
| 15 | + |
| 16 | +## Screenshots |
| 17 | + |
| 18 | +| Progress dialog | Expando | Expando + footer | Command links | Rtl + Nave | |
| 19 | +|:---:|:---:| :---:| :---:| :---:| |
| 20 | +|  |  |  |  |  | |
| 21 | + |
| 22 | +--- |
| 23 | +The only other public solution — |
| 24 | +[SFTRS/DarkTaskDialog](https://github.com/SFTRS/DarkTaskDialog) — hooks |
| 25 | +`DrawTheme*` APIs via **Microsoft Detours**. That requires a third-party build |
| 26 | +dependency and is GPL-3.0 licensed. |
| 27 | + |
| 28 | +This library uses **UI Automation**, **window subclassing**, **UxTheme**, and |
| 29 | +**`DrawThemeTextEx`** — all fully documented Win32 APIs — and adds features the |
| 30 | +Detours approach cannot support: |
| 31 | + |
| 32 | +| | SFTRS/DarkTaskDialog | **DarkTaskDialog-Native** | |
| 33 | +|---|:---:|:---:| |
| 34 | +| Dependency | Microsoft Detours | **None** | |
| 35 | +| Approach | `DrawTheme*` API hooking | UIA + subclassing + UxTheme | |
| 36 | +| Documented APIs only | ✅ | ✅ | |
| 37 | +| License | GPL-3.0 | **MIT** | |
| 38 | +--- |
| 39 | + |
| 40 | +## Quick start |
| 41 | + |
| 42 | +### Requirements |
| 43 | + |
| 44 | +| | Version | |
| 45 | +|---|---| |
| 46 | +| Windows SDK | 10.0.19041+ | |
| 47 | +| Visual Studio | 2022 (v143) | |
| 48 | +| C++ standard | C++17 | |
| 49 | +| Target OS | Windows 10 1809+ (build 17763) | |
| 50 | + |
| 51 | +### Integration |
| 52 | + |
| 53 | +**1.** Copy `DarkMode.h` and `DarkMode.cpp` into your project. |
| 54 | + |
| 55 | +**2.** Call once at startup, before any windows are created: |
| 56 | + |
| 57 | +```cpp |
| 58 | +#include "DarkMode.h" |
| 59 | + |
| 60 | +int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) |
| 61 | +{ |
| 62 | + CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); |
| 63 | + DarkMode::Init(); // reads OS dark-mode state; safe no-op on pre-Win10 |
| 64 | + // ... |
| 65 | +} |
| 66 | +``` |
| 67 | +
|
| 68 | +**3.** Add two lines to your `TaskDialogIndirect` callback: |
| 69 | +
|
| 70 | +```cpp |
| 71 | +static HRESULT CALLBACK MyCallback( |
| 72 | + HWND hwnd, UINT note, WPARAM wParam, LPARAM lParam, LONG_PTR dwRef) |
| 73 | +{ |
| 74 | + auto* pCfg = reinterpret_cast<TASKDIALOGCONFIG*>(dwRef); |
| 75 | + switch (note) |
| 76 | + { |
| 77 | + case TDN_CREATED: |
| 78 | + DarkMode::AllowForTaskDialog(hwnd, pCfg); // ← applies dark mode |
| 79 | + break; |
| 80 | + case TDN_NAVIGATED: |
| 81 | + // Required when using TDM_NAVIGATE_PAGE |
| 82 | + DarkMode::AllowForTaskDialog(hwnd, reinterpret_cast<TASKDIALOGCONFIG*>(lParam)); |
| 83 | + break; |
| 84 | + case TDN_DESTROYED: |
| 85 | + DarkMode::RemoveFromTaskDialog(hwnd); // ← frees per-dialog state |
| 86 | + break; |
| 87 | + } |
| 88 | + return S_OK; |
| 89 | +} |
| 90 | +
|
| 91 | +TASKDIALOGCONFIG cfg = { sizeof(cfg) }; |
| 92 | +cfg.pfCallback = MyCallback; |
| 93 | +cfg.lpCallbackData = (LONG_PTR)&cfg; |
| 94 | +TaskDialogIndirect(&cfg, &nButton, nullptr, nullptr); |
| 95 | +``` |
| 96 | + |
| 97 | +If the system is in light mode every call is a no-op. |
| 98 | +If the user switches themes while the dialog is open it adapts automatically. |
| 99 | + |
| 100 | +--- |
| 101 | + |
| 102 | +## API reference |
| 103 | + |
| 104 | +```cpp |
| 105 | +namespace DarkMode |
| 106 | +{ |
| 107 | + // Call once at startup. Reads OS theme state. |
| 108 | + bool Init(); |
| 109 | + |
| 110 | + // True when the OS is currently in dark mode. |
| 111 | + bool IsActive(); |
| 112 | + |
| 113 | + // True on Windows 11 where DarkMode_TaskDialog UxTheme class exists. |
| 114 | + bool HasNativeTaskDialogTheme(); |
| 115 | + |
| 116 | + // Sets DWMWA_USE_IMMERSIVE_DARK_MODE on a top-level window (dark title bar). |
| 117 | + void EnableForTLW(HWND hwnd); |
| 118 | + |
| 119 | + // Applies SetWindowTheme to any child control. |
| 120 | + void AllowForWindow(HWND hwnd, const wchar_t* themeClass = nullptr); |
| 121 | + |
| 122 | + // Main entry point. Call from TDN_CREATED (and TDN_NAVIGATED for page nav). |
| 123 | + void AllowForTaskDialog(HWND hwndTaskDialog, TASKDIALOGCONFIG* pConfig); |
| 124 | + |
| 125 | + // Call from TDN_DESTROYED to free per-dialog state. |
| 126 | + void RemoveFromTaskDialog(HWND hwndTaskDialog); |
| 127 | +} |
| 128 | +``` |
| 129 | + |
| 130 | +--- |
| 131 | + |
| 132 | +## How it works |
| 133 | + |
| 134 | +### The comctl32 UIFILE bug — why text needs owner-draw on every Windows version |
| 135 | + |
| 136 | +The TaskDialog layout engine reads its colours from a stylesheet embedded in |
| 137 | +`comctl32.dll` as resource 4255 (`UIFILE`). Every text element in that |
| 138 | +stylesheet queries colour via: |
| 139 | + |
| 140 | +``` |
| 141 | +foreground="gtc(TaskDialogStyle, <part>, 0, 3803)" |
| 142 | +``` |
| 143 | + |
| 144 | +The key is the class name: **`TaskDialogStyle`** — not `DarkMode_TaskDialogStyle`. |
| 145 | +`DarkMode_TaskDialogStyle` does not exist in any shipping version of Windows. |
| 146 | +Because `TaskDialogStyle` always returns light-mode colours, every text element |
| 147 | +paints black-on-dark even when the panel backgrounds are correctly dark. |
| 148 | + |
| 149 | + |
| 150 | +### Windows 11 (build 22000+) |
| 151 | + |
| 152 | +`DarkMode_TaskDialog` **does** exist — it covers all panel backgrounds and the |
| 153 | +expando glyph. `AllowForTaskDialog` walks the `DirectUIHWND` child tree, calls |
| 154 | +`SetWindowTheme(pane, L"DarkMode_TaskDialog", nullptr)` on each structural |
| 155 | +pane, then broadcasts `WM_THEMECHANGED`. comctl32 repaints the backgrounds via |
| 156 | +`dtb(DarkMode_TaskDialog, part, state)` natively. Text is then owner-drawn to |
| 157 | +fix the `TaskDialogStyle` colour bug described above. |
| 158 | + |
| 159 | +### Windows 10 (build 17763–19045) |
| 160 | + |
| 161 | +`DarkMode_TaskDialog` does not exist. The library: |
| 162 | + |
| 163 | +1. Uses **UI Automation** to walk the `DirectUIHWND` child tree, identifying |
| 164 | + each element by its `AutomationId` (the UIFILE atom names). |
| 165 | +2. Subclasses each pane with `WM_ERASEBKGND` / `WM_CTLCOLORDLG` / |
| 166 | + `WM_CTLCOLORSTATIC` handlers that return a dark brush with the correct |
| 167 | + `SetTextColor` per element type. |
| 168 | +3. Owner-draws all text elements with `DrawThemeTextEx` + `DTT_TEXTCOLOR` |
| 169 | + (background filled first since there is no native dark panel paint). |
| 170 | +4. Owner-draws the expando glyph via `DrawThemeBackground(TaskDialog, 13, state)` |
| 171 | + reading the current expand/collapse state from window properties |
| 172 | + (`"IsExpanded"`, `"IsChecked"`) seeded by `TDN_EXPANDO_BUTTON_CLICKED` / |
| 173 | + `TDN_VERIFICATION_CLICKED`. |
| 174 | + |
| 175 | +| Element | Windows 10 | Windows 11 | |
| 176 | +|---|---|---| |
| 177 | +| `PrimaryPanel` background | `WM_ERASEBKGND` dark brush | `dtb(DarkMode_TaskDialog, 1, 0)` ✅ | |
| 178 | +| `SecondaryPanel` background | `WM_ERASEBKGND` dark brush | `dtb(DarkMode_TaskDialog, 8, 0)` ✅ | |
| 179 | +| `FootnoteArea` background | `WM_ERASEBKGND` dark brush | `dtb(DarkMode_TaskDialog, 15, 0)` ✅ | |
| 180 | +| `SeparatorLine` background | dark brush | `dtb(DarkMode_TaskDialog, 17, 0)` ✅ | |
| 181 | +| Expando glyph | `dtb(TaskDialog, 13, state)` | `dtb(DarkMode_TaskDialog, 13, state)` ✅ | |
| 182 | +| **All text** (`gtc(TaskDialogStyle,*,*)`) | `FillRect` + `DrawThemeTextEx` + `DTT_TEXTCOLOR` | `DrawThemeText` + `DTT_TEXTCOLOR` | |
| 183 | + |
| 184 | +--- |
| 185 | + |
| 186 | +## UIA element reference |
| 187 | + |
| 188 | +These `AutomationId` strings come directly from the comctl32 UIFILE (resource |
| 189 | +4255, Windows 11 build 26100.7965). They are the **atom names** the DirectUI |
| 190 | +engine uses internally and are exactly what `IUIAutomationElement::get_CurrentAutomationId` |
| 191 | +returns when walking the `DirectUIHWND` tree. |
| 192 | + |
| 193 | +> **This is the only public cross-reference of these identifiers against their |
| 194 | +> UxTheme part IDs and resolved dark-mode colour values.** |
| 195 | +
|
| 196 | +### Root |
| 197 | + |
| 198 | +| AutomationId | Description | |
| 199 | +|---|---| |
| 200 | +| `"TaskDialog"` | The `DirectUIHWND` TaskPage window — UIA walk entry point | |
| 201 | + |
| 202 | +### Primary panel |
| 203 | + |
| 204 | +| AutomationId | UIFILE atom | Control type | UxTheme query | Dark colour | |
| 205 | +|---|---|---|---|---| |
| 206 | +| `MainIcon` | `atom(MainIcon)` | Image | — | No recolour | |
| 207 | +| `MainInstruction` | `atom(MainInstruction)` | Text | `gtc(TaskDialogStyle, 2, 0, 3803)` | `RGB(153, 235, 255)` | |
| 208 | +| `ContentText` | `atom(ContentText)` | Text | `gtc(TaskDialogStyle, 4, 0, 3803)` | `RGB(255, 255, 255)` | |
| 209 | +| `ContentLink` | `atom(ContentLink)` | Hyperlink | `gtc(TaskDialogStyle, 4, 0, 3803)` + `dtb(TaskDialog,1,0)` bg | `RGB(255, 255, 255)` | |
| 210 | +| `ExpandedInformationText` | `atom(ExpandedInformationText)` | Text | `gtc(TaskDialogStyle, 6, 0, 3803)` | `RGB(255, 255, 255)` | |
| 211 | +| `ExpandedInformationLink` | `atom(ExpandedInformationLink)` | Hyperlink | `gtc(TaskDialogStyle, 6, 0, 3803)` + `dtb(TaskDialog,1,0)` bg | `RGB(255, 255, 255)` | |
| 212 | +| `ExpandoButton` | `atom(ExpandoButton)` | Button | `dtb(TaskDialog, 13, state)` | Owner-drawn glyph | |
| 213 | +| `ExpandoTextExpanded` | `atom(ExpandoTextExpanded)` | Text | `gtc(TaskDialogStyle, 12, 0, 3803)` | `RGB(255, 255, 255)` | |
| 214 | +| `ExpandoTextCollapsed` | `atom(ExpandoTextCollapsed)` | Text | `gtc(TaskDialogStyle, 12, 0, 3803)` | `RGB(255, 255, 255)` | |
| 215 | +| `VerificationCheckBox` | `atom(VerificationCheckBox)` | CheckBox | — | System-themed | |
| 216 | +| `VerificationText` | `atom(VerificationText)` | Text | `gtc(TaskDialogStyle, 14, 0, 3803)` | `RGB(255, 255, 255)` | |
| 217 | +| `RadioButton_0` … `_N` | `class RadioButton` | RadioButton | `dtb(TaskDialog, 1, 0)` bg | System-themed | |
| 218 | +| `CommandLink_0` … `_N` | `class CommandLink` | Button | `dtb(TaskDialog, 1, 0)` bg | `DarkMode_Explorer` theme | |
| 219 | +| `CommandButton_0` … `_N` | `class CommandButton` | Button | — | `DarkMode_Explorer` theme | |
| 220 | +| `ProgressBar` | `atom(ProgressBar)` | ProgressBar | — | System-themed | |
| 221 | + |
| 222 | +### Secondary panel (button row) |
| 223 | + |
| 224 | +| AutomationId | UIFILE atom | UxTheme query | Dark colour | |
| 225 | +|---|---|---|---| |
| 226 | +| *(push buttons)* | `class CommandButton` | `dtb(TaskDialog, 8, 0)` bg | `DarkMode_Explorer` / `DarkMode_CFD` | |
| 227 | +| `ButtonArea` | `atom(ButtonArea)` | `gtc(TaskDialogStyle, **15**, 0, 3803)` ⚠️ | Uses footnote part — UIFILE quirk | |
| 228 | + |
| 229 | +### Separator |
| 230 | + |
| 231 | +| AutomationId | UIFILE atom | UxTheme query | Dark colour | |
| 232 | +|---|---|---|---| |
| 233 | +| `Separator` | `atom(Separator)` | `dtb(TaskDialog, 15, 0)` bg | `RGB(44, 44, 44)` | |
| 234 | +| `SeparatorLine` | `atom(SeparatorLine)` | `dtb(TaskDialog, 17, 0)` bg | `RGB(77, 77, 77)` | |
| 235 | + |
| 236 | +### Footnote / expanded footer panel |
| 237 | + |
| 238 | +| AutomationId | UIFILE atom | UxTheme query | Dark colour | |
| 239 | +|---|---|---|---| |
| 240 | +| `FootnoteIcon` | `atom(FootnoteIcon)` | — | No recolour | |
| 241 | +| `FootnoteText` | `atom(FootnoteText)` | `gtc(TaskDialogStyle, 15, 0, 3803)` | `RGB(224, 224, 224)` | |
| 242 | +| `FootnoteTextLink` | `atom(FootnoteTextLink)` | `gtc(TaskDialogStyle, 15, 0, 3803)` | `RGB(224, 224, 224)` | |
| 243 | +| `ExpandedFooterText` | `atom(ExpandedFooterText)` | `gtc(TaskDialogStyle, 18, 0, 3803)` | `RGB(224, 224, 224)` | |
| 244 | +| `ExpandedFooterTextLink` | `atom(ExpandedFooterTextLink)` | `gtc(TaskDialogStyle, **4**, 0, 3803)` ⚠️ | `RGB(224, 224, 224)` | |
| 245 | + |
| 246 | +> ⚠️ **`ExpandedFooterTextLink` uses part 4** (the content text part) instead |
| 247 | +> of part 18 in the UIFILE. This is a Microsoft bug — the wrong part ID is |
| 248 | +> hardcoded. The library handles this by checking the `AutomationId` and |
| 249 | +> applying part-18 colours regardless of what `gtc()` returns. |
| 250 | +
|
| 251 | +--- |
| 252 | + |
| 253 | +## FAQ |
| 254 | + |
| 255 | +**Does this work with the simple `TaskDialog()` overload?** |
| 256 | +`TaskDialog()` has no callback, so there is no `TDN_CREATED` hook point. |
| 257 | +Use `TaskDialogIndirect()` with a `TASKDIALOGCONFIG`. |
| 258 | + |
| 259 | +**Does `TDM_NAVIGATE_PAGE` work?** |
| 260 | +Yes — call `DarkMode::AllowForTaskDialog(hwnd, pNewConfig)` from `TDN_NAVIGATED`. |
| 261 | +The included `main.cpp` demonstrates page navigation to an Arabic RTL page. |
| 262 | + |
| 263 | +**Why does my text still appear black on Windows 11?** |
| 264 | +Because `DarkMode_TaskDialogStyle` does not exist — see the UIFILE bug section |
| 265 | +above. All text must be owner-drawn with `DTT_TEXTCOLOR`. If you call only |
| 266 | +`SetWindowTheme` without the text owner-draw path you will get dark panel |
| 267 | +backgrounds but black text. |
| 268 | + |
| 269 | +**I see a white flash when the dialog first opens.** |
| 270 | +Ensure `DarkMode::AllowForTaskDialog` is called from `TDN_CREATED`, not |
| 271 | +`TDN_DIALOG_CONSTRUCTED`. `TDN_CREATED` fires after the window is fully |
| 272 | +initialised. |
| 273 | + |
| 274 | +**Can I use this from MFC?** |
| 275 | +Yes — no MFC dependency. Override `DoMessageBox` and call |
| 276 | +`TaskDialogIndirect` directly. `DarkMode::Init()` can go in `InitInstance`. |
| 277 | + |
| 278 | +**What about pre-Windows 10?** |
| 279 | +`DarkMode::Init()` reads OS state at startup. All calls are silent no-ops |
| 280 | +on pre-Win10 systems. |
| 281 | + |
| 282 | +--- |
| 283 | + |
| 284 | +## Building |
| 285 | + |
| 286 | +```cmd |
| 287 | +git clone https://github.com/YourUsername/DarkTaskDialog-NoDetours.git |
| 288 | +cd DarkTaskDialog-NoDetours |
| 289 | +msbuild DarkTaskDialog-NoDetours.sln /p:Configuration=Release /p:Platform=x64 |
| 290 | +``` |
| 291 | + |
| 292 | +Links only against: `uxtheme.lib` `dwmapi.lib` `comctl32.lib` `shell32.lib` |
| 293 | +`uiautomationcore.lib` `msimg32.lib` — all part of the Windows SDK. |
| 294 | + |
| 295 | +--- |
| 296 | + |
| 297 | +## Related |
| 298 | + |
| 299 | +### Tools |
| 300 | + |
| 301 | +- [memoarfaa/TaskDialog-Stylesheet-Dumper](https://github.com/memoarfaa/TaskDialog-Stylesheet-Dumper) — |
| 302 | + Win32 tool that extracts and parses `comctl32.dll` resource 4255 (`UIFILE`) at |
| 303 | + runtime, then evaluates every `gtf()`, `gtc()`, `gtmar()`, `gtmet()`, and |
| 304 | + `dtb()` call live against `OpenThemeData(L"TaskDialog")` and |
| 305 | + `OpenThemeData(L"TaskDialogStyle")`. The resolved colour table and UIFILE |
| 306 | + bug findings documented in this README were produced with this tool. |
| 307 | + Uses only `IXmlReader`, `FindResourceW`, and `GetThemeColor` — no third-party |
| 308 | + dependencies. Output can be saved as XML. |
| 309 | + |
| 310 | +### Other implementations |
| 311 | + |
| 312 | +- [SFTRS/DarkTaskDialog](https://github.com/SFTRS/DarkTaskDialog) — |
| 313 | + alternative approach using Microsoft Detours to hook `DrawTheme*` APIs (GPL-3.0) |
| 314 | + |
| 315 | +### References |
| 316 | + |
| 317 | +- [Stack Overflow #79403975: Dark Mode Task Dialog](https://stackoverflow.com/questions/79403975/) |
| 318 | +- [TaskDialogIndirect — Win32 docs](https://docs.microsoft.com/windows/win32/api/commctrl/nf-commctrl-taskdialogindirect) |
| 319 | + |
| 320 | +--- |
| 321 | + |
| 322 | +## License |
| 323 | + |
| 324 | +MIT — see [LICENSE](LICENSE). |
0 commit comments