Пользовательский интерфейс в Qt

Поддержка тем (light/dark) через динамическую замену QSS

Введение

Поддержка светлой и тёмной темы — ожидаемая пользователями функциональность современных приложений. Основной цикл статей уже затрагивал переключение тем (статья 83) на уровне палитры — в этой статье разберём более глубокий подход через структурированную организацию QSS-файлов для нескольких тем и динамическое переключение между ними во время работы приложения.

Концепция

Практичный подход к организации многотемного QSS — выделение цветовых значений в отдельные переменные на уровне кода (а не дублирование всего QSS-текста для каждой темы), и генерация итоговой строки стилей через подстановку конкретных цветов темы в общий шаблон QSS. Это позволяет поддерживать одну структуру стилей и просто переключать набор используемых в подстановке цветов, вместо поддержания нескольких полностью независимых, потенциально расходящихся друг от друга наборов правил QSS для каждой темы отдельно.

Пример кода: Поддержка тем (light/dark)

// thememanager.h — генерация QSS из шаблона с подстановкой цветов конкретной темы
#pragma once
#include <QApplication>
#include <QMap>
#include <QString>

struct ThemeColors
{
    QString background;
    QString surface;
    QString primaryText;
    QString secondaryText;
    QString accentColor;
    QString borderColor;
};

class ThemeManager : public QObject
{
    Q_OBJECT
public:
    enum class Theme { Light, Dark };

    static ThemeManager &instance()
    {
        static ThemeManager manager;
        return manager;
    }

    void applyTheme(Theme theme)
    {
        m_currentTheme = theme;
        const ThemeColors &colors = (theme == Theme::Dark) ? darkColors() : lightColors();

        QString styleSheet = styleTemplate();
        styleSheet.replace("@background", colors.background);
        styleSheet.replace("@surface", colors.surface);
        styleSheet.replace("@primaryText", colors.primaryText);
        styleSheet.replace("@secondaryText", colors.secondaryText);
        styleSheet.replace("@accentColor", colors.accentColor);
        styleSheet.replace("@borderColor", colors.borderColor);

        qApp->setStyleSheet(styleSheet);
        emit themeChanged(theme);
    }

    Theme currentTheme() const { return m_currentTheme; }

signals:
    void themeChanged(Theme theme);

private:
    static ThemeColors lightColors()
    {
        return {"#ffffff", "#f3f4f6", "#111827", "#6b7280", "#2563eb", "#d1d5db"};
    }

    static ThemeColors darkColors()
    {
        return {"#111827", "#1f2937", "#f9fafb", "#9ca3af", "#3b82f6", "#374151"};
    }

    static QString styleTemplate()
    {
        return R"(
            QWidget {
                background-color: @background;
                color: @primaryText;
            }

            QPushButton {
                background-color: @surface;
                border: 1px solid @borderColor;
                color: @primaryText;
                padding: 8px 16px;
                border-radius: 4px;
            }

            QPushButton:hover {
                border-color: @accentColor;
            }

            QLabel[secondary="true"] {
                color: @secondaryText;
            }
        )";
    }

    Theme m_currentTheme = Theme::Light;
};
// Переключение темы из интерфейса приложения
connect(themeToggleButton, &QPushButton::clicked, []() {
    const auto newTheme = ThemeManager::instance().currentTheme() == ThemeManager::Theme::Light
        ? ThemeManager::Theme::Dark : ThemeManager::Theme::Light;
    ThemeManager::instance().applyTheme(newTheme);
});

// Сохранение выбранной темы между запусками приложения
connect(&ThemeManager::instance(), &ThemeManager::themeChanged, [](ThemeManager::Theme theme) {
    QSettings().setValue("ui/theme", theme == ThemeManager::Theme::Dark ? "dark" : "light");
});

Пояснения к коду

styleTemplate() содержит единый QSS-текст с символическими плейсхолдерами (@background, @accentColor и так далее) вместо конкретных цветовых значений — этот шаблон пишется и поддерживается ровно один раз, независимо от количества доступных тем. applyTheme() берёт конкретный набор цветов (lightColors()/darkColors()) и последовательно заменяет каждый плейсхолдер на реальное значение через QString::replace(), формируя итоговую, готовую к применению строку QSS.

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

Подводные камни

  • Использование простого QString::replace() для подстановки плейсхолдеров рискует случайным совпадением подстроки — если, например, значение цвета случайно содержит текст, совпадающий с другим плейсхолдером, или порядок замены плейсхолдеров выбран неудачно (один плейсхолдер является подстрокой другого), результат подстановки может быть некорректным; для более сложных шаблонов стоит использовать чуть более строгий формат плейсхолдеров (например, с явными разделителями) или специализированный движок шаблонов.
  • Полная регенерация и повторное применение всего QSS приложения при каждом переключении темы (через qApp->setStyleSheet()) пересчитывает стили для каждого виджета приложения — для очень большого, сложного интерфейса с множеством виджетов это может вызвать заметную, хотя обычно кратковременную задержку в момент переключения темы, что стоит иметь в виду при тестировании на представительном по сложности экране приложения.
  • Изображения и иконки, специфичные для конкретной темы (например, чёрная иконка для светлой темы, белая — для тёмной), не меняются автоматически вместе с QSS-цветами, если ссылки на эти иконки заданы статично в коде интерфейса, а не вычисляются динамически в зависимости от текущей темы — нужна отдельная логика, реагирующая на сигнал themeChanged и подставляющая нужный вариант иконки, аналогичная обработке смены палитры в основном цикле статей (статья 83).
  • Не все аспекты внешнего вида виджетов покрываются QSS-цветами — некоторые системные элементы (например, нативные системные диалоги выбора файла/цвета на некоторых платформах) могут не подчиняться кастомной теме приложения вовсе, оставаясь в системном виде, и пользователь может заметить визуальную рассогласованность между основным интерфейсом приложения (полностью тематизированным) и такими системными элементами (которые продолжают использовать собственную, системную тему ОС).