Тёмный режим и темы контрастности в приложениях Windows ── тёмная панель заголовка DWM, следование системной теме в WinForms/WPF, отрисовка при высокой контрастности

· Обновлено: · · Тёмный режим, Темы контрастности, Высокая контрастность, DWM, WinForms, WPF, Windows 11, Доступность, Бизнес-приложения, Windows

История изменений (первая версия, опубликована 2 Sep 2026)
Первая публикация

«Заменили служебные ПК на Windows 11, и сотрудники в тёмном режиме жалуются, что только у нашего бизнес-приложения панель заголовка ослепительно белая». «Сотрудник со слабовидением включил тему контрастности — и индикатор статуса на экране заказа пропал». Оба симптома за последние год-два стали чаще приходить как обращения.

Первый идёт от настройки «Цвета» в Windows 11, второй — от настроек специальных возможностей, но разработчику это выглядит одной проблемой: приложение не следует теме. И в самом деле фундамент решения общий. Не задавать цвета жёстко; прочитать системную настройку, заметить изменение и перерисовать. Эти три пункта.

В предыдущей статье «Доступность приложений Windows: UI Automation и разумное приспособление» разбирались механизм, через который программы чтения с экрана читают приложение (UI Automation), и основы имён, клавиатуры и цвета. Следование темам контрастности там затронуто, сам цветовой режим светлый/тёмный — нет. Эта статья — её парная: две оси — цветовой режим (светлый/тёмный) и темы контрастности — связывает на уровне реализации, в таком порядке: отрисовка панели заголовка диспетчером окон рабочего стола (DWM), следование системной теме в WinForms/WPF, отрисовка при теме контрастности. Читатели — разработчики, которые пишут и сопровождают бизнес-приложения на WinForms, WPF или Win32. Предпосылки — Windows 11 (тёмная панель заголовка — сборка 22000 и новее) и WinForms/WPF на .NET 9/10 (на .NET Framework 4.8 и .NET 8 часть реализуют вручную). Сложность — средняя.

Ход этой статьиСтруктура статьи, которая по порядку связывает две оси тем, почему по умолчанию светло, тёмную панель заголовка DWM, механизм обнаружения и следования, реализацию в WinForms и WPF, отрисовку при теме контрастности, выбор политики и проверкуРазобрать две оси темПочему по умолчанию светлоТёмная панель заголовка DWMОбнаружение и следованиеРеализация WinForms/WPFОтрисовка при теме контрастностиПолитика и проверка

Рис. 1: Статья идёт одной нитью от разбора тем через механизмы, реализацию, темы контрастности и проверку.

1. Сначала вывод

  • У «темы» Windows две оси. Светлый/тёмный (цветовой режим) в «Параметры > Персонализация > Цвета» и «Параметры > Специальные возможности > Темы контрастности». Вторая — палитра, ограниченная коэффициентом контрастности примерно 7:1 и выше, и это не то же самое, что светлый/тёмный. Пока тема контрастности включена, тёмный режим недоступен. Порядок проверки: «тема контрастности, затем светлый/тёмный».12
  • Белая панель заголовка у существующего приложения — умолчание ради совместимости. Windows не знает, поддерживает ли приложение тёмный режим, поэтому по умолчанию считает все окна светлыми.3
  • Панель заголовка делает тёмной DWMWA_USE_IMMERSIVE_DARK_MODE (значение 20) через DwmSetWindowAttribute. Передайте BOOL TRUE — рамка рисуется тёмной, когда система тёмная. Документированная поддержка — Windows 11 сборка 22000 и новее.43
  • Текущий режим читают UISettings.GetColorValue, изменения принимают через ColorValuesChanged. Официальная процедура Microsoft: если передний план (цвет текста по умолчанию) яркий — режим тёмный. Событие не гарантирует приход на поток UI, поэтому перед перерисовкой возвращайтесь на UI.35
  • В WinForms в .NET 9 появился Application.SetColorMode, в .NET 10 он перестал быть экспериментальным. Вызывайте SystemColorMode.System до Application.Run. Три ограничения: только Windows 11, выключен при теме контрастности, не следует за сменой настройки во время работы.672
  • В WPF в .NET 9 появились тема Fluent и ThemeMode. ThemeMode="System" следует системе и также управляет затемнением окна. Однако операции из кода в .NET 10 всё ещё экспериментальны (WPF0001), стили Fluent — «в работе». Если остаётесь на прежней теме, подменяйте светлый и тёмный ResourceDictionary через DynamicResource.8910
  • При теме контрастности отображайте цвета на правильные пары системных цветов, убирайте изображения за текстом и рисуйте многоцветную графику двумя цветами переднего плана и фона. Определяйте через SPI_GETHIGHCONTRAST (SystemInformation.HighContrast в WinForms, SystemParameters.HighContrast в WPF); уведомления — WM_SYSCOLORCHANGE / WM_THEMECHANGED (в .NET — SystemEvents.UserPreferenceChanged).111213
  • Поддержка тёмного режима не заменяет поддержку доступности. Тёмной палитре тоже нужен коэффициент контрастности 4,5:1, и передача информации не одним цветом нужна при любой теме.1415

Одной фразой: поддержка темы — это собрать цвета в одном месте, прочитать системную настройку, заметить изменение и перерисовать; но при теме контрастности полностью отдать пары системных цветов.

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 28, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. У «темы» две оси ── светлый/тёмный и темы контрастности

2.1. Светлый/тёмный (цветовой режим)

Цветовой режим в «Параметры > Персонализация > Цвета» Windows задаёт яркость переднего плана и фона для ОС и приложений в целом. Документация Microsoft определяет светлый как «тёмный передний план на светлом фоне», тёмный как «светлый передний план на тёмном фоне» и уточняет, что передний план здесь — «цвет текста по умолчанию». В тёмном режиме передний план (текст) светлый, фон тёмный.3

Эта настройка хранится в реестре HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize как значения DWORD AppsUseLightTheme (режим приложений) и SystemUsesLightTheme (режим самой Windows); они есть в справочнике настроек Microsoft.16 Но, как ниже, канонический путь чтения из приложения — UISettings WinRT.

2.2. Темы контрастности (высокая контрастность)

Тема контрастности, которую выбирают в «Параметры > Специальные возможности > Темы контрастности», использует палитру, ограниченную коэффициентом контрастности примерно 7:1 и выше, для пользователей, которым нужно сильное визуальное отделение переднего плана от фона. В Windows 11 встроены четыре темы: Aquatic, Desert, Dusk и Night sky; пользователь не только выбирает из них, но и по отдельности правит цвета фона, текста, гиперссылок, отключённого текста, выделенного текста и кнопок. Быстрое переключение — Left Alt+Left Shift+PrintScreen; если ничего не выбрано, применяется Aquatic.1

Документация Microsoft прямо пишет: «не путайте темы контрастности со светлой и тёмной темами». Светлый/тёмный использует широкую палитру и не оптимизирован под максимальный контраст.1 И важно: пока тема контрастности включена, тёмный режим недоступен. Application.SetColorMode в WinForms в это время тёмный режим не даёт, а RequestedTheme в XAML система перезаписывает.217

2.3. Порядок проверки

Поэтому реализация приложения идёт в таком порядке. Сначала определить, включена ли тема контрастности; если да — полностью отдать системным цветам. Если нет — выбрать светлую или тёмную палитру.

Две оси тем и порядок проверкиЕсли тема контрастности включена, полностью отдать пары системных цветов; если нет — прочитать цветовой режим светлый/тёмный и выбрать палитру приложенияданетсветлыйтёмныйТема контрастности включена?Отдать пары системных цветовЦветовой режим?Светлая палитраТёмная палитраТёмный режим недоступен

Рис. 2: Сначала проверяют тему контрастности; светлую или тёмную палитру выбирают, только если она выключена.

3. Почему существующие приложения в тёмном режиме остаются белыми

Окно состоит из двух областей. Неклиентская область — панель заголовка, рамка, кнопки заголовка — и клиентская область, которую рисует приложение. С Windows Vista неклиентскую область синтезирует и рисует DWM (диспетчер окон рабочего стола); приложение задаёт атрибуты отрисовки через DwmSetWindowAttribute.18

Документация Microsoft прямо объясняет, почему существующие приложения остаются белыми. «Windows не знает, может ли приложение поддерживать тёмный режим, и из соображений обратной совместимости предполагает, что не может». Есть платформы вроде WinUI и Windows App SDK, которые обрабатывают тёмный режим нативно, но приложения Win32 его часто не поддерживают, поэтому Windows по умолчанию даёт светлую панель заголовка.3

Две области окна и кто их рисуетНеклиентскую область из панели заголовка и рамки рисует DWM, клиентскую — приложение или UI-платформа, поэтому тёмная поддержка нужна обеимОкно верхнего уровняНеклиентская область (панель заголовка, рамка)Клиентская область (содержимое экрана)DWM синтезирует и рисуетРисует приложение или платформаУказание через DwmSetWindowAttributeСобственная палитра приложения

Рис. 3: Панель заголовка рисует DWM, содержимое — приложение, поэтому тёмная поддержка требует и указания DWM, и палитры приложения.

Отсюда два следствия. Первое: чтобы сделать панель заголовка тёмной, приложение должно явно попросить DWM. Второе: тёмной после этой просьбы становится только панель заголовка; клиентскую область нужно перекрасить самим. Документация тоже пишет, что «для полной поддержки тёмного режима вся поверхность приложения должна следовать тёмной теме», и оговаривает, что официальное руководство покрывает обнаружение и панель заголовка, а перекраску клиентской области не разбирает.3 Приложение с чёрной панелью заголовка и белым содержимым выглядит неестественнее, чем целиком белое.

Почему по умолчанию светлоWindows не знает, поддерживает ли приложение тёмный режим, поэтому ради совместимости по умолчанию светлое; рамку по тёмной системной настройке рисует, только когда приложение передало TRUE в атрибут DWMнетдаWindows не знает, есть ли поддержкаРади совместимости умолчание — светлоеПриложение передало TRUE?Всегда светлая рамкаРисовать по системной настройке

