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
|
||||
|
||||
- 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
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
this->parseFrom(json.object());
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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_;
|
||||
|
|
|
@ -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<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>(
|
||||
"Font", {"Segoe UI", "Arial", "Choose..."},
|
||||
getApp()->fonts->chatFontFamily,
|
||||
|
|
|
@ -14,6 +14,8 @@
|
|||
#include <QSpinBox>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <utility>
|
||||
|
||||
class QScrollArea;
|
||||
|
||||
namespace chatterino {
|
||||
|
@ -192,6 +194,59 @@ public:
|
|||
|
||||
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);
|
||||
|
||||
void addSeperator();
|
||||
|
|
Loading…
Reference in a new issue