diff --git a/extra/config.jsonc b/extra/config.jsonc index 227868b98..f82e261b3 100644 --- a/extra/config.jsonc +++ b/extra/config.jsonc @@ -84,10 +84,10 @@ } }, - // The general vicinae theme as well as the system icon theme (used for applications and file icons) can be customized - // according to the system appearance. - // When editing the current theme through the vicinae GUI, it will modify the value that maps to the current system appearance. + // Light and dark themes are stored separately. "appearance" chooses which slot is active: + // "system" follows the OS color scheme, while "light" / "dark" pin that slot. "theme": { + "appearance": "system", "light": { "name": "vicinae-light", "icon_theme": "auto" diff --git a/src/server/CMakeLists.txt b/src/server/CMakeLists.txt index 05e99bbd9..96b4d76e2 100644 --- a/src/server/CMakeLists.txt +++ b/src/server/CMakeLists.txt @@ -264,6 +264,7 @@ set(SRCS src/internal/keyboard/keybind-manager.cpp src/internal/keyboard/keyboard.cpp + src/theme/theme-variant.hpp src/theme/theme-file.hpp src/theme/theme-file.cpp src/theme/theme-parser.cpp diff --git a/src/server/src/config/config.cpp b/src/server/src/config/config.cpp index a391ea23c..27bc54b34 100644 --- a/src/server/src/config/config.cpp +++ b/src/server/src/config/config.cpp @@ -39,13 +39,19 @@ template T static merge(const auto &v1, const auto &v2) { return cfg; } +bool ConfigValue::followsSystemAppearance() const { + return theme.appearance != "light" && theme.appearance != "dark"; +} + +ThemeVariant ConfigValue::activeAppearance() const { + if (theme.appearance == "light") return ThemeVariant::Light; + if (theme.appearance == "dark") return ThemeVariant::Dark; + return QGuiApplication::styleHints()->colorScheme() == Qt::ColorScheme::Light ? ThemeVariant::Light + : ThemeVariant::Dark; +} + const SystemThemeConfig &ConfigValue::systemTheme() const { - switch (QGuiApplication::styleHints()->colorScheme()) { - case Qt::ColorScheme::Light: - return theme.light; - default: - return theme.dark; - } + return activeAppearance() == ThemeVariant::Light ? theme.light : theme.dark; } Manager::Manager(fs::path path) : m_userPath(std::move(path)) { @@ -122,12 +128,10 @@ bool Manager::mergeEntrypointWithUser(const EntrypointId &id, const ProviderItem } bool Manager::mergeThemeConfig(const config::Partial &cfg) { - switch (QGuiApplication::styleHints()->colorScheme()) { - case Qt::ColorScheme::Light: + if (m_user.activeAppearance() == ThemeVariant::Light) { return mergeWithUser({.theme = config::Partial{.light = cfg}}); - default: - return mergeWithUser({.theme = config::Partial{.dark = cfg}}); } + return mergeWithUser({.theme = config::Partial{.dark = cfg}}); } bool Manager::mergeWithUser(const Partial &patch) { diff --git a/src/server/src/config/config.hpp b/src/server/src/config/config.hpp index 5ba5459b0..f7b52298d 100644 --- a/src/server/src/config/config.hpp +++ b/src/server/src/config/config.hpp @@ -2,6 +2,7 @@ #include #include #include "common/entrypoint.hpp" +#include "theme/theme-variant.hpp" #include "vicinae.hpp" #include #include @@ -37,6 +38,7 @@ struct SystemThemeConfig { }; struct ThemeConfig { + std::string appearance = "system"; SystemThemeConfig light; SystemThemeConfig dark; }; @@ -47,6 +49,7 @@ template <> struct Partial { }; template <> struct Partial { + std::optional appearance; std::optional> light; std::optional> dark; }; @@ -345,6 +348,8 @@ struct ConfigValue { return std::nullopt; } + bool followsSystemAppearance() const; + ThemeVariant activeAppearance() const; const SystemThemeConfig &systemTheme() const; }; diff --git a/src/server/src/qml/general-settings-model.cpp b/src/server/src/qml/general-settings-model.cpp index e55517d6b..8db048e34 100644 --- a/src/server/src/qml/general-settings-model.cpp +++ b/src/server/src/qml/general-settings-model.cpp @@ -14,6 +14,42 @@ #include #include #include +#include +#include + +namespace { + +QVariantList wrapSection(const QString &title, const QVariantList &items) { + QVariantMap section; + section[QStringLiteral("title")] = title; + section[QStringLiteral("items")] = items; + return {section}; +} + +QVariant themeDropdownItem(const std::string &id) { + const auto qid = QString::fromStdString(id); + const auto *theme = ThemeService::instance().findTheme(qid); + if (!theme) return qml::makeDropdownItem(qid, qid); + const auto iconUrl = theme->icon() ? ImageURL::local(QString::fromStdString(theme->icon()->string())) + : ImageURL::builtin(BuiltinIcon::Vicinae); + return qml::makeDropdownItem(qid, theme->name(), qml::imageSourceFor(iconUrl)); +} + +QVariantList themeItemsForVariant(std::optional variant) { + QVariantList items; + for (const auto &theme : ThemeService::instance().themes()) { + if (variant && theme->variant() != *variant) continue; + const auto iconUrl = theme->icon() ? ImageURL::local(QString::fromStdString(theme->icon()->string())) + : ImageURL::builtin(BuiltinIcon::Vicinae); + items.append(qml::makeDropdownItem(theme->id(), theme->name(), qml::imageSourceFor(iconUrl))); + } + const QString title = !variant ? GeneralSettingsModel::tr("Themes") + : *variant == ThemeVariant::Light ? GeneralSettingsModel::tr("Light themes") + : GeneralSettingsModel::tr("Dark themes"); + return wrapSection(title, items); +} + +} // namespace GeneralSettingsModel::GeneralSettingsModel(QObject *parent) : QObject(parent) { m_windowMaterialModel.setSections(windowMaterialItems()); @@ -34,6 +70,14 @@ void GeneralSettingsModel::refreshDynamicModels() { m_themeItems = items; m_themeModel.setSections(items); } + if (auto items = themeItemsForVariant(ThemeVariant::Light); items != m_lightThemeItems) { + m_lightThemeItems = items; + m_lightThemeModel.setSections(items); + } + if (auto items = themeItemsForVariant(ThemeVariant::Dark); items != m_darkThemeItems) { + m_darkThemeItems = items; + m_darkThemeModel.setSections(items); + } if (auto items = iconThemeItems(); items != m_iconThemeItems) { m_iconThemeItems = items; m_iconThemeModel.setSections(items); @@ -175,15 +219,22 @@ void GeneralSettingsModel::setFontSize(const QString &v) { if (ok) cfgManager().mergeWithUser({.font = config::Partial{.normal{.size = val}}}); } -using qml::makeDropdownItem; +bool GeneralSettingsModel::followSystemAppearance() const { return cfg().followsSystemAppearance(); } -static QVariantList wrapSection(const QString &title, const QVariantList &items) { - QVariantMap section; - section[QStringLiteral("title")] = title; - section[QStringLiteral("items")] = items; - return {section}; +void GeneralSettingsModel::setFollowSystemAppearance(bool follow) { + if (follow) { + cfgManager().mergeWithUser( + {.theme = config::Partial{.appearance = std::string{"system"}}}); + return; + } + + const bool light = QGuiApplication::styleHints()->colorScheme() == Qt::ColorScheme::Light; + cfgManager().mergeWithUser({.theme = config::Partial{ + .appearance = light ? std::string{"light"} : std::string{"dark"}}}); } +using qml::makeDropdownItem; + QVariantList GeneralSettingsModel::windowMaterialItems() const { QVariantList items; items.append(makeDropdownItem(QStringLiteral("none"), tr("None"))); @@ -206,24 +257,13 @@ void GeneralSettingsModel::selectWindowMaterial(const QString &id) { {.launcherWindow = config::Partial{.material = id.toStdString()}}); } -QVariantList GeneralSettingsModel::themeItems() const { - QVariantList items; - for (const auto &theme : ThemeService::instance().themes()) { - auto iconUrl = theme->icon() ? ImageURL::local(QString::fromStdString(theme->icon()->string())) - : ImageURL::builtin(BuiltinIcon::Vicinae); - items.append(makeDropdownItem(theme->id(), theme->name(), qml::imageSourceFor(iconUrl))); - } - return wrapSection(tr("Themes"), items); -} +QVariantList GeneralSettingsModel::themeItems() const { return themeItemsForVariant(std::nullopt); } -QVariant GeneralSettingsModel::currentTheme() const { - auto id = QString::fromStdString(cfg().systemTheme().name); - auto *theme = ThemeService::instance().findTheme(id); - if (!theme) return makeDropdownItem(id, id); - auto iconUrl = theme->icon() ? ImageURL::local(QString::fromStdString(theme->icon()->string())) - : ImageURL::builtin(BuiltinIcon::Vicinae); - return makeDropdownItem(id, theme->name(), qml::imageSourceFor(iconUrl)); -} +QVariant GeneralSettingsModel::currentTheme() const { return themeDropdownItem(cfg().systemTheme().name); } + +QVariant GeneralSettingsModel::currentLightTheme() const { return themeDropdownItem(cfg().theme.light.name); } + +QVariant GeneralSettingsModel::currentDarkTheme() const { return themeDropdownItem(cfg().theme.dark.name); } QVariantList GeneralSettingsModel::fontItems() const { QVariantList items; @@ -292,6 +332,20 @@ void GeneralSettingsModel::selectTheme(const QString &id) { cfgManager().mergeThemeConfig({.name = id.toStdString()}); } +void GeneralSettingsModel::selectLightTheme(const QString &id) { + config::Partial theme{ + .light = config::Partial{.name = id.toStdString()}}; + if (!followSystemAppearance()) theme.appearance = "light"; + cfgManager().mergeWithUser({.theme = theme}); +} + +void GeneralSettingsModel::selectDarkTheme(const QString &id) { + config::Partial theme{ + .dark = config::Partial{.name = id.toStdString()}}; + if (!followSystemAppearance()) theme.appearance = "dark"; + cfgManager().mergeWithUser({.theme = theme}); +} + void GeneralSettingsModel::selectFont(const QString &id) { cfgManager().mergeWithUser( {.font = config::Partial{.normal = {.family = id.toStdString()}}}); diff --git a/src/server/src/qml/general-settings-model.hpp b/src/server/src/qml/general-settings-model.hpp index 10b57c2b1..47285d905 100644 --- a/src/server/src/qml/general-settings-model.hpp +++ b/src/server/src/qml/general-settings-model.hpp @@ -33,9 +33,13 @@ class GeneralSettingsModel : public QObject { Q_PROPERTY( bool nativeTextRendering READ nativeTextRendering WRITE setNativeTextRendering NOTIFY configChanged) Q_PROPERTY(QString fontSize READ fontSize WRITE setFontSize NOTIFY configChanged) + Q_PROPERTY(bool followSystemAppearance READ followSystemAppearance WRITE setFollowSystemAppearance NOTIFY + configChanged) Q_PROPERTY(CompletionModel *windowMaterialModel READ windowMaterialModel CONSTANT) Q_PROPERTY(QVariant currentWindowMaterial READ currentWindowMaterial NOTIFY configChanged) Q_PROPERTY(CompletionModel *themeModel READ themeModel CONSTANT) + Q_PROPERTY(CompletionModel *lightThemeModel READ lightThemeModel CONSTANT) + Q_PROPERTY(CompletionModel *darkThemeModel READ darkThemeModel CONSTANT) Q_PROPERTY(CompletionModel *fontModel READ fontModel CONSTANT) Q_PROPERTY(CompletionModel *iconThemeModel READ iconThemeModel CONSTANT) Q_PROPERTY(CompletionModel *faviconServiceModel READ faviconServiceModel CONSTANT) @@ -43,6 +47,8 @@ class GeneralSettingsModel : public QObject { Q_PROPERTY(CompletionModel *languageModel READ languageModel CONSTANT) Q_PROPERTY(QVariant currentLanguage READ currentLanguage NOTIFY configChanged) Q_PROPERTY(QVariant currentTheme READ currentTheme NOTIFY configChanged) + Q_PROPERTY(QVariant currentLightTheme READ currentLightTheme NOTIFY configChanged) + Q_PROPERTY(QVariant currentDarkTheme READ currentDarkTheme NOTIFY configChanged) Q_PROPERTY(QVariant currentFont READ currentFont NOTIFY configChanged) Q_PROPERTY(QVariant currentIconTheme READ currentIconTheme NOTIFY configChanged) Q_PROPERTY(QVariant currentFaviconService READ currentFaviconService NOTIFY configChanged) @@ -95,9 +101,13 @@ class GeneralSettingsModel : public QObject { void setInputServerEnabled(bool v); QString fontSize() const; void setFontSize(const QString &v); + bool followSystemAppearance() const; + void setFollowSystemAppearance(bool v); CompletionModel *windowMaterialModel() { return &m_windowMaterialModel; } CompletionModel *themeModel() { return &m_themeModel; } + CompletionModel *lightThemeModel() { return &m_lightThemeModel; } + CompletionModel *darkThemeModel() { return &m_darkThemeModel; } CompletionModel *fontModel() { return &m_fontModel; } CompletionModel *iconThemeModel() { return &m_iconThemeModel; } CompletionModel *faviconServiceModel() { return &m_faviconServiceModel; } @@ -109,12 +119,16 @@ class GeneralSettingsModel : public QObject { QVariant currentLanguage() const; QVariant currentTheme() const; + QVariant currentLightTheme() const; + QVariant currentDarkTheme() const; QVariant currentFont() const; QVariant currentIconTheme() const; QVariant currentFaviconService() const; QVariant currentKeybindingScheme() const; Q_INVOKABLE void selectTheme(const QString &id); + Q_INVOKABLE void selectLightTheme(const QString &id); + Q_INVOKABLE void selectDarkTheme(const QString &id); Q_INVOKABLE void selectFont(const QString &id); Q_INVOKABLE void selectIconTheme(const QString &id); Q_INVOKABLE void selectFaviconService(const QString &id); @@ -139,11 +153,15 @@ class GeneralSettingsModel : public QObject { CompletionModel m_windowMaterialModel{this}; CompletionModel m_themeModel{this}; + CompletionModel m_lightThemeModel{this}; + CompletionModel m_darkThemeModel{this}; CompletionModel m_fontModel{this}; CompletionModel m_iconThemeModel{this}; CompletionModel m_faviconServiceModel{this}; CompletionModel m_keybindingSchemeModel{this}; CompletionModel m_languageModel{this}; QVariantList m_themeItems; + QVariantList m_lightThemeItems; + QVariantList m_darkThemeItems; QVariantList m_iconThemeItems; }; diff --git a/src/server/src/qml/qml/AppearanceSettingsPage.qml b/src/server/src/qml/qml/AppearanceSettingsPage.qml index fc32708b3..06e7b4b22 100644 --- a/src/server/src/qml/qml/AppearanceSettingsPage.qml +++ b/src/server/src/qml/qml/AppearanceSettingsPage.qml @@ -37,12 +37,31 @@ Flickable { SettingsGroup { SettingsRow { - label: qsTr("Theme") + label: qsTr("Follow system appearance") + description: qsTr("Automatically switch between the light and dark themes based on your system appearance.") + SettingsToggle { + checked: root.model.followSystemAppearance + onToggled: checked => root.model.followSystemAppearance = checked + } + } + + SettingsRow { + label: qsTr("Light theme") + SearchableDropdown { + width: parent.width + model: root.model.lightThemeModel + currentItem: root.model.currentLightTheme + onActivated: item => root.model.selectLightTheme(item.id) + } + } + + SettingsRow { + label: qsTr("Dark theme") SearchableDropdown { width: parent.width - model: root.model.themeModel - currentItem: root.model.currentTheme - onActivated: item => root.model.selectTheme(item.id) + model: root.model.darkThemeModel + currentItem: root.model.currentDarkTheme + onActivated: item => root.model.selectDarkTheme(item.id) } } diff --git a/src/server/src/server.cpp b/src/server/src/server.cpp index 66bf49e7f..a9c01ca9c 100644 --- a/src/server/src/server.cpp +++ b/src/server/src/server.cpp @@ -512,6 +512,7 @@ int startServer(const ServerLaunchOptions &launchOpts) { QObject::connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, [&]() { auto &value = cfgService->value(); + if (!value.followsSystemAppearance()) return; auto &theme = value.systemTheme(); if (theme.iconTheme != "auto") { diff --git a/src/server/src/theme/theme-file.hpp b/src/server/src/theme/theme-file.hpp index 09982bbc5..2214573d3 100644 --- a/src/server/src/theme/theme-file.hpp +++ b/src/server/src/theme/theme-file.hpp @@ -6,8 +6,7 @@ #include #include #include "theme/colors.hpp" - -enum class ThemeVariant { Light, Dark }; +#include "theme/theme-variant.hpp" class ThemeFile { public: diff --git a/src/server/src/theme/theme-variant.hpp b/src/server/src/theme/theme-variant.hpp new file mode 100644 index 000000000..33715e6fd --- /dev/null +++ b/src/server/src/theme/theme-variant.hpp @@ -0,0 +1,3 @@ +#pragma once + +enum class ThemeVariant { Light, Dark };