Рис. 4: Не зная о поддержке, Windows по умолчанию остаётся светлой и следует, только когда приложение явно указало.

4. Тёмная панель заголовка DWM ── DwmSetWindowAttribute

4.1. DWMWA_USE_IMMERSIVE_DARK_MODE

Атрибут, который делает панель заголовка тёмной, — DWMWA_USE_IMMERSIVE_DARK_MODE. Описание перечисления DWMWINDOWATTRIBUTE такое. «Разрешить рисовать рамку этого окна цветами тёмного режима, когда системная настройка тёмного режима включена. Из соображений совместимости все окна по умолчанию светлые независимо от системной настройки. pvAttribute указывает на BOOL: TRUE — уважать тёмный режим, FALSE — всегда светлый. Поддержка с Windows 11 сборки 22000».4

То есть TRUE — не «сделай тёмным», а разрешение «если система тёмная, можно рисовать тёмным». Если клиентскую область приложение уже готово красить тёмным, достаточно передать TRUE — панель заголовка последует системной настройке. Наоборот, если приложение всегда показывают светлым (ниже — «фиксированный светлый режим»), умолчание FALSE нормально.

Официальный C++-пример такого вида. В него входит и определение значения 20 самостоятельно для старых SDK, где константы в заголовке нет.3

#include <dwmapi.h>
#pragma comment(lib, "dwmapi.lib")

#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif

// Windows 11 (сборка 22000) или новее. Предполагается манифест
// с supportedOS для Windows 10 и новее (без него версия округляется до Windows 8)
bool IsWindows11OrGreater()
{
    OSVERSIONINFOEXW osvi{ sizeof(osvi) };
    osvi.dwMajorVersion = 10;
    osvi.dwMinorVersion = 0;
    osvi.dwBuildNumber = 22000;
    DWORDLONG mask = 0;
    VER_SET_CONDITION(mask, VER_MAJORVERSION, VER_GREATER_EQUAL);
    VER_SET_CONDITION(mask, VER_MINORVERSION, VER_GREATER_EQUAL);
    VER_SET_CONDITION(mask, VER_BUILDNUMBER, VER_GREATER_EQUAL);
    return ::VerifyVersionInfoW(
        &osvi, VER_MAJORVERSION | VER_MINORVERSION | VER_BUILDNUMBER, mask) != FALSE;
}

// honorDarkMode = true: если система тёмная, панель заголовка тоже можно рисовать тёмной
void ApplyTitleBarTheme(HWND hwnd, bool honorDarkMode)
{
    if (!IsWindows11OrGreater())
    {
        // Документированная поддержка — сборка 22000 и новее. Ниже не вызываем, оставляем умолчание (светлое)
        LogInfo(L"DWMWA_USE_IMMERSIVE_DARK_MODE is not documented for this OS build; keeping the default light frame");
        return;
    }
    BOOL value = honorDarkMode ? TRUE : FALSE;
    HRESULT hr = ::DwmSetWindowAttribute(
        hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &value, sizeof(value));
    if (FAILED(hr))
    {
        // Сбой на поддерживаемой ОС — аномалия. Не глотать молча: записать HRESULT и всплыть
        LogWarning(L"DwmSetWindowAttribute(DWMWA_USE_IMMERSIVE_DARK_MODE) failed: 0x%08X", hr);
    }
}

Почему ветвление по версии ОС — одной фразой. Документированная поддержка — Windows 11 сборка 22000 и новее.4 Сообщения, что то же значение срабатывает и на Windows 10, не редкость, но отображение бизнес-приложения нельзя сажать на недокументированное поведение. Если спроектировать «вызвать и при сбое сдаться», на Windows 10 вызов может успешно пройти — и панель заголовка станет тёмной на недокументированном поведении. На сборках ниже 22000 не вызывать и следовать документированному умолчанию «светлая панель заголовка»; сбой на поддерживаемой ОС записывать HRESULT в журнал и всплывать — только это. В интернете ходят и старый приём со значением 19, и вызов порядкового экспорта uxtheme.dll, чтобы затемнить общие элементы управления, — и то и другое закрытые API, никто не гарантирует поведение после обновления.

4.2. Когда вызывать ── пока HWND жив и каждый раз, когда его пересоздают

DwmSetWindowAttribute вызывают для HWND, поэтому после создания дескриптора окна. А формы WinForms при смене ShowInTaskbar и подобном пересоздают дескриптор. На новом HWND после пересоздания атрибута нет, поэтому место вызова — не «конструктор», а «место, которое вызывается каждый раз, когда создаётся дескриптор». В WinForms это OnHandleCreated, в WPF — SourceInitialized.

Когда вызывать DwmSetWindowAttributeАтрибут DWM задают после создания дескриптора окна, при пересоздании задают снова на новый дескриптор и задают снова при уведомлении о смене темыпересоздание дескрипторауведомление о смене темыСоздание HWNDЗадать атрибут DWMПока показано

Рис. 5: Атрибут DWM привязан к HWND, поэтому его задают заново при каждом создании и пересоздании.

