From 5d0bdc195e42863c5176d8ba3c0ecd2409d50805 Mon Sep 17 00:00:00 2001 From: pajlada Date: Fri, 19 May 2023 14:26:51 +0200 Subject: [PATCH] Add the ability to select custom themes in the settings dialog (#4570) Themes are loaded from the Themes directory (under the Chatterino directory, so %APPDATA%/Chatterino2/Themes). Themes are json files (see the built in themes as an example). After importing a theme, you must restart Chatterino for it to show up in the settings --- CHANGELOG.md | 1 + src/singletons/Paths.cpp | 1 + src/singletons/Paths.hpp | 3 + src/singletons/Theme.cpp | 189 +++++++++++++++--- src/singletons/Theme.hpp | 43 +++- src/widgets/settingspages/GeneralPage.cpp | 14 +- src/widgets/settingspages/GeneralPageView.hpp | 55 +++++ 7 files changed, 277 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af8bbfa..1d131fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unversioned - Dev: Added command to set Qt's logging filter/rules at runtime (`/c2-set-logging-rules`). (#4637) +- Dev: Added the ability to see & load custom themes from the Themes directory. No stable promises are made of this feature, changes might be made that breaks custom themes without notice. (#4570) ## 2.4.4 diff --git a/src/singletons/Paths.cpp b/src/singletons/Paths.cpp index 79344ac7..f2fa5533 100644 --- a/src/singletons/Paths.cpp +++ b/src/singletons/Paths.cpp @@ -142,6 +142,7 @@ void Paths::initSubDirectories() this->miscDirectory = makePath("Misc"); this->twitchProfileAvatars = makePath("ProfileAvatars"); this->pluginsDirectory = makePath("Plugins"); + this->themesDirectory = makePath("Themes"); this->crashdumpDirectory = makePath("Crashes"); //QDir().mkdir(this->twitchProfileAvatars + "/twitch"); } diff --git a/src/singletons/Paths.hpp b/src/singletons/Paths.hpp index f20195fe..d7f00e19 100644 --- a/src/singletons/Paths.hpp +++ b/src/singletons/Paths.hpp @@ -37,6 +37,9 @@ public: // Plugin files live here. /Plugins QString pluginsDirectory; + // Custom themes live here. /Themes + QString themesDirectory; + bool createFolder(const QString &folderPath); bool isPortable(); diff --git a/src/singletons/Theme.cpp b/src/singletons/Theme.cpp index 1b4f40cd..98510662 100644 --- a/src/singletons/Theme.cpp +++ b/src/singletons/Theme.cpp @@ -3,17 +3,21 @@ #include "Application.hpp" #include "common/QLogging.hpp" +#include "singletons/Paths.hpp" #include "singletons/Resources.hpp" #include +#include #include #include -#include #include #include namespace { + +using namespace chatterino; + void parseInto(const QJsonObject &obj, const QLatin1String &key, QColor &color) { const auto &jsonValue = obj[key]; @@ -139,62 +143,197 @@ void parseColors(const QJsonObject &root, chatterino::Theme &theme) } #undef parseColor -QString getThemePath(const QString &name) +/** + * Load the given theme descriptor from its path + * + * Returns a JSON object containing theme data if the theme is valid, otherwise nullopt + * + * NOTE: No theme validation is done by this function + **/ +std::optional loadTheme(const ThemeDescriptor &theme) { - static QSet knownThemes = {"White", "Light", "Dark", "Black"}; - - if (knownThemes.contains(name)) + QFile file(theme.path); + if (!file.open(QFile::ReadOnly)) { - return QStringLiteral(":/themes/%1.json").arg(name); + qCWarning(chatterinoTheme) + << "Failed to open" << file.fileName() << "at" << theme.path; + return std::nullopt; } - return name; + + QJsonParseError error{}; + auto json = QJsonDocument::fromJson(file.readAll(), &error); + if (!json.isObject()) + { + qCWarning(chatterinoTheme) << "Failed to parse" << file.fileName() + << "error:" << error.errorString(); + return std::nullopt; + } + + // TODO: Validate JSON schema? + + return json.object(); } } // namespace namespace chatterino { +const std::vector Theme::builtInThemes{ + { + .key = "White", + .path = ":/themes/White.json", + .name = "White", + }, + { + .key = "Light", + .path = ":/themes/Light.json", + .name = "Light", + }, + { + .key = "Dark", + .path = ":/themes/Dark.json", + .name = "Dark", + }, + { + .key = "Black", + .path = ":/themes/Black.json", + .name = "Black", + }, +}; + +// Dark is our default & fallback theme +const ThemeDescriptor Theme::fallbackTheme = Theme::builtInThemes.at(2); + bool Theme::isLightTheme() const { return this->isLight_; } -Theme::Theme() +void Theme::initialize(Settings &settings, Paths &paths) { - this->update(); - - this->themeName.connectSimple( - [this](auto) { + this->themeName.connect( + [this](auto themeName) { + qCInfo(chatterinoTheme) << "Theme updated to" << themeName; this->update(); }, false); + + this->loadAvailableThemes(); + + this->update(); } void Theme::update() { - this->parse(); + auto oTheme = this->findThemeByKey(this->themeName); + + std::optional themeJSON; + if (!oTheme) + { + qCWarning(chatterinoTheme) + << "Theme" << this->themeName + << "not found, falling back to the fallback theme"; + + themeJSON = loadTheme(fallbackTheme); + } + else + { + const auto &theme = *oTheme; + + themeJSON = loadTheme(theme); + + if (!themeJSON) + { + qCWarning(chatterinoTheme) + << "Theme" << this->themeName + << "not valid, falling back to the fallback theme"; + + // Parsing the theme failed, fall back + themeJSON = loadTheme(fallbackTheme); + } + } + + if (!themeJSON) + { + qCWarning(chatterinoTheme) + << "Failed to load" << this->themeName << "or the fallback theme"; + return; + } + + this->parseFrom(*themeJSON); + this->updated.invoke(); } -void Theme::parse() +std::vector> Theme::availableThemes() const { - QFile file(getThemePath(this->themeName)); - if (!file.open(QFile::ReadOnly)) + std::vector> packagedThemes; + + for (const auto &theme : this->availableThemes_) { - qCWarning(chatterinoTheme) << "Failed to open" << file.fileName(); - return; + if (theme.custom) + { + auto p = std::make_pair( + QStringLiteral("Custom: %1").arg(theme.name), theme.key); + + packagedThemes.emplace_back(p); + } + else + { + auto p = std::make_pair(theme.name, theme.key); + + packagedThemes.emplace_back(p); + } } - QJsonParseError error{}; - auto json = QJsonDocument::fromJson(file.readAll(), &error); - if (json.isNull()) + return packagedThemes; +} + +void Theme::loadAvailableThemes() +{ + this->availableThemes_ = Theme::builtInThemes; + + auto dir = QDir(getPaths()->themesDirectory); + for (const auto &info : + dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot, QDir::Name)) { - qCWarning(chatterinoTheme) << "Failed to parse" << file.fileName() - << "error:" << error.errorString(); - return; + if (!info.isFile()) + { + continue; + } + + if (!info.fileName().endsWith(".json")) + { + continue; + } + + auto themeName = info.baseName(); + + auto themeDescriptor = ThemeDescriptor{ + info.fileName(), info.absoluteFilePath(), themeName, true}; + + auto theme = loadTheme(themeDescriptor); + if (!theme) + { + qCWarning(chatterinoTheme) << "Failed to parse theme at" << info; + continue; + } + + this->availableThemes_.emplace_back(std::move(themeDescriptor)); + } +} + +std::optional Theme::findThemeByKey(const QString &key) +{ + for (const auto &theme : this->availableThemes_) + { + if (theme.key == key) + { + return theme; + } } - this->parseFrom(json.object()); + return std::nullopt; } void Theme::parseFrom(const QJsonObject &root) diff --git a/src/singletons/Theme.hpp b/src/singletons/Theme.hpp index 034bb799..3a7e1c98 100644 --- a/src/singletons/Theme.hpp +++ b/src/singletons/Theme.hpp @@ -6,16 +6,40 @@ #include #include +#include #include +#include +#include + +#include +#include namespace chatterino { class WindowManager; +struct ThemeDescriptor { + QString key; + + // Path to the theme on disk + // Can be a Qt resource path + QString path; + + // Name of the theme + QString name; + + bool custom{}; +}; + class Theme final : public Singleton { public: - Theme(); + static const std::vector builtInThemes; + + // The built in theme that will be used if some theme parsing fails + static const ThemeDescriptor fallbackTheme; + + void initialize(Settings &settings, Paths &paths) final; bool isLightTheme() const; @@ -114,6 +138,11 @@ public: void normalizeColor(QColor &color) const; void update(); + /** + * Return a list of available themes + **/ + std::vector> availableThemes() const; + pajlada::Signals::NoArgSignal updated; QStringSetting themeName{"/appearance/theme/name", "Dark"}; @@ -121,7 +150,17 @@ public: private: bool isLight_ = false; - void parse(); + std::vector availableThemes_; + + /** + * Figure out which themes are available in the Themes directory + * + * NOTE: This is currently not built to be reloadable + **/ + void loadAvailableThemes(); + + std::optional findThemeByKey(const QString &key); + void parseFrom(const QJsonObject &root); pajlada::Signals::NoArgSignal repaintVisibleChatWidgets_; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index d8234e9f..5bf6fe67 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -114,8 +114,18 @@ void GeneralPage::initLayout(GeneralPageView &layout) auto &s = *getSettings(); layout.addTitle("Interface"); - layout.addDropdown("Theme", {"White", "Light", "Dark", "Black"}, - getApp()->themes->themeName); + + layout.addDropdown( + "Theme", getApp()->themes->availableThemes(), + getApp()->themes->themeName, + [](const auto *combo, const auto &themeKey) { + return combo->findData(themeKey, Qt::UserRole); + }, + [](const auto &args) { + return args.combobox->itemData(args.index, Qt::UserRole).toString(); + }, + {}, Theme::fallbackTheme.name); + layout.addDropdown( "Font", {"Segoe UI", "Arial", "Choose..."}, getApp()->fonts->chatFontFamily, diff --git a/src/widgets/settingspages/GeneralPageView.hpp b/src/widgets/settingspages/GeneralPageView.hpp index 38a043fe..101b0b7b 100644 --- a/src/widgets/settingspages/GeneralPageView.hpp +++ b/src/widgets/settingspages/GeneralPageView.hpp @@ -14,6 +14,8 @@ #include #include +#include + class QScrollArea; namespace chatterino { @@ -192,6 +194,59 @@ public: return combo; } + + template + ComboBox *addDropdown( + const QString &text, + const std::vector> &items, + pajlada::Settings::Setting &setting, + std::function(ComboBox *, T)> getValue, + std::function setValue, QString toolTipText = {}, + const QString &defaultValueText = {}) + { + auto *combo = this->addDropdown(text, {}, std::move(toolTipText)); + + for (const auto &[text, userData] : items) + { + combo->addItem(text, userData); + } + + if (!defaultValueText.isEmpty()) + { + combo->setCurrentText(defaultValueText); + } + + setting.connect( + [getValue = std::move(getValue), combo](const T &value, auto) { + auto var = getValue(combo, value); + if (var.which() == 0) + { + const auto index = boost::get(var); + if (index >= 0) + { + combo->setCurrentIndex(index); + } + } + else + { + combo->setCurrentText(boost::get(var)); + combo->setEditText(boost::get(var)); + } + }, + this->managedConnections_); + + QObject::connect( + combo, QOverload::of(&QComboBox::currentIndexChanged), + [combo, &setting, + setValue = std::move(setValue)](const int newIndex) { + setting = setValue(DropdownArgs{combo->itemText(newIndex), + combo->currentIndex(), combo}); + getApp()->windows->forceLayoutChannelViews(); + }); + + return combo; + } + DescriptionLabel *addDescription(const QString &text); void addSeperator();