mirror of
https://github.com/Chatterino/chatterino2.git
synced 2024-11-13 19:49:51 +01:00
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:
parent
82dff89f3b
commit
5d0bdc195e
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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_;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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();
|
||||||
|
|
Loading…
Reference in a new issue