P/Invoke в WinForms такой (безопасную запись DllImport см. в «Безопасный вызов Win32 API из C# — практическое руководство по P/Invoke (DllImport / LibraryImport / CsWin32)»).

using System.Runtime.InteropServices;

public partial class MainForm : Form
{
    private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;

    [DllImport("dwmapi.dll")]
    private static extern int DwmSetWindowAttribute(
        IntPtr hwnd, int attribute, ref int value, int size);

    protected override void OnHandleCreated(EventArgs e)
    {
        base.OnHandleCreated(e);
        ApplyTitleBarTheme();
    }

    private void ApplyTitleBarTheme()
    {
        // Документированная поддержка — сборка 22000 и новее. Ниже не вызываем, оставляем умолчание (светлое).
        // Проверка, которая работает и на .NET Framework. На .NET Framework без манифеста
        // с supportedOS для Windows 10 и новее версия округляется до Windows 8
        // (на .NET 5 и новее можно OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000))
        if (Environment.OSVersion.Version < new Version(10, 0, 22000))
        {
            _logger.LogInformation("Dark title bar is not documented for this OS build; keeping the default light frame");
            return;
        }
        // 1 (TRUE) = если система тёмная, можно рисовать тёмным. 0 (FALSE) = всегда светлое
        int honorDarkMode = 1;
        int hr = DwmSetWindowAttribute(
            Handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref honorDarkMode, sizeof(int));
        if (hr < 0)
        {
            _logger.LogWarning("DwmSetWindowAttribute failed: 0x{Hr:X8}", hr);
        }
    }
}

Этот код нужен в приложениях на .NET 8 и старше, .NET Framework и Win32/MFC. Если в WinForms на .NET 9 и новее использовать Application.SetColorMode, а в WPF на .NET 9 и новее — ThemeMode, затемнение окна берёт на себя платформа (документация ThemeMode прямо пишет, что он «также управляет применением материала фона и тёмного режима к окну»).10 Двойной вызов вреда не несёт, но ответственность размывается — выберите одно.

В WPF HWND фиксируется к моменту SourceInitialized. Дескриптор берут из WindowInteropHelper.19

using System.Windows.Interop;

public partial class MainWindow : Window
{
    protected override void OnSourceInitialized(EventArgs e)
    {
        base.OnSourceInitialized(e);
        var hwnd = new WindowInteropHelper(this).Handle;
        TitleBarTheme.Apply(hwnd, honorDarkMode: true); // тело — P/Invoke выше
    }
}

4.3. Цвет панели заголовка, цвет текста, цвет рамки и материал фона

В Windows 11 кроме выбора тёмный/светлый появились атрибуты, задающие сам цвет панели заголовка.

Атрибут Значение Содержание Сборка
DWMWA_USE_IMMERSIVE_DARK_MODE 20 Если система тёмная, рисовать рамку тёмной (BOOL) 22000
DWMWA_BORDER_COLOR 34 Цвет рамки окна (COLORREF). DWMWA_COLOR_NONE убирает рамку 22000
DWMWA_CAPTION_COLOR 35 Цвет панели заголовка (COLORREF) 22000
DWMWA_TEXT_COLOR 36 Цвет текста заголовка (COLORREF) 22000
DWMWA_SYSTEMBACKDROP_TYPE 38 Системный материал фона (Mica или Acrylic) 22621

Три атрибута цвета возвращаются к системному умолчанию, если передать DWMWA_COLOR_DEFAULT (0xFFFFFFFF). Про цвет рамки оговорено: «менять цвет при смене состояния вроде активации окна — ответственность приложения».4 Материал фона задают перечислением DWM_SYSTEMBACKDROP_TYPE: DWMSBT_MAINWINDOW на Windows 11 соответствует Mica, DWMSBT_TRANSIENTWINDOW — Acrylic, но прямо написано, что «эффект материала может измениться в будущих Windows».20

В бизнес-приложении место применения стоит продумывать осторожно. Если покрасить панель заголовка в цвет бренда, ответственность за контраст текста заголовка и кнопок на этом цвете ложится на вас. К двум состояниям тёмный/светлый добавляется ещё пара активное/неактивное. Для большинства бизнес-приложений правильный ответ — «следовать системному умолчанию (только TRUE для значения 20)»; цвет бренда — вариант, когда он действительно нужен.

Как выбрать цвет панели заголовкаЕсли следовать системному умолчанию, достаточно TRUE для значения 20; покраска цветом бренда переносит на приложение гарантию контраста текста и кнопок заголовка и управление цветами активного/неактивного; возврат — передача DWMWA_COLOR_DEFAULTследовать системному умолчаниюпокрасить цветом брендаЦвет панели заголовка?Только TRUE для значения 20Задать цветовые атрибуты (34-36)Самим гарантировать контраст текста и кнопокСамим вести активное/неактивноеВозврат — COLOR_DEFAULT

Рис. 6: Выбор цвета бренда переносит на приложение ответственность за контраст и состояния; для большинства бизнес-приложений правильно следовать умолчанию.

5. Обнаружение системной темы и следование ── прочитать, заметить, перерисовать

Работа по следованию клиентской области раскладывается на три части. Прочитать текущую настройку, заметить изменение и перерисовать.

Три шага обнаружения и следованияПри запуске прочитать текущий цветовой режим через UISettings, заметить изменение уведомлениями вроде ColorValuesChanged, вернуться на поток UI и переприменить палитру приложенияПрочитать: UISettings.GetColorValueПерерисовать: снова применить палитруЗаметить: ColorValuesChangedВернуться на поток UIЗадать атрибут DWM снова

Рис. 7: Цикл «прочитать при запуске, заметить по уведомлению, вернуться на поток UI и перерисовать» — скелет следования теме.

5.1. Прочитать ── UISettings и «если передний план яркий — тёмный»

Официальная процедура Microsoft использует WinRT Windows.UI.ViewManagement.UISettings. Берут цвет переднего плана (цвет текста по умолчанию) через GetColorValue(UIColorType::Foreground), оценивают его воспринимаемую яркость целочисленной формулой — «яркий ли» — и считают тёмным режимом, если передний план яркий. Документация оговаривает, что формула — не строгая модель анализа яркости, а приближение, достаточное для классификации светлого и тёмного.321

UISettings — класс WinRT, но из C# WPF/WinForms его вызывают напрямую, если поставить TargetFramework с версией Windows SDK вроде net8.0-windows10.0.19041.0 (устройство см. в «WinRT — это COM ── IInspectable, .winmd, языковые проекции и почему WinUI до сих пор стоит на двоичном контракте»). Можно читать реестр AppsUseLightTheme напрямую, но реестр — место хранения настройки, а не контракт API: канон чтения — UISettings, реестр оставьте диагностике.

Пути чтения цветового режимаКанонический путь — взять цвет переднего плана через UISettings WinRT и классифицировать светлое/тёмное; AppsUseLightTheme в реестре — место хранения, его оставляют диагностикеСейчас светлый или тёмныйUISettings.GetColorValueРеестр AppsUseLightThemeОценить воспринимаемую яркость переднего планаЕсли яркий — тёмныйМесто хранения. Оставить диагностике

Рис. 8: Канонический путь чтения — UISettings; реестр лишь место хранения.

5.2. Заметить ── ColorValuesChanged не приходит на поток UI

Для обнаружения изменений тоже берут UISettings. Событие ColorValuesChanged возникает, когда изменилось значение цвета; официальное руководство тоже отслеживает смену настройки этим событием.53 Практическая оговорка одна. Это событие не гарантирует приход на поток UI. Возвращайтесь на поток UI через Dispatcher WPF, Control.Invoke WinForms или общий SynchronizationContext и только потом трогайте элементы. Как жить с потоком UI — в «WPF и WinForms: async/await и поток UI».

Событие возникает и при смене акцентного цвета. Если перерисовывать только при смене светлый/тёмный, при каждом событии перепроверяйте и уведомляйте, только когда стало иначе, чем в прошлый раз. И по приоритету главы 2: если включена тема контрастности, яркость переднего плана смотреть нельзя. В теме контрастности с тёмным фоном вроде Aquatic передний план яркий, и по одной яркости получится ложное «тёмный». Тему контрастности проверяют раньше как отдельное состояние.

Следующий класс собирает этот порядок проверки в одном месте. SynchronizationContext для возврата на поток UI и проверку темы контрастности (SystemInformation.HighContrast в WinForms, SystemParameters.HighContrast в WPF, глава 8) передаёт вызывающая сторона. Следите за моментом создания. В WinForms в Program.Main ещё нет ни цикла сообщений, ни Control, SynchronizationContext.Currentnull. Передавайте SynchronizationContext.Current после создания элемента, в конструкторе формы или OnLoad. В WPF можно передать new DispatcherSynchronizationContext(Application.Current.Dispatcher).

using Windows.UI.ViewManagement; // TargetFramework: net8.0-windows10.0.19041.0 и новее

public enum ThemeState { Light, Dark, HighContrast }

public sealed class SystemThemeWatcher : IDisposable
{
    private readonly UISettings _settings = new();
    private readonly SynchronizationContext _ui;
    private readonly Func<bool> _isHighContrast;

    private bool _disposed;

    public ThemeState Current { get; private set; }
    public event EventHandler? Changed;

    // ui: SynchronizationContext потока UI. Передать SynchronizationContext.Current
    //     после создания элемента или DispatcherSynchronizationContext в WPF
    // isHighContrast: () => SystemInformation.HighContrast (WinForms)
    //                 () => SystemParameters.HighContrast (WPF)
    public SystemThemeWatcher(SynchronizationContext ui, Func<bool> isHighContrast)
    {
        _ui = ui ?? throw new ArgumentNullException(nameof(ui));
        _isHighContrast = isHighContrast ?? throw new ArgumentNullException(nameof(isHighContrast));
        Current = Read();
        _settings.ColorValuesChanged += OnColorValuesChanged;
    }

    private ThemeState Read()
    {
        // Порядок как в главе 2: сначала тема контрастности. Тема контрастности
        // с тёмным фоном даёт яркий передний план, и по одной яркости выйдет ложное «тёмный»
        if (_isHighContrast()) return ThemeState.HighContrast;
        // Та же проверка, что в официальном руководстве: если передний план (цвет текста по умолчанию) яркий — тёмный
        var fg = _settings.GetColorValue(UIColorType.Foreground);
        bool isDark = (5 * fg.G + 2 * fg.R + fg.B) > 8 * 128;
        return isDark ? ThemeState.Dark : ThemeState.Light;
    }

    // Вызывать с потока UI. Можно и из уведомлений другим путём, вроде UserPreferenceChanged
    public void Refresh()
    {
        if (_disposed) return;
        var next = Read();
        // Между светлым и тёмным не уведомлять при том же состоянии, чтобы игнорировать смену только акцента.
        // Исключение при теме контрастности: даже если пользователь правит цвета темы, состояние остаётся HighContrast,
        // поэтому уведомлять и при том же состоянии, чтобы заново взять системные цвета
        if (next == Current && next != ThemeState.HighContrast) return;
        Current = next;
        Changed?.Invoke(this, EventArgs.Empty);
    }

    private void OnColorValuesChanged(UISettings sender, object args)
    {
        // Приход на поток UI не гарантирован, поэтому сначала вернуться на UI, потом проверка и уведомление.
        // Вызовы, уже стоявшие в очереди после Dispose, игнорирует флаг в Refresh
        _ui.Post(_ => Refresh(), null);
    }

    public void Dispose()
    {
        // Отписка только останавливает будущую доставку; вызовы, уже отправленные на поток UI, остаются.
        // Ставим флаг, и оставшиеся вызовы сами себя игнорируют (вызывать на потоке UI)
        _disposed = true;
        _settings.ColorValuesChanged -= OnColorValuesChanged;
    }
}

На уровне Win32 при смене настройки всем окнам верхнего уровня уходит WM_SETTINGCHANGE,22 а в .NET это доходит как SystemEvents.UserPreferenceChanged.23 При переключении визуального стиля (включая включение темы контрастности) приходит WM_THEMECHANGED,24 при смене системных цветов — WM_SYSCOLORCHANGE.25 Писать разную обработку под каждый вид уведомления хрупче, чем при любом уведомлении вызывать одну и ту же обработку «прочитать и перерисовать»; это та же форма, что следование теме контрастности в предыдущей статье. Для SystemThemeWatcher выше это значит вызывать Refresh() и из обработчиков UserPreferenceChanged или StaticPropertyChanged.

Пути уведомления о смене темы и потокиColorValuesChanged UISettings может прийти не на поток UI, поэтому возвращаются на UI; WM_SETTINGCHANGE доходит как SystemEvents.UserPreferenceChanged; WM_THEMECHANGED и WM_SYSCOLORCHANGE доходят в оконную процедуру. Все сходятся в одну повторную применениеColorValuesChangedВернуться на поток UIUserPreferenceChangedПрочитать и перерисоватьWM_THEMECHANGED и др.

Рис. 9: Путей уведомления несколько, все сходятся в одну обработку «прочитать и перерисовать».

5.3. Перерисовать ── собрать цвета в одном месте

Предпосылка перерисовки — цвета собраны в одном месте. Если Color.White и #FFFFFF размазаны по формам и XAML, места перерисовки не перечислить. В WinForms делают класс «палитра» (два экземпляра — светлый и тёмный), элементы берут цвета оттуда при запуске и при уведомлении. В WPF цвета собирают в ResourceDictionary, из XAML ссылаются через DynamicResource. В WinUI эта структура с самого начала есть как ThemeDictionaries.

Структура, где цвета собраны в одном местеВ приложении по одной светлой и тёмной палитре; экраны берут цвета из палитры, выбранной по текущему режиму, поэтому объекты перерисовки можно перечислитьсветлыйтёмныйТекущий режимКакую брать?Светлая палитраТёмная палитраЭкраны и элементыРазмазанный Color.WhiteМеста перерисовки не перечислить

Рис. 10: Если палитра в одном месте, перерисовку можно перечислить; размазанный жёсткий код — нет.

Эта работа «собрать цвета» сразу работает и для поддержки тем контрастности, и для проверки коэффициента контрастности ниже. Главная стоимость поддержки тёмного режима — не вызовы API, а эта уборка.

6. Реализация в WinForms

6.1. .NET 9/10 ── Application.SetColorMode

В WinForms предварительная поддержка тёмного режима появилась в .NET 9 и «полностью интегрирована» в .NET 10. В Application.SetColorMode передают одно из трёх значений.67

  • SystemColorMode.Classic — умолчание. Светлое как раньше.
  • SystemColorMode.System — следовать светлой/тёмной настройке Windows.
  • SystemColorMode.Dark — тёмный.

Место вызова — до Application.Run, до создания элементов UI. В .NET 9 это была экспериментальная функция: без подавления WFO5001 в файле проекта была ошибка компиляции; с .NET 10 этой ошибки нет.26

static class Program
{
    [STAThread]
    static void Main()
    {
        ApplicationConfiguration.Initialize();
        Application.SetColorMode(SystemColorMode.System); // вызвать до создания UI
        Application.Run(new MainForm());
    }
}

При смене цветового режима System.Drawing.SystemColors переключается на соответствующие цвета, и стандартные элементы рисуются по ним.6 По устройству экспериментальное свойство SystemColors.UseAlternativeColorSet (SYSLIB5002) «заставляет системные KnownColor возвращать альтернативный набор цветов (сейчас — версия тёмного режима)»: сами системные цвета Win32 от настройки светлый/тёмный не меняются, поэтому альтернативный набор держит сторона .NET. Тот же документ пишет, что когда включена тема контрастности, всегда возвращаются текущие цвета Windows.27

Зафиксируйте три ограничения из документации SetColorMode.2

  1. Тёмный цветовой режим доступен только на Windows 11 и новее.
  2. Когда включена тема контрастности, тёмный режим недоступен.
  3. Даже с SystemColorMode.System приложение не следует автоматически, если настройка Windows изменилась во время работы.

Третий пункт в бизнес-приложениях часто становится обращением. Напишите в пользовательском объяснении, что решает настройка Windows на запуске и вступает в силу при следующем запуске. Если во что бы то ни стало следовать переключению во время работы, придётся пересоздавать форму — почти всегда это не окупается.

Поток SetColorMode и ограниченияSetColorMode вызывают до Application.Run, SystemColors переключается на альтернативный набор, стандартные элементы следуют. Три ограничения: только Windows 11, выключен при теме контрастности, не следует за сменой настройки во время работыSetColorMode (System)До Application.RunSystemColors на альтернативный наборСтандартные элементы следуютТолько Windows 11Выключен при теме контрастностиНе следует за сменой во время работы

Рис. 11: SetColorMode действует один раз до запуска и несёт три документированных ограничения.

6.2. Элементы с собственной отрисовкой и ApplyThemingImplicitly

Стандартные элементы следуют цветовому режиму приложения, но документация .NET 10 называет два исключительных случая. Если внутри элемента, который вы сами компонуете и рисуете, используются общие элементы управления Win32 вроде полос прокрутки, они остаются светлыми, пока явно не сделать opt-in. Наоборот, если наследуете существующий элемент, который следует теме, и хотите полностью сами контролировать отрисовку — делаете opt-out.7

В обоих случаях переопределяют Control.CreateParams и вызывают SetStyle(ControlStyles.ApplyThemingImplicitly, true/false) до чтения base.CreateParams. Ловушка этого API: конструктор базового класса читает CreateParams, поэтому в собственном конструкторе уже поздно.7

public partial class GanttChartControl : Control
{
    protected override CreateParams CreateParams
    {
        get
        {
            // Задать до чтения base.CreateParams. В конструкторе поздно
            SetStyle(ControlStyles.ApplyThemingImplicitly, true);
            return base.CreateParams;
        }
    }
}
Когда успеть задать ApplyThemingImplicitlyApplyThemingImplicitly фиксируется в момент, когда конструктор базы читает CreateParams, поэтому SetStyle нужно вызвать в переопределении CreateParams до base.CreateParams; в конструкторе производного класса уже поздноКонструктор базыЧитает CreateParamsК этому моменту нужен SetStyleКонструктор производного классаВызов здесь уже поздний

Рис. 12: ApplyThemingImplicitly должен быть задан до того, как конструктор базы прочитает CreateParams.

Сама собственная отрисовка (OnPaint через GDI+) следует альтернативному набору, если использовать SystemColors / SystemBrushes / SystemPens. Места, где красят Color.White, и здесь заменяют на палитру выше.

6.3. .NET Framework 4.8 и .NET 8 и старше ── явно фиксировать светлый режим

Там, где нет SetColorMode, стандартной поддержки тёмного режима нет. Вариантов два.

  1. Явно фиксировать светлый режим. Атрибут DWM оставить умолчанием FALSE (всегда светлая панель заголовка), клиентская область как раньше. Поддержку тем контрастности (глава 8) сделать обязательно.
  2. Полностью поддержать самим. Собрать палитру, читать и следовать через UISettings, поставить TRUE в атрибут DWM и перекрасить всё, включая вид общих элементов управления.

У 2 приложение не полностью контролирует отрисовку общих элементов Win32 (полосы прокрутки, заголовки, кнопки раскрытия дерева и т. п.), поэтому часто получается состояние «почти тёмное, местами светлое». Как пишет официальное руководство — «вся поверхность должна следовать»,3 — половинчатый тёмный режим даёт худший опыт, чем фиксированный светлый. Для существующего актива реалистичная и объяснимая политика: выбрать 1, явно сказать «это приложение показывают светлым» и в момент перехода на .NET 10 переключиться на SetColorMode.

Варианты WinForms по среде выполненияНа .NET 10 и новее берут SetColorMode; на .NET 9 тот же API с подавлением WFO5001; на .NET 8 и старше и .NET Framework либо явно фиксируют светлый режим, либо полностью поддерживают сами вплоть до общих элементов.NET 10 и новее.NET 9.NET 8 и старше / .NET Frameworkрекомендуетсяесли готовыКакая среда выполнения?SetColorMode (System)SetColorMode + подавить WFO5001Что делать?Явно фиксировать светлыйПолностью поддержать самимОбщие элементы остаются

Рис. 13: На средах выполнения без SetColorMode реалистичное умолчание — явно фиксировать светлый режим.

7. Реализация в WPF

7.1. .NET 9/10 ── тема Fluent и ThemeMode

В WPF .NET 9 поставляется новая тема по Fluent-дизайну Windows 11 с поддержкой светлого/тёмного и акцентного цвета. Применить её можно двумя способами: задать свойство ThemeMode или добавить словарь ресурсов PresentationFramework.Fluent в MergedDictionaries.8

Значения ThemeMode — четыре: Light / Dark / System / None (умолчание; прежняя тема Aero2). На Application действует на всё приложение, на Window — только на это окно.8

<Application x:Class="OrderEntry.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             StartupUri="MainWindow.xaml"
             ThemeMode="System">
</Application>

ThemeMode не только загружает словарь темы Fluent в ресурсы, но и прямо «управляет применением материала фона и тёмного режима к окну». То есть атрибутом DWM из главы 4 занимается сторона WPF. ThemeMode и Resources спроектированы так, чтобы работать синхронно: иначе окно тёмное, а элементы внутри светлые.10

Что делает ThemeMode в WPFThemeMode=System загружает в ресурсы словарь темы Fluent по настройке Windows и вместе с этим управляет затемнением окна и применением материала фонаThemeMode=SystemПрочитать настройку WindowsЗагрузить словарь Fluent в ресурсыЗатемнение окна и материал фонаСинхронизация с Resources, чтобы не было рассинхрона

Рис. 14: ThemeMode вместе управляет загрузкой словаря Fluent и затемнением окна.

Перед внедрением нужно знать две оговорки. Первая: чтение и запись ThemeMode из кода — экспериментальная функция; доступ даёт ошибку WPF0001. С подавлением можно писать Application.Current.ThemeMode = ThemeMode.Dark, но справочник API и в .NET 10 оставляет [Experimental("WPF0001")] и помечает, что «в будущем может быть удалено».810 Вторая: поддержка стилей Fluent и в .NET 10 «в работе». В .NET 10 добавлены стили DatePicker, GridSplitter, GroupBox, TextBox и других и исправлены сбои, связанные с HighContrast,9 — иначе говоря, в Fluent .NET 9 их не было. Перед решением о внедрении на реальной машине проверьте, не ломаются ли используемые в бизнес-приложении элементы (особенно DataGrid и сторонние) под Fluent.

7.2. Следовать, оставаясь на прежней теме ── подмена ResourceDictionary

В WPF без Fluent (или на .NET 8 и старше и .NET Framework) стандартной поддержки тёмного режима нет. Системные цвета Win32 от настройки светлый/тёмный не меняются, поэтому ссылка на SystemColors WPF сама по себе не затемнит. Структура собственного следования такая.

  1. В светлом Themes/Light.xaml и тёмном Themes/Dark.xaml определить цвета и кисти с теми же ключами.
  2. Из XAML ссылаться через DynamicResource, например {DynamicResource App.WindowBackgroundBrush} (StaticResource фиксируется при загрузке и подмену не подхватывает).
  3. По уведомлению SystemThemeWatcher из главы 5 подменять соответствующий словарь в MergedDictionaries. При теме контрастности любой словарь всё равно заменит триггер из 8.4 на системные цвета, поэтому оставляют светлый.
public static class AppTheme
{
    private static readonly Uri Light = new("pack://application:,,,/Themes/Light.xaml");
    private static readonly Uri Dark = new("pack://application:,,,/Themes/Dark.xaml");

    public static void Apply(ThemeState state)
    {
        var merged = Application.Current.Resources.MergedDictionaries;
        var current = merged.FirstOrDefault(d => d.Source == Light || d.Source == Dark);
        // Тёмный словарь только в тёмном состоянии. При теме контрастности отдать системным цветам (8.4)
        var next = new ResourceDictionary { Source = state == ThemeState.Dark ? Dark : Light };
        if (current is null)
        {
            merged.Add(next);
        }
        else
        {
            merged[merged.IndexOf(current)] = next; // подменить на том же месте
        }
    }
}
Подмена словаря ресурсов в WPFВ светлом и тёмном словарях цвета с теми же ключами, XAML ссылается через DynamicResource, по уведомлению о смене темы словарь в MergedDictionaries подменяют — ссылки обновляютсяУведомление о смене темыПодменить словарь в MergedDictionariesLight.xaml (те же ключи)Dark.xaml (те же ключи)Ссылки DynamicResource обновляютсяСсылка StaticResourceОстаётся значением на момент загрузки

Рис. 15: Если подменить словарь с теми же ключами, следуют только ссылки DynamicResource.

Шаблоны стандартных элементов (фон кнопки, цвет полосы прокрутки) несут цвета прежней темы, поэтому и здесь останутся места «собственные поверхности тёмные, стандартные элементы светлые». Оцените объём работы по переопределению стилей нужных элементов и сравните с внедрением Fluent или фиксированным светлым режимом.

7.3. Панель заголовка

Если используете ThemeMode, этим занимается WPF. Если следуете сами, оставаясь на прежней теме, задавайте атрибут DWM из OnSourceInitialized, как в 4.2, и задавайте снова по уведомлению SystemThemeWatcher.

8. Отрисовка при теме контрастности ── соблюдать пары системных цветов

8.1. Обнаружение и уведомление

В Win32 в SystemParametersInfo передают SPI_GETHIGHCONTRAST, получают структуру HIGHCONTRAST и проверяют бит HCF_HIGHCONTRASTON в dwFlags. При вызове нужно задать cbSize.1128 Microsoft называет это «единственным поддерживаемым способом проверить наличие высокой контрастности».12

bool IsContrastThemeActive()
{
    HIGHCONTRASTW hc{};
    hc.cbSize = sizeof(hc);
    if (!::SystemParametersInfoW(SPI_GETHIGHCONTRAST, sizeof(hc), &hc, 0))
    {
        // Не маскировать сбой умолчанием. Всплыть с кодом ошибки, чтобы была причина
        throw std::system_error(::GetLastError(), std::system_category(),
                                "SystemParametersInfo(SPI_GETHIGHCONTRAST)");
    }
    return (hc.dwFlags & HCF_HIGHCONTRASTON) != 0;
}

У каждой платформы есть свойство, которое оборачивает этот вызов.

Среда Проверка Уведомление об изменении
Win32 / MFC SPI_GETHIGHCONTRAST + HCF_HIGHCONTRASTON WM_SYSCOLORCHANGE, WM_THEMECHANGED
WinForms SystemInformation.HighContrast SystemEvents.UserPreferenceChanged
WPF SystemParameters.HighContrast (соответствует SPI_GETHIGHCONTRAST) SystemParameters.StaticPropertyChanged
WinUI 3 ThemeSettings.HighContrast (Microsoft.UI.System) ThemeSettings.Changed

Руководство по доступности WinForms требует проверять HighContrast при запуске и реагировать на изменения через UserPreferenceChanged.13 SystemParameters.HighContrast в WPF сопоставлен с SPI_GETHIGHCONTRAST и HCF_HIGHCONTRASTON,29 смена статического свойства уведомляется через StaticPropertyChanged.30 ThemeSettings в WinUI 3 создают, привязав к окну через CreateForWindowId, и подписываются на событие Changed; важно держать ссылку на объект, иначе событие остановится.31

Пути обнаружения темы контрастностиSPI_GETHIGHCONTRAST Win32 — единственный поддерживаемый способ проверки; SystemInformation.HighContrast WinForms, SystemParameters.HighContrast WPF и ThemeSettings.HighContrast WinUI 3 — обёртки этого вызова с каждой платформыSPI_GETHIGHCONTRAST (единственный способ проверки)WinForms SystemInformationWPF SystemParametersWinUI 3 ThemeSettingsWin32 прямой вызов

Рис. 16: Корень проверки — один Win32 API; у каждой платформы есть оборачивающее свойство.

8.2. Принципы отрисовки ── пары переднего плана и фона

«High contrast parameter» Microsoft называет три вещи, которые приложение должно делать, когда высокая контрастность включена.11

  1. Отобразить все цвета на одну пару переднего плана и фона. Через GetSysColor брать пару COLOR_WINDOWTEXT и COLOR_WINDOW или пару COLOR_BTNTEXT и COLOR_BTNFACE.
  2. Убрать растровые изображения за текстом. Для пользователей, которым нужна высокая контрастность, это визуальная помеха.
  3. Многоцветные изображения рисовать цветами переднего плана и фона, предназначенными для текста.

Ключ — «пара». Руководство с Windows 8 объясняет, что COLOR_HIGHLIGHTTEXT рассчитан на фон COLOR_HIGHLIGHT, а COLOR_WINDOWTEXT — на фон COLOR_WINDOW, и требует не задавать цвет текста жёстко и делать UI, который не зависит от применяемой темы, потому что пользователь настраивает цвета.12 Пример того же руководства — «в Aero текст всегда чёрный, цвет выделения светло-голубой, а в High Contrast Black цвет выделения чёрный. Если взять системный цвет выделения, предполагая чёрный текст, получится чёрный текст на чёрном» — это и есть «пропал индикатор статуса» из начала статьи.

Руководство по темам контрастности Windows 11 сводит это соответствие в таблицу.1

Назначение Передний план Фон
Заголовки, текст, списки, рамки, неинтерактивный UI SystemColorWindowText SystemColorWindow
Гиперссылки SystemColorHotlight SystemColorWindow
Отключённый и неактивный UI SystemColorGrayText SystemColorWindow
Выделение, наведение, нажатие, выполнение SystemColorHighlightText SystemColorHighlight
Кнопки и другой интерактивный UI SystemColorButtonText SystemColorButtonFace

И прямо написано, чего не делать. Не использовать GrayText для пояснений и подсказок (только отключённое состояние), не использовать Hotlight ни для чего, кроме гиперссылок, не смешивать несовместимые передний план и фон, не выбирать цвет только по виду (пользователь реально меняет цвета). Фон страниц, панелей и всплывающих окон берут от SystemColorWindow как базы; соседние поверхности оказываются одного цвета, поэтому нужные границы отделяют рамкой только для темы контрастности (для всплывающих окон и диалогов рекомендуют 2px).1

Как ломается читаемость, когда пару нарушаютЕсли зашить «текст чёрный» и взять только системный цвет выделения для фона, в High Contrast Black цвет выделения чёрный — чёрный текст на чёрном. Если брать передний план и фон парой, читаемость остаётся, даже когда пользователь правит цветаЗашить: текст чёрныйТолько фон выделения — системный цвет выделенияВ High Contrast Black цвет выделения чёрныйЧёрный текст на чёрномБрать передний план и фон паройЧитаемо, даже если пользователь правит цветаПользователь правит цвета

Рис. 17: Если взять только одну сторону как системный цвет, возможен чёрный текст на чёрном; парой — читаемо и после правки цветов.

Решение об отрисовке при теме контрастностиЕсли тема контрастности включена, отобразить цвета на пары системных цветов, убрать изображения за текстом, рисовать многоцветные изображения двумя цветами переднего плана и фона, не использовать жёстко заданные цветаТема контрастности включенаОтобразить цвета на парыУбрать изображения за текстомМногоцветные рисунки двумя цветамиНе использовать жёстко заданные цвета

Рис. 18: Отрисовка при включённой теме контрастности сводится к отображению, опущению, двум цветам и отказу от жёсткого кода.

8.3. Реализация в WinForms

Стандартные элементы WinForms следуют системным цветам, если оставить ForeColor / BackColor по умолчанию. Переключают по проверке только места с собственными цветами и собственную отрисовку. Пример руководства: обычно метка жёлтым на синем, при высокой контрастности возвращают к SystemColors.Window / SystemColors.WindowText.13 К структуре палитры выше добавляют ветку для темы контрастности.

using Microsoft.Win32;

public partial class OrderForm : Form
{
    private readonly SynchronizationContext _ui;

    public OrderForm()
    {
        InitializeComponent();
        // Элемент уже создан, поэтому WindowsFormsSynchronizationContext на месте
        _ui = SynchronizationContext.Current
              ?? throw new InvalidOperationException("Создавайте на потоке UI.");
        ApplyColorScheme();
        SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;
    }

    private void ApplyColorScheme()
    {
        if (SystemInformation.HighContrast)
        {
            // Соблюсти пару, полностью отдать системным цветам, убрать изображение за текстом
            statusLabel.BackColor = SystemColors.Window;
            statusLabel.ForeColor = SystemColors.WindowText;
            headerPanel.BackgroundImage = null;
        }
        else
        {
            var p = AppPalette.Current; // светлая/тёмная палитра (глава 5)
            statusLabel.BackColor = p.PanelBackground;
            statusLabel.ForeColor = p.PanelForeground;
            headerPanel.BackgroundImage = Properties.Resources.HeaderPattern;
        }
    }

    private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
    {
        // Это событие тоже не гарантирует приход на поток UI. Вернуться на UI и перепроверить без фильтра по категории
        _ui.Post(_ =>
        {
            if (IsDisposed) return;
            ApplyColorScheme();
        }, null);
    }

    // Статическое событие: если не отписать, форма утечёт. Отписывать в Dispose(bool),
    // который проходит и путь уничтожения без закрытия (если есть Dispose(bool) от дизайнера — писать туда)
    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged;
        }
        base.Dispose(disposing);
    }
}

