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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ public:
|
||||
// Plugin files live here. <appDataDirectory>/Plugins
|
||||
QString pluginsDirectory;
|
||||
|
||||
// Custom themes live here. <appDataDirectory>/Themes
|
||||
QString themesDirectory;
|
||||
|
||||
bool createFolder(const QString &folderPath);
|
||||
bool isPortable();
|
||||
|
||||
|
||||
+164
-25
@@ -3,17 +3,21 @@
|
||||
|
||||
#include "Application.hpp"
|
||||
#include "common/QLogging.hpp"
|
||||
#include "singletons/Paths.hpp"
|
||||
#include "singletons/Resources.hpp"
|
||||
|
||||
#include <QColor>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSet>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
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<QJsonObject> loadTheme(const ThemeDescriptor &theme)
|
||||
{
|
||||
static QSet<QString> 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<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
|
||||
{
|
||||
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<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();
|
||||
}
|
||||
|
||||
void Theme::parse()
|
||||
std::vector<std::pair<QString, QVariant>> Theme::availableThemes() const
|
||||
{
|
||||
QFile file(getThemePath(this->themeName));
|
||||
if (!file.open(QFile::ReadOnly))
|
||||
std::vector<std::pair<QString, QVariant>> 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<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)
|
||||
|
||||
@@ -6,16 +6,40 @@
|
||||
|
||||
#include <pajlada/settings/setting.hpp>
|
||||
#include <QColor>
|
||||
#include <QJsonObject>
|
||||
#include <QPixmap>
|
||||
#include <QString>
|
||||
#include <QVariant>
|
||||
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
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<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;
|
||||
|
||||
@@ -114,6 +138,11 @@ public:
|
||||
void normalizeColor(QColor &color) const;
|
||||
void update();
|
||||
|
||||
/**
|
||||
* Return a list of available themes
|
||||
**/
|
||||
std::vector<std::pair<QString, QVariant>> 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<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);
|
||||
|
||||
pajlada::Signals::NoArgSignal repaintVisibleChatWidgets_;
|
||||
|
||||
Reference in New Issue
Block a user