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
This commit is contained in:
pajlada 2023-05-19 14:26:51 +02:00 committed by GitHub
parent 82dff89f3b
commit 5d0bdc195e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 277 additions and 29 deletions

View file

@ -3,6 +3,7 @@
## Unversioned ## Unversioned
- Dev: Added command to set Qt's logging filter/rules at runtime (`/c2-set-logging-rules`). (#4637) - 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 ## 2.4.4

View file

@ -142,6 +142,7 @@ void Paths::initSubDirectories()
this->miscDirectory = makePath("Misc"); this->miscDirectory = makePath("Misc");
this->twitchProfileAvatars = makePath("ProfileAvatars"); this->twitchProfileAvatars = makePath("ProfileAvatars");
this->pluginsDirectory = makePath("Plugins"); this->pluginsDirectory = makePath("Plugins");
this->themesDirectory = makePath("Themes");
this->crashdumpDirectory = makePath("Crashes"); this->crashdumpDirectory = makePath("Crashes");
//QDir().mkdir(this->twitchProfileAvatars + "/twitch"); //QDir().mkdir(this->twitchProfileAvatars + "/twitch");
} }

View file

@ -37,6 +37,9 @@ public:
// Plugin files live here. <appDataDirectory>/Plugins // Plugin files live here. <appDataDirectory>/Plugins
QString pluginsDirectory; QString pluginsDirectory;
// Custom themes live here. <appDataDirectory>/Themes
QString themesDirectory;
bool createFolder(const QString &folderPath); bool createFolder(const QString &folderPath);
bool isPortable(); bool isPortable();

View file

@ -3,17 +3,21 @@
#include "Application.hpp" #include "Application.hpp"
#include "common/QLogging.hpp" #include "common/QLogging.hpp"
#include "singletons/Paths.hpp"
#include "singletons/Resources.hpp" #include "singletons/Resources.hpp"
#include <QColor> #include <QColor>
#include <QDir>
#include <QFile> #include <QFile>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject>
#include <QSet> #include <QSet>
#include <cmath> #include <cmath>
namespace { namespace {
using namespace chatterino;
void parseInto(const QJsonObject &obj, const QLatin1String &key, QColor &color) void parseInto(const QJsonObject &obj, const QLatin1String &key, QColor &color)
{ {
const auto &jsonValue = obj[key]; const auto &jsonValue = obj[key];
@ -139,62 +143,197 @@ void parseColors(const QJsonObject &root, chatterino::Theme &theme)
} }
#undef parseColor #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<QJsonObject> loadTheme(const ThemeDescriptor &theme)
{ {
static QSet<QString> knownThemes = {"White", "Light", "Dark", "Black"}; QFile file(theme.path);
if (!file.open(QFile::ReadOnly))
if (knownThemes.contains(name))
{ {
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
namespace chatterino { namespace chatterino {
const std::vector<ThemeDescriptor> 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 bool Theme::isLightTheme() const
{ {
return this->isLight_; return this->isLight_;
} }
Theme::Theme() void Theme::initialize(Settings &settings, Paths &paths)
{ {
this->update(); this->themeName.connect(
[this](auto themeName) {
this->themeName.connectSimple( qCInfo(chatterinoTheme) << "Theme updated to" << themeName;
[this](auto) {
this->update(); this->update();
}, },
false); false);
this->loadAvailableThemes();
this->update();
} }
void Theme::update() void Theme::update()
{ {
this->parse(); auto oTheme = this->findThemeByKey(this->themeName);
std::optional<QJsonObject> 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(); this->updated.invoke();
} }
void Theme::parse() std::vector<std::pair<QString, QVariant>> Theme::availableThemes() const
{ {
QFile file(getThemePath(this->themeName)); std::vector<std::pair<QString, QVariant>> packagedThemes;
if (!file.open(QFile::ReadOnly))
for (const auto &theme : this->availableThemes_)
{ {
qCWarning(chatterinoTheme) << "Failed to open" << file.fileName(); if (theme.custom)
return; {
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{}; return packagedThemes;
auto json = QJsonDocument::fromJson(file.readAll(), &error); }
if (json.isNull())
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() if (!info.isFile())
<< "error:" << error.errorString(); {
return; 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<ThemeDescriptor> 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) void Theme::parseFrom(const QJsonObject &root)

View file

@ -6,16 +6,40 @@
#include <pajlada/settings/setting.hpp> #include <pajlada/settings/setting.hpp>
#include <QColor> #include <QColor>
#include <QJsonObject>
#include <QPixmap> #include <QPixmap>
#include <QString>
#include <QVariant>
#include <optional>
#include <vector>
namespace chatterino { namespace chatterino {
class WindowManager; 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 class Theme final : public Singleton
{ {
public: public:
Theme(); static const std::vector<ThemeDescriptor> 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; bool isLightTheme() const;
@ -114,6 +138,11 @@ public:
void normalizeColor(QColor &color) const; void normalizeColor(QColor &color) const;
void update(); void update();
/**
* Return a list of available themes
**/
std::vector<std::pair<QString, QVariant>> availableThemes() const;
pajlada::Signals::NoArgSignal updated; pajlada::Signals::NoArgSignal updated;
QStringSetting themeName{"/appearance/theme/name", "Dark"}; QStringSetting themeName{"/appearance/theme/name", "Dark"};
@ -121,7 +150,17 @@ public:
private: private:
bool isLight_ = false; bool isLight_ = false;
void parse(); std::vector<ThemeDescriptor> availableThemes_;
/**
* Figure out which themes are available in the Themes directory
*
* NOTE: This is currently not built to be reloadable
**/
void loadAvailableThemes();
std::optional<ThemeDescriptor> findThemeByKey(const QString &key);
void parseFrom(const QJsonObject &root); void parseFrom(const QJsonObject &root);
pajlada::Signals::NoArgSignal repaintVisibleChatWidgets_; pajlada::Signals::NoArgSignal repaintVisibleChatWidgets_;

View file

@ -114,8 +114,18 @@ void GeneralPage::initLayout(GeneralPageView &layout)
auto &s = *getSettings(); auto &s = *getSettings();
layout.addTitle("Interface"); layout.addTitle("Interface");
layout.addDropdown("Theme", {"White", "Light", "Dark", "Black"},
getApp()->themes->themeName); layout.addDropdown<QString>(
"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<QString>( layout.addDropdown<QString>(
"Font", {"Segoe UI", "Arial", "Choose..."}, "Font", {"Segoe UI", "Arial", "Choose..."},
getApp()->fonts->chatFontFamily, getApp()->fonts->chatFontFamily,

View file

@ -14,6 +14,8 @@
#include <QSpinBox> #include <QSpinBox>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <utility>
class QScrollArea; class QScrollArea;
namespace chatterino { namespace chatterino {
@ -192,6 +194,59 @@ public:
return combo; return combo;
} }
template <typename T>
ComboBox *addDropdown(
const QString &text,
const std::vector<std::pair<QString, QVariant>> &items,
pajlada::Settings::Setting<T> &setting,
std::function<boost::variant<int, QString>(ComboBox *, T)> getValue,
std::function<T(DropdownArgs)> 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<int>(var);
if (index >= 0)
{
combo->setCurrentIndex(index);
}
}
else
{
combo->setCurrentText(boost::get<QString>(var));
combo->setEditText(boost::get<QString>(var));
}
},
this->managedConnections_);
QObject::connect(
combo, QOverload<const int>::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); DescriptionLabel *addDescription(const QString &text);
void addSeperator(); void addSeperator();