Собственную отрисовку в OnPaint ведут системными кистями, соблюдая пары, вроде SystemBrushes.Window / SystemPens.WindowText; многоцветные рисунки вроде цветного кружка статуса заменяют рамкой цвета переднего плана и текстом («работа», «остановлено»). Отображение не одним цветом — то же, что критерий успеха 1.4.1 из предыдущей статьи.

Ветка применения цветов в WinFormsПри запуске и при каждом UserPreferenceChanged вызывают ApplyColorScheme; если SystemInformation.HighContrast истинно, отдают пары системных цветов и убирают фоновое изображение, иначе берут цвета из светлой/тёмной палитрыданетЗапуск / UserPreferenceChangedApplyColorSchemeSystemInformation.HighContrast?Отдать пары SystemColorsУбрать фоновое изображениеВзять из светлой/тёмной палитры

Рис. 19: В WinForms при запуске и при каждом уведомлении вызывают одну обработку; при теме контрастности отдают системным цветам.

8.4. Реализация в WPF

SystemColors WPF автоматически обновляются при смене кисти, если ключ ресурса вроде WindowBrushKey ссылать через DynamicResource (статическая ссылка напрямую на WindowBrush не обновляется).32 Чтобы переключать вид только при теме контрастности, из DataTrigger ссылаются на значение SystemParameters.HighContrast. Но SystemParameters.HighContrast — статическое свойство и само по себе не живой источник привязки. Делают маленький прокси, который подписывается на StaticPropertyChanged, держит значение и уведомляет через INotifyPropertyChanged, и в Source указывают этот экземпляр.30

public sealed class ThemeSettings : INotifyPropertyChanged
{
    public static ThemeSettings Instance { get; } = new();

    public bool IsHighContrast { get; private set; } = SystemParameters.HighContrast;
    public event PropertyChangedEventHandler? PropertyChanged;

    private ThemeSettings()
    {
        // Уведомление, когда меняется статическое свойство SystemParameters (заново берут SPI_GETHIGHCONTRAST)
        SystemParameters.StaticPropertyChanged += (_, e) =>
        {
            if (!string.IsNullOrEmpty(e.PropertyName)
                && e.PropertyName != nameof(SystemParameters.HighContrast)) return;
            IsHighContrast = SystemParameters.HighContrast;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsHighContrast)));
        };
    }
}
<!-- заранее объявить xmlns:local="clr-namespace:OrderEntry" -->
<Style x:Key="CardStyle" TargetType="Border">
    <Setter Property="Background" Value="{DynamicResource App.CardBackgroundBrush}"/>
    <Setter Property="BorderBrush" Value="{DynamicResource App.CardBorderBrush}"/>
    <Setter Property="BorderThickness" Value="1"/>
    <Style.Triggers>
        <DataTrigger Binding="{Binding Source={x:Static local:ThemeSettings.Instance}, Path=IsHighContrast}"
                     Value="True">
            <!-- Соблюсти пару: фон Window, рамка и текст WindowText. Граница потолще -->
            <Setter Property="Background"
                    Value="{DynamicResource {x:Static SystemColors.WindowBrushKey}}"/>
            <Setter Property="BorderBrush"
                    Value="{DynamicResource {x:Static SystemColors.WindowTextBrushKey}}"/>
            <!-- Наследовать и тексту внутри. Дочерние элементы с явным Foreground обрывают наследование -->
            <Setter Property="TextElement.Foreground"
                    Value="{DynamicResource {x:Static SystemColors.WindowTextBrushKey}}"/>
            <Setter Property="BorderThickness" Value="2"/>
        </DataTrigger>
    </Style.Triggers>
</Style>
Ссылки на системные цвета WPF и триггер темы контрастностиСсылка DynamicResource на ключ ресурса SystemColors автоматически следует смене кисти; триггер, привязанный к IsHighContrast прокси, который подписан на StaticPropertyChanged, реагирует на переключение во время работы и переключает на цвета с соблюдением пары. Прямая ссылка на WindowBrush не обновляетсяWindowBrushKey через DynamicResourceАвтоматически следует смене кистиStaticPropertyChangedIsHighContrast проксиСрабатывает DataTriggerПереключить на цвета с соблюдением парыПрямая ссылка на WindowBrushНе обновляется

Рис. 20: WPF следует переключению во время работы динамической ссылкой на ключ ресурса и привязкой к прокси, который ретранслирует смену статического свойства.

Даже при теме Fluent, учитывая исправление сбоев HighContrast в .NET 10,9 включайте проверку работы при теме контрастности в условия внедрения.

8.5. Реализация в WinUI 3

В WinUI 3 стандартные элементы с самого начала следуют светлой/тёмной теме и теме контрастности; собственные цвета приложения определяют в ResourceDictionary.ThemeDictionaries ключами Default (тёмный), Light и HighContrast. В HighContrast цвета не задают жёстко, а ссылаются через ThemeResource на динамические системные цвета вроде SystemColorWindowColor. У пользовательского элемента с Light/Dark обязательно готовят и HighContrast; HighContrast — ключ запасного варианта, когда не найдена другая именованная тема высокой контрастности.331

<ResourceDictionary.ThemeDictionaries>
    <ResourceDictionary x:Key="Default">
        <SolidColorBrush x:Key="App.CardBackgroundBrush" Color="#2B2B2B"/>
    </ResourceDictionary>
    <ResourceDictionary x:Key="Light">
        <SolidColorBrush x:Key="App.CardBackgroundBrush" Color="#F3F3F3"/>
    </ResourceDictionary>
    <ResourceDictionary x:Key="HighContrast">
        <SolidColorBrush x:Key="App.CardBackgroundBrush"
                         Color="{ThemeResource SystemColorWindowColor}"/>
    </ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>

Ещё в WinUI есть механизм HighContrastAdjustment, по умолчанию включённый. Он принудительно ставит белый текст и чёрный фон выделения, чтобы сохранить контраст; если словарь темы правильно использует системные цвета, руководство рекомендует поставить None и пропустить свои стили.1

Разрешение ThemeDictionaries WinUIПо текущей теме выбирается один из словарей Default (тёмный), Light, HighContrast; в HighContrast динамические системные цвета ссылают через ThemeResource. HighContrast — ключ запасного варианта, когда нет именованной темы высокой контрастноститёмнаясветлаятема контрастностиКакая сейчас тема?Словарь DefaultСловарь LightСловарь HighContrastSystemColor через ThemeResourceЗапасной вариант, когда нет именованной темы

Рис. 21: В WinUI словарь по теме выбирается автоматически; словарь HighContrast ссылается на системные цвета.

9. Поддержка тёмного режима не заменяет поддержку доступности

Сообщать «сделали поддержку доступности», имея в виду поддержку тёмного режима, — ошибка. Связь такая.

  • К тёмной палитре критерии коэффициента контрастности применяются так же. Критерий успеха WCAG 1.4.3 требует 4,5:1 для текста и 3:1 для крупного текста; тёмный фон это не меняет.14 Тёмный дизайн с текстом среднего серого на тёмно-сером фоне несёт ту же проблему, что светлый «бледно-серый на белом».
  • Избегать чистых чёрного и белого — дизайн Windows 11. Лучшие практики Microsoft объясняют, что Windows 11 ушёл от чистых белого и чёрного к тонам, более щадящим для глаз.34 Наоборот, фон #000000 в тёмном режиме у части людей вызывает жалобы на слишком сильный контраст со светлым текстом (ореол).
  • Учёт разнообразия цветового зрения нужен независимо от темы. Цветовое руководство Microsoft требует не делать цвет основным средством передачи и не делать сочетание красного и зелёного единственным отличием.3515
  • Тема контрастности — отдельное требование от тёмного режима. Как в главе 2, пока тема контрастности включена, тёмный режим недоступен: какой бы совершенной ни была тёмная поддержка, до пользователей темы контрастности она не доходит.
Связь тёмного режима и доступностиПоддержка тёмного режима — учёт предпочтения и среды; требования доступности — коэффициент контрастности, передача не одним цветом, поддержка тем контрастности — нужно выполнять отдельно, независимо от темыне заменяетПоддержка тёмного режимаУчёт предпочтения и средыДоступностьКоэффициент контрастности 4,5:1Не одним цветомПоддержка тем контрастности

Рис. 22: Поддержка тёмного режима — учёт предпочтения и среды; требования доступности выполняют отдельно.

С другой стороны, работа главы 5 «собрать цвета в одном месте» — фундамент обоих. Если палитра в одном месте, можно перечислить объекты для измерения коэффициента контрастности в светлом и в тёмном и в том же месте написать ветку для темы контрастности. Самый выгодный порядок вложений: по случаю тёмной поддержки собрать палитру и заодно проверить коэффициент контрастности и тему контрастности.

10. Как выбрать политику ── рекомендации по типу приложения

Тип приложения Рекомендуемая политика
Новое WinForms (.NET 10) Использовать SetColorMode(System). Собственную отрисовку строить на SystemColors; собственные элементы, включающие общие элементы управления, подключать через ApplyThemingImplicitly
Новое WPF (.NET 9/10) Внедрять ThemeMode="System" после проверки отображения используемых элементов и работы при теме контрастности. Если трудно — прежняя тема + подмена словаря
Существующее WinForms/WPF (.NET Framework 4.8, .NET 8 и старше) Явно «фиксированный светлый режим», атрибут DWM оставить умолчанием (FALSE). Поддержку тем контрастности сделать обязательно, тёмную — при переходе на .NET 10
WinUI 3 По умолчанию следует системе. Собственные цвета определять в ThemeDictionaries с HighContrast, HighContrastAdjustment поставить в None
Win32 / MFC Атрибут DWM + собственная палитра + пересчёт на WM_THEMECHANGED / WM_SYSCOLORCHANGE. Официальное руководство покрывает обнаружение и панель заголовка; перекраска общих элементов управления вне области
Путь существующего актива к тёмной поддержкеСуществующий актив сначала явно фиксирует светлый режим, обязательно заканчивает поддержку тем контрастности, готовит собранную палитру, переходит на .NET 10 и переключается на SetColorMode или ThemeMode. Половинчатая тёмная поддержка даёт худший опыт, чем фиксированный светлый, поэтому этим путём не идутне идутЯвно фиксировать светлый (сейчас)Поддержка тем контрастности (обязательно)Собрать палитру (подготовка)Переход на .NET 10Переключение на SetColorMode / ThemeModeПоловинчатая тёмная поддержкаХуже, чем фиксированный светлый

Рис. 23: Существующий актив начинают с фиксированного светлого, проходят поддержку тем контрастности и сбор палитры и к тёмной поддержке переходят при миграции на .NET 10.

«Фиксированный светлый режим» — не поражение. Это само умолчание Windows и документированное поведение. Последовательный светлый для пользователя куда лучше, чем половинчатый тёмный с «тёмной только панелью заголовка» и «светлой только полосой прокрутки». Но одну только поддержку тем контрастности «фиксировать» нельзя. Это область разумного приспособления из предыдущей статьи: не предпочтение темы, а вопрос, можно ли пользоваться.

11. Контрольный список проверки

После реализации проверяют на реальной машине в таком порядке. Все переключения — из экрана параметров, за десятки секунд.

  1. Переключить светлый/тёмный во время работы. В «Параметры > Персонализация > Цвета» сменить режим и проверить, следуют ли и панель заголовка, и клиентская область, либо что поведение «вступит в силу при следующем запуске» (у SetColorMode WinForms не следует).
  2. Вызвать пересоздание дескриптора. В WinForms во время работы переключить ShowInTaskbar и проверить, сохранился ли атрибут панели заголовка.
  3. Прогнать все четыре темы контрастности. Переключить Left Alt+Left Shift+PrintScreen и на Aquatic, Desert, Dusk и Night sky проверить, читаются ли текст, рамки, выделенная строка, отключённые пункты и ссылки.1
  4. Отредактировать цвета темы контрастности. Пользователь реально меняет цвета. Поставить фону экстремальный цвет и выжечь оставшийся жёсткий код.
  5. Смотреть журнал. На поддерживаемой ОС записан ли сбой DwmSetWindowAttribute или SystemParametersInfo; на Windows 10 ниже сборки 22000 атрибут DWM не вызывается и запуск остаётся светлым.
  6. Измерить коэффициент контрастности. В светлом и в тёмном проверить сочетания цвета текста и фона палитры на 4,5:1.14
Порядок проверки поддержки темыНа реальной машине проверяют в порядке: переключение светлый/тёмный во время работы, пересоздание дескриптора, четыре темы контрастности, правка цветов темы, журнал сбоев, измерение коэффициента контрастностиПереключить светлый/тёмный во время работыПересоздание дескриптораЧетыре темы контрастностиОтредактировать цвета темыПроверить журнал сбоевИзмерить коэффициент контрастности

Рис. 24: Проверку начинают с переключения настроек и закрывают журналом и коэффициентом контрастности.

12. Итог

  • У тем Windows две оси — «светлый/тёмный» и «темы контрастности»; пока вторая включена, тёмный режим недоступен. Сначала проверяют тему контрастности.
  • Белая панель заголовка у существующего приложения — умолчание ради совместимости; если передать TRUE в DWMWA_USE_IMMERSIVE_DARK_MODE (DwmSetWindowAttribute, значение 20, Windows 11 сборка 22000 и новее), при тёмной системе рисуется тёмным. Задают при каждом создании HWND, сбой пишут в журнал.
  • Текущий режим определяют по яркости цвета переднего плана UISettings.GetColorValue, замечают изменение через ColorValuesChanged, возвращаются на поток UI и перерисовывают. Цвета держат в одном месте.
  • WinForms — Application.SetColorMode(SystemColorMode.System) на .NET 9/10. Три ограничения: только Windows 11, выключен при теме контрастности, не следует за сменой во время работы; плюс ApplyThemingImplicitly у собственных элементов.
  • WPF — ThemeMode="System" на .NET 9/10. Операции из кода экспериментальны, Fluent в работе — внедрять после оценки. На прежней теме — подмена словаря + DynamicResource.
  • При теме контрастности определяют семейством SPI_GETHIGHCONTRAST, отображают цвета на пары системных цветов, убирают изображения за текстом, многоцветные рисунки двумя цветами. GrayText — отключённое состояние, Hotlight — только ссылки.
  • Тёмная поддержка не заменяет поддержку доступности. И в тёмном 4,5:1, не одним цветом, поддержку тем контрастности делают отдельно.
  • Для существующего актива реалистичный ответ — явно «фиксированный светлый режим»; одну только поддержку тем контрастности фиксировать нельзя.

Рекомендуемый первый шаг: взять один основной экран, сначала включить тему контрастности Left Alt+Left Shift+PrintScreen и посмотреть, теми же клавишами вернуться и затем переключить цветовую настройку Windows на тёмную (пока тема контрастности включена, тёмный режим недоступен, поэтому два пробуют отдельно). За несколько минут станет видно, «где приложение держит цвета».

Похожие статьи

Смежные области консультирования

Компания KomuraSoft LLC занимается поддержкой тёмного режима в бизнес-приложениях WinForms/WPF (сбор палитры, оценка перехода на SetColorMode / ThemeMode в .NET 9/10, встраивание атрибута DWM), диагностикой и исправлением поломки отображения при темах контрастности, консультациями по следованию теме в активе Win32/MFC. Можно начинать со стадии «переключили на тёмный режим — пришли жалобы сотрудников».

Справочные ссылки

  1. Microsoft Learn, Contrast themes. Что темы контрастности используют ограниченную палитру примерно 7:1 и выше и их нельзя путать со светлой и тёмной темами; четыре темы Aquatic, Desert, Dusk и Night sky и правка цветов; переключение Left Alt + Left Shift + Print Screen; пары переднего плана/фона ресурсов SystemColor и назначения; GrayText только для отключённого состояния, Hotlight только для ссылок; поломка от жёстко заданных цветов; рамки границ; HighContrast в ThemeDictionaries; HighContrastAdjustment в None; обнаружение через Microsoft.UI.System.ThemeSettings.  2 3 4 5 6 7 8

  2. Microsoft Learn, Application.SetColorMode(SystemColorMode) Method. Вызывать до создания элементов UI; даже с System приложение не адаптируется автоматически при смене системной настройки; тёмный цветовой режим доступен только на Windows 11 и новее и недоступен в режиме высокой контрастности.  2 3 4

  3. Microsoft Learn, Support Dark and Light themes in Win32 apps. Определение переднего плана и фона в цветовых режимах; что Windows не знает, поддерживает ли приложение тёмный режим, и ради совместимости по умолчанию даёт светлую панель заголовка; процедура взять цвет переднего плана через UISettings.GetColorValue и классифицировать светлое/тёмное по воспринимаемой яркости; отслеживание через ColorValuesChanged; включение тёмной панели заголовка через DwmSetWindowAttribute и DWMWA_USE_IMMERSIVE_DARK_MODE (значение 20); что вся поверхность должна следовать тёмному режиму.  2 3 4 5 6 7 8 9 10

  4. Microsoft Learn, DWMWINDOWATTRIBUTE enumeration (dwmapi.h). Что DWMWA_USE_IMMERSIVE_DARK_MODE разрешает рисовать рамку тёмной, когда системная тёмная настройка включена, и все окна по умолчанию светлые; задание COLORREF для DWMWA_BORDER_COLOR, DWMWA_CAPTION_COLOR и DWMWA_TEXT_COLOR и возврат к умолчанию через DWMWA_COLOR_DEFAULT; поддержка с Windows 11 сборки 22000; поддержка DWMWA_SYSTEMBACKDROP_TYPE с сборки 22621.  2 3 4

  5. Microsoft Learn, UISettings.ColorValuesChanged Event. Событие, возникающее при смене значения цвета.  2

  6. Microsoft Learn, What’s new in Windows Forms for .NET 9. Экспериментальная предварительная поддержка тёмного режима; SystemColors меняются соответственно при смене цветового режима; три значения SystemColorMode Classic, System и Dark; вызов Application.SetColorMode в коде запуска; подавление WFO5001.  2 3

  7. Microsoft Learn, What’s new in Windows Forms for .NET 10. Полная интеграция тёмного режима и то, что SetColorMode больше не экспериментален; общие элементы Win32 внутри элементов с собственной отрисовкой остаются светлыми без opt-in; SetStyle(ControlStyles.ApplyThemingImplicitly) нужно вызвать до base.CreateParams внутри переопределения CreateParams, потому что в конструкторе уже поздно.  2 3 4

  8. Microsoft Learn, What’s new in WPF for .NET 9. Тема Fluent с поддержкой светлого/тёмного и акцентного цвета; четыре значения ThemeMode Light, Dark, System и None и задание на Application или Window; применение через словари ресурсов; задание ThemeMode из кода экспериментально и требует подавления WPF0001.  2 3 4

  9. Microsoft Learn, What’s new in WPF for .NET 10. Что поддержка стилей Fluent UI ещё в работе; добавленные стили Fluent для DatePicker, GridSplitter, GridView, GroupBox, Hyperlink, Label, NavigationWindow, RichTextBox и TextBox; исправления сбоев, связанных с HighContrast.  2 3

  10. Microsoft Learn, Application.ThemeMode Property. Что управляет загрузкой темы Fluent в светлом, тёмном или системном режиме и также применением материала фона и тёмного режима к окну; ThemeMode и Resources спроектированы так, чтобы оставаться синхронными и избегать рассинхрона; атрибут Experimental(“WPF0001”) и возможное удаление в будущем.  2 3 4

  11. Microsoft Learn, High contrast parameter. Получить структуру HIGHCONTRAST через SPI_GETHIGHCONTRAST при инициализации и при обработке WM_SYSCOLORCHANGE и проверить HCF_HIGHCONTRASTON; когда включено, отобразить все цвета на одну пару COLOR_WINDOWTEXT и COLOR_WINDOW или COLOR_BTNTEXT и COLOR_BTNFACE, убрать растровые изображения за текстом и рисовать многоцветные изображения цветами переднего плана и фона.  2 3

  12. Microsoft Learn, High-contrast mode. Что в Aero чёрный текст и светло-голубой цвет выделения, а в High Contrast Black цвет выделения чёрный, из-за чего возможен чёрный текст на чёрном; COLOR_HIGHLIGHTTEXT рассчитан на пару с COLOR_HIGHLIGHT, COLOR_WINDOWTEXT — с COLOR_WINDOW; не задавать цвет текста жёстко; строить UI, не зависящий от темы, потому что пользователь настраивает цвета; пересчитывать цвета на WM_THEMECHANGED; SPI_GETHIGHCONTRAST — единственный поддерживаемый способ проверки.  2 3

  13. Microsoft Learn, Walkthrough: Creating an Accessible Windows-based Application. Проверка через SystemInformation.HighContrast; при включении использовать системную схему цветов, добавлять визуальные подсказки к информации, передаваемой цветом, и убирать изображения за текстом; проверка при запуске и следование событию UserPreferenceChanged; пример переключения цветов метки через SystemColors.  2 3

  14. W3C / японский перевод Комитета по инфраструктуре веб-доступности (WAIC), Web Content Accessibility Guidelines (WCAG) 2.1, японский перевод. Критерий успеха 1.4.3 (контраст (минимум)): 4,5:1 для текста и 3:1 для крупного текста; критерий успеха 1.4.1 (использование цвета).  2 3

  15. Microsoft Learn, Color in Windows. Что у Windows два цветовых режима, светлый и тёмный; выбор акцентного цвета и темы отражается на всём опыте пользователя; обеспечение контраста и учёт разнообразия цветового зрения.  2

  16. Microsoft Learn, Reference for Windows 11 and Windows 10 settings. Что AppsUseLightTheme и SystemUsesLightTheme в HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize — значения DWORD, представляющие светлый/тёмный режим приложений и Windows. 

  17. Microsoft Learn, Theming in Windows apps. Если убрать RequestedTheme, приложение следует системной настройке; когда пользователь выбирает тему высокой контрастности, система перезаписывает RequestedTheme; в пользовательских шаблонах не задавать цвета жёстко, а использовать кисти темы. 

  18. Microsoft Learn, DwmSetWindowAttribute function (dwmapi.h). Функция, задающая атрибуты отрисовки DWM неклиентской области окна, и доступность с Windows Vista. 

  19. Microsoft Learn, Retrieve a window handle (HWND). Как в WPF взять Handle из WindowInteropHelper. 

  20. Microsoft Learn, DWM_SYSTEMBACKDROP_TYPE enumeration (dwmapi.h). Что DWMSBT_MAINWINDOW на Windows 11 соответствует Mica, DWMSBT_TRANSIENTWINDOW — Acrylic; эффект материала может измениться в будущих Windows; поддержка с Windows 11 сборки 22621. 

  21. Microsoft Learn, UISettings.GetColorValue(UIColorType) Method. Метод, возвращающий значение цвета указанного UIColorType. 

  22. Microsoft Learn, WM_SETTINGCHANGE message. Сообщение, которое уходит всем окнам верхнего уровня, когда SystemParametersInfo меняет общесистемную настройку или меняется настройка политики. 

  23. Microsoft Learn, SystemEvents.UserPreferenceChanged Event. Статическое событие при смене пользовательской настройки и утечка памяти, если не отписать обработчик. 

  24. Microsoft Learn, WM_THEMECHANGED message. Что рассылается всем окнам после включения, выключения или переключения темы; существующие дескрипторы темы становятся недействительными и их нужно открыть заново. 

  25. Microsoft Learn, WM_SYSCOLORCHANGE message. Что уходит всем окнам верхнего уровня при смене настройки системного цвета; кисти, использующие системные цвета, нужно создать заново; сообщение нужно переслать общим элементам управления. 

  26. Microsoft Learn, Compiler Error WFO5001. Что SetColorMode и SystemColorMode в .NET 9 защищены как экспериментальные функции для оценки, и с .NET 10 эта ошибка не применяется. 

  27. Microsoft Learn, SystemColors.UseAlternativeColorSet Property. Что true заставляет системные KnownColor возвращать альтернативный набор цветов (сейчас — версия тёмного режима); экспериментальная функция SYSLIB5002; когда в Windows включена тема высокой контрастности, системные KnownColor всегда возвращают текущие цвета Windows. 

  28. Microsoft Learn, HIGHCONTRASTW structure (winuser.h). HCF_HIGHCONTRASTON (0x00000001) в dwFlags и необходимость задать cbSize при использовании с SPI_GETHIGHCONTRAST. 

  29. Microsoft Learn, SystemParameters.HighContrast Property. Статическое свойство WPF, сопоставленное с SPI_GETHIGHCONTRAST и HCF_HIGHCONTRASTON. 

  30. Microsoft Learn, SystemParameters.StaticPropertyChanged Event. Статическое событие при смене любого свойства SystemParameters.  2

  31. Microsoft Learn, ThemeSettings Class (Microsoft.UI.System). Создавать, привязав к окну через CreateForWindowId, и принимать смену высокой контрастности через событие Changed; освобождение ссылки уничтожает объект, и событие больше не возникает. 

  32. Microsoft Learn, SystemColors.WindowBrushKey Property. Что динамическая ссылка ключом ресурса автоматически обновляется при смене кисти, а статическая ссылка через WindowBrush — нет. 

  33. Microsoft Learn, ResourceDictionary.ThemeDictionaries Property (Microsoft.UI.Xaml). У пользовательского элемента со словарями тем Light и Dark готовить и словарь HighContrast; HighContrast — ключ запасного варианта, когда нет другой темы высокой контрастности; Default используется, когда словарь ресурсов темы не найден; в HighContrast можно использовать ресурсы системных цветов вроде SystemColorButtonFaceColor. 

  34. Microsoft Learn, Windows app development best practices. Что Windows 11 ушёл от чистых белого и чёрного к тонам, более щадящим для глаз, и что тёмная и светлая темы — средство адаптации к визуальному предпочтению пользователя. 

  35. Microsoft Learn, Color (Windows UX guidelines). Использовать цвет как визуальное усиление, а не основное средство передачи; выбирать цвета темы и системные цвета по назначению и брать передний план и фон соответствующими парами; обрабатывать смену темы через WM_THEMECHANGED; High Contrast Black на Windows 11 соответствует Aquatic, High Contrast White — Desert. 

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Переключили Windows в тёмный режим, а панель заголовка нашего WinForms-приложения осталась белой. Почему?
Потому что Windows не знает, поддерживает ли приложение тёмный режим, и ради совместимости по умолчанию считает все окна светлыми. Неклиентскую область, включая панель заголовка, рисует диспетчер окон рабочего стола (DWM); рамка становится тёмной, когда система тёмная, только после того, как приложение передаст TRUE в DWMWA_USE_IMMERSIVE_DARK_MODE (значение 20) через DwmSetWindowAttribute. Поддержка этого атрибута документирована для Windows 11 сборки 22000 и новее. Если в WinForms на .NET 9 и новее вызывать Application.SetColorMode или в WPF на .NET 9 и новее использовать ThemeMode, этот вызов берёт на себя платформа, так что самим его делать нужно только в приложениях на .NET 8 и старше, .NET Framework или Win32/MFC. Тёмная панель заголовка при белой клиентской области выглядит хуже, не лучше. Включайте этот атрибут, только когда готовы перекрасить всё приложение в тёмное.
Тёмный режим и темы контрастности (высокая контрастность) — одно и то же?
Нет. Светлый/тёмный — это цветовой режим в «Параметры > Персонализация > Цвета»: широкая палитра, которая меняет местами яркость переднего плана и фона. Тему контрастности выбирают в «Параметры > Специальные возможности > Темы контрастности»: ограниченная палитра с коэффициентом контрастности примерно 7:1 и выше (четыре встроенные темы Aquatic, Desert, Dusk и Night sky плюс цвета, которые пользователь отредактировал). Документация Microsoft прямо просит их не путать; пока тема контрастности включена, тёмный режим недоступен (SetColorMode в WinForms в это время тёмный режим не даёт, а RequestedTheme в XAML система перезаписывает). В реализации сначала проверяйте, включена ли тема контрастности, и если да — полностью отдайте цвета системным; иначе выбирайте светлую или тёмную палитру. Это порядок приоритета.
Какой самый короткий путь включить тёмный режим в приложении WinForms?
На .NET 9 и новее самый короткий путь — вызвать Application.SetColorMode(SystemColorMode.System) в Program.cs до Application.Run. В .NET 9 это была экспериментальная функция, поэтому в файле проекта нужно было подавить WFO5001; с .NET 10 подавление не нужно. Вызов SetColorMode переключает SystemColors на альтернативный набор для тёмного режима, и стандартные элементы рисуются по нему. Есть три оговорки. Первая: тёмный режим доступен только на Windows 11 и новее и выключен, пока включена тема контрастности. Вторая: даже с SystemColorMode.System приложение не следует за сменой настройки Windows во время работы (изменение вступит в силу при следующем запуске). Третья: если элемент с собственной отрисовкой использует общие элементы управления Win32 вроде полос прокрутки, нужно переопределить CreateParams и вызвать SetStyle(ControlStyles.ApplyThemingImplicitly, true) до base.CreateParams (в конструкторе уже поздно).
Что сделать в приложении WPF, чтобы оно следовало тёмному режиму?
В WPF на .NET 9 и новее поставляется новая тема по Fluent-дизайну Windows 11: достаточно написать ThemeMode="System" на элементе Application в App.xaml — загрузится Fluent-тема, совпадающая со светлой/тёмной настройкой Windows. ThemeMode также управляет затемнением окна (панелью заголовка) и применением материала фона. Однако чтение и запись свойства ThemeMode из кода в .NET 10 всё ещё экспериментальны (WPF0001), а сами стили Fluent в документации .NET 10 описаны как «ещё в работе». Перед внедрением в бизнес-приложение оцените, не ломаются ли используемые элементы под Fluent. Если остаётесь на прежней теме (то же для .NET 8 и старше и для .NET Framework), подготовьте светлый и тёмный ResourceDictionary, подменяйте их в MergedDictionaries, из XAML ссылайтесь через DynamicResource, а смену отслеживайте через UISettings.ColorValuesChanged. Для панели заголовка в SourceInitialized возьмите HWND из WindowInteropHelper и вызовите DwmSetWindowAttribute.
Почему при теме контрастности (высокой контрастности) текст пропадает или становится нечитаемым?
Типичные причины — жёстко заданные цвета или нарушенная пара переднего плана и фона системных цветов. В теме контрастности пользователь свободно правит цвета фона, текста, ссылок и прочего, поэтому все предпосылки вроде «текст будет чёрным» или «выделенная строка будет светло-голубой» рушатся. Например, если зафиксировать только фон как #E6E6E6, в части тем передний план станет белым, и белый текст на светло-сером не читается. Принципов три. Определяйте состояние через SPI_GETHIGHCONTRAST (SystemInformation.HighContrast в WinForms, SystemParameters.HighContrast в WPF); замените все цвета правильными парами системных цветов (WindowText с Window, ButtonText с ButtonFace, HighlightText с Highlight); уберите изображения за текстом и многоцветную графику, рисуя только цветами переднего плана и фона. GrayText нельзя использовать ни для чего, кроме отключённого состояния, Hotlight — ни для чего, кроме гиперссылок. Об изменениях сообщают WM_SYSCOLORCHANGE и WM_THEMECHANGED (в .NET — SystemEvents.UserPreferenceChanged): там пересчитайте цвета и перерисуйте.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог