Files
obs-studio/UI/obs-app-theming.cpp
derrod 4a46d2d722 UI: Fix themeDir buffer being resized incorrectly
c677bac875 changed the order here, but
this also resulted in the string having whatever size was necessary for
the install data path, rather than being large enough to fit a userdata
path. To fix this, move the resize operaetion *after* the buit-in
themes are searched, and also bump it to 1024 just to be sure.

This resulted in a crash due to a bug in os_get_path_internal() which
will need to be fixed separately.
2024-06-12 17:57:02 -04:00

1012 lines
25 KiB
C++

/******************************************************************************
Copyright (C) 2023 by Dennis Sädtler <dennis@obsproject.com>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
******************************************************************************/
#include <cinttypes>
#include <util/cf-parser.h>
#include <QDir>
#include <QFile>
#include <QTimer>
#include <QMetaEnum>
#include <QDirIterator>
#include <QGuiApplication>
#include <QRandomGenerator>
#include "qt-wrappers.hpp"
#include "obs-app.hpp"
#include "obs-app-theming.hpp"
#include "obs-proxy-style.hpp"
#include "platform.hpp"
#include "ui-config.h"
using namespace std;
struct CFParser {
cf_parser cfp = {};
~CFParser() { cf_parser_free(&cfp); }
operator cf_parser *() { return &cfp; }
cf_parser *operator->() { return &cfp; }
};
static OBSTheme *ParseThemeMeta(const QString &path)
{
QFile themeFile(path);
if (!themeFile.open(QIODeviceBase::ReadOnly))
return nullptr;
OBSTheme *meta = nullptr;
const QByteArray data = themeFile.readAll();
CFParser cfp;
int ret;
if (!cf_parser_parse(cfp, data.constData(), QT_TO_UTF8(path)))
return nullptr;
if (cf_token_is(cfp, "@") || cf_go_to_token(cfp, "@", nullptr)) {
while (cf_next_token(cfp)) {
if (cf_token_is(cfp, "OBSThemeMeta"))
break;
if (!cf_go_to_token(cfp, "@", nullptr))
return nullptr;
}
if (!cf_next_token(cfp))
return nullptr;
if (!cf_token_is(cfp, "{"))
return nullptr;
meta = new OBSTheme();
for (;;) {
if (!cf_next_token(cfp)) {
delete meta;
return nullptr;
}
ret = cf_token_is_type(cfp, CFTOKEN_NAME, "name",
nullptr);
if (ret != PARSE_SUCCESS)
break;
string name(cfp->cur_token->str.array,
cfp->cur_token->str.len);
ret = cf_next_token_should_be(cfp, ":", ";", nullptr);
if (ret != PARSE_SUCCESS)
continue;
if (!cf_next_token(cfp)) {
delete meta;
return nullptr;
}
ret = cf_token_is_type(cfp, CFTOKEN_STRING, "value",
";");
if (ret != PARSE_SUCCESS)
continue;
BPtr str = cf_literal_to_str(cfp->cur_token->str.array,
cfp->cur_token->str.len);
if (str) {
if (name == "dark")
meta->isDark = strcmp(str, "true") == 0;
else if (name == "extends")
meta->extends = str;
else if (name == "author")
meta->author = str;
else if (name == "id")
meta->id = str;
else if (name == "name")
meta->name = str;
}
if (!cf_go_to_token(cfp, ";", nullptr)) {
delete meta;
return nullptr;
}
}
}
if (meta) {
auto filepath = filesystem::u8path(path.toStdString());
meta->isBaseTheme = filepath.extension() == ".obt";
meta->filename = filepath.stem();
if (meta->id.isEmpty() || meta->name.isEmpty() ||
(!meta->isBaseTheme && meta->extends.isEmpty())) {
/* Theme is invalid */
delete meta;
meta = nullptr;
} else {
meta->location = absolute(filepath);
meta->isHighContrast = path.endsWith(".oha");
meta->isVisible = !path.contains("System");
}
}
return meta;
}
static bool ParseVarName(CFParser &cfp, QString &value)
{
int ret;
ret = cf_next_token_should_be(cfp, "(", ";", nullptr);
if (ret != PARSE_SUCCESS)
return false;
ret = cf_next_token_should_be(cfp, "-", ";", nullptr);
if (ret != PARSE_SUCCESS)
return false;
ret = cf_next_token_should_be(cfp, "-", ";", nullptr);
if (ret != PARSE_SUCCESS)
return false;
if (!cf_next_token(cfp))
return false;
value = QString::fromUtf8(cfp->cur_token->str.array,
cfp->cur_token->str.len);
ret = cf_next_token_should_be(cfp, ")", ";", nullptr);
if (ret != PARSE_SUCCESS)
return false;
return !value.isEmpty();
}
static QColor ParseColor(CFParser &cfp)
{
const char *array;
uint32_t color = 0;
QColor res(QColor::Invalid);
if (cf_token_is(cfp, "#")) {
if (!cf_next_token(cfp))
return res;
color = strtol(cfp->cur_token->str.array, nullptr, 16);
} else if (cf_token_is(cfp, "rgb")) {
int ret = cf_next_token_should_be(cfp, "(", ";", nullptr);
if (ret != PARSE_SUCCESS || !cf_next_token(cfp))
return res;
array = cfp->cur_token->str.array;
color |= strtol(array, nullptr, 10) << 16;
ret = cf_next_token_should_be(cfp, ",", ";", nullptr);
if (ret != PARSE_SUCCESS || !cf_next_token(cfp))
return res;
array = cfp->cur_token->str.array;
color |= strtol(array, nullptr, 10) << 8;
ret = cf_next_token_should_be(cfp, ",", ";", nullptr);
if (ret != PARSE_SUCCESS || !cf_next_token(cfp))
return res;
array = cfp->cur_token->str.array;
color |= strtol(array, nullptr, 10);
ret = cf_next_token_should_be(cfp, ")", ";", nullptr);
if (ret != PARSE_SUCCESS)
return res;
} else if (cf_token_is(cfp, "bikeshed")) {
color |= QRandomGenerator::global()->bounded(INT8_MAX) << 16;
color |= QRandomGenerator::global()->bounded(INT8_MAX) << 8;
color |= QRandomGenerator::global()->bounded(INT8_MAX);
}
res = color;
return res;
}
static bool ParseCalc(CFParser &cfp, QStringList &calc,
vector<OBSThemeVariable> &vars)
{
int ret = cf_next_token_should_be(cfp, "(", ";", nullptr);
if (ret != PARSE_SUCCESS)
return false;
if (!cf_next_token(cfp))
return false;
while (!cf_token_is(cfp, ")")) {
if (cf_token_is(cfp, ";"))
break;
if (cf_token_is(cfp, "calc")) {
/* Internal calc's do not have proper names.
* They are anonymous variables */
OBSThemeVariable var;
QStringList subcalc;
var.name = QString("__unnamed_%1")
.arg(QRandomGenerator::global()
->generate64());
if (!ParseCalc(cfp, subcalc, vars))
return false;
var.type = OBSThemeVariable::Calc;
var.value = subcalc;
calc << var.name;
vars.push_back(std::move(var));
} else if (cf_token_is(cfp, "var")) {
QString value;
if (!ParseVarName(cfp, value))
return false;
calc << value;
} else {
calc << QString::fromUtf8(cfp->cur_token->str.array,
cfp->cur_token->str.len);
}
if (!cf_next_token(cfp))
return false;
}
return !calc.isEmpty();
}
static vector<OBSThemeVariable> ParseThemeVariables(const char *themeData)
{
CFParser cfp;
int ret;
std::vector<OBSThemeVariable> vars;
if (!cf_parser_parse(cfp, themeData, nullptr))
return vars;
if (!cf_token_is(cfp, "@") && !cf_go_to_token(cfp, "@", nullptr))
return vars;
while (cf_next_token(cfp)) {
if (cf_token_is(cfp, "OBSThemeVars"))
break;
if (!cf_go_to_token(cfp, "@", nullptr))
return vars;
}
if (!cf_next_token(cfp))
return {};
if (!cf_token_is(cfp, "{"))
return {};
for (;;) {
if (!cf_next_token(cfp))
return vars;
if (!cf_token_is(cfp, "-"))
return vars;
ret = cf_next_token_should_be(cfp, "-", ";", nullptr);
if (ret != PARSE_SUCCESS)
continue;
if (!cf_next_token(cfp))
return vars;
ret = cf_token_is_type(cfp, CFTOKEN_NAME, "key", nullptr);
if (ret != PARSE_SUCCESS)
break;
QString key = QString::fromUtf8(cfp->cur_token->str.array,
cfp->cur_token->str.len);
OBSThemeVariable var;
var.name = key;
#ifdef _WIN32
const QString osPrefix = "os_win_";
#elif __APPLE__
const QString osPrefix = "os_mac_";
#else
const QString osPrefix = "os_lin_";
#endif
if (key.startsWith(osPrefix) &&
key.length() > osPrefix.length()) {
var.name = key.sliced(osPrefix.length());
}
ret = cf_next_token_should_be(cfp, ":", ";", nullptr);
if (ret != PARSE_SUCCESS)
continue;
if (!cf_next_token(cfp))
return vars;
if (cfp->cur_token->type == CFTOKEN_NUM) {
const char *ch = cfp->cur_token->str.array;
const char *end = ch + cfp->cur_token->str.len;
double f = os_strtod(ch);
var.value = f;
var.type = OBSThemeVariable::Number;
/* Look for a suffix and mark variable as size if it exists */
while (ch < end) {
if (!isdigit(*ch) && !isspace(*ch) &&
*ch != '.') {
var.suffix =
QString::fromUtf8(ch, end - ch);
var.type = OBSThemeVariable::Size;
break;
}
ch++;
}
} else if (cf_token_is(cfp, "rgb") || cf_token_is(cfp, "#") ||
cf_token_is(cfp, "bikeshed")) {
QColor color = ParseColor(cfp);
if (!color.isValid())
continue;
var.value = color;
var.type = OBSThemeVariable::Color;
} else if (cf_token_is(cfp, "var")) {
QString value;
if (!ParseVarName(cfp, value))
continue;
var.value = value;
var.type = OBSThemeVariable::Alias;
} else if (cf_token_is(cfp, "calc")) {
QStringList calc;
if (!ParseCalc(cfp, calc, vars))
continue;
var.type = OBSThemeVariable::Calc;
var.value = calc;
} else {
var.type = OBSThemeVariable::String;
BPtr strVal =
cf_literal_to_str(cfp->cur_token->str.array,
cfp->cur_token->str.len);
var.value = QString::fromUtf8(strVal.Get());
}
if (!cf_next_token(cfp))
return vars;
if (cf_token_is(cfp, "!") &&
cf_next_token_should_be(cfp, "editable", nullptr,
nullptr) == PARSE_SUCCESS) {
if (var.type == OBSThemeVariable::Calc ||
var.type == OBSThemeVariable::Alias) {
blog(LOG_WARNING,
"Variable of calc/alias type cannot be editable: %s",
QT_TO_UTF8(var.name));
} else {
var.editable = true;
}
}
vars.push_back(std::move(var));
if (!cf_token_is(cfp, ";") &&
!cf_go_to_token(cfp, ";", nullptr))
return vars;
}
return vars;
}
void OBSApp::FindThemes()
{
string themeDir;
QStringList filters;
filters << "*.obt" // OBS Base Theme
<< "*.ovt" // OBS Variant Theme
<< "*.oha" // OBS High-contrast Adjustment layer
;
GetDataFilePath("themes/", themeDir);
QDirIterator it(QString::fromStdString(themeDir), filters, QDir::Files);
while (it.hasNext()) {
OBSTheme *theme = ParseThemeMeta(it.next());
if (theme && !themes.contains(theme->id))
themes[theme->id] = std::move(*theme);
else
delete theme;
}
themeDir.resize(1024);
if (GetConfigPath(themeDir.data(), themeDir.capacity(),
"obs-studio/themes/") > 0) {
QDirIterator it(QT_UTF8(themeDir.c_str()), filters,
QDir::Files);
while (it.hasNext()) {
OBSTheme *theme = ParseThemeMeta(it.next());
if (theme && !themes.contains(theme->id))
themes[theme->id] = std::move(*theme);
else
delete theme;
}
}
/* Build dependency tree for all themes, removing ones that have items missing. */
QSet<QString> invalid;
for (OBSTheme &theme : themes) {
if (theme.extends.isEmpty()) {
if (!theme.isBaseTheme) {
blog(LOG_ERROR,
R"(Theme "%s" is not base, but does not specify parent!)",
QT_TO_UTF8(theme.id));
invalid.insert(theme.id);
}
continue;
}
QString parentId = theme.extends;
while (!parentId.isEmpty()) {
OBSTheme *parent = GetTheme(parentId);
if (!parent) {
blog(LOG_ERROR,
R"(Theme "%s" is missing ancestor "%s"!)",
QT_TO_UTF8(theme.id),
QT_TO_UTF8(parentId));
invalid.insert(theme.id);
break;
}
if (theme.isBaseTheme && !parent->isBaseTheme) {
blog(LOG_ERROR,
R"(Ancestor "%s" of base theme "%s" is not a base theme!)",
QT_TO_UTF8(parent->id),
QT_TO_UTF8(theme.id));
invalid.insert(theme.id);
break;
}
if (parent->id == theme.id ||
theme.dependencies.contains(parent->id)) {
blog(LOG_ERROR,
R"(Dependency chain of "%s" ("%s") contains recursion!)",
QT_TO_UTF8(theme.id),
QT_TO_UTF8(parent->id));
invalid.insert(theme.id);
break;
}
/* Mark this theme as a variant of first parent that is a base theme. */
if (!theme.isBaseTheme && parent->isBaseTheme &&
theme.parent.isEmpty())
theme.parent = parent->id;
theme.dependencies.push_front(parent->id);
parentId = parent->extends;
if (parentId.isEmpty() && !parent->isBaseTheme) {
blog(LOG_ERROR,
R"(Final ancestor of "%s" ("%s") is not a base theme!)",
QT_TO_UTF8(theme.id),
QT_TO_UTF8(parent->id));
invalid.insert(theme.id);
break;
}
}
}
for (const QString &name : invalid) {
themes.remove(name);
}
}
static bool ResolveVariable(const QHash<QString, OBSThemeVariable> &vars,
OBSThemeVariable &var)
{
const OBSThemeVariable *varPtr = &var;
const OBSThemeVariable *realVar = varPtr;
while (realVar->type == OBSThemeVariable::Alias) {
QString newKey = realVar->value.toString();
if (!vars.contains(newKey)) {
blog(LOG_ERROR,
R"(Variable "%s" (aliased by "%s") does not exist!)",
QT_TO_UTF8(newKey), QT_TO_UTF8(var.name));
return false;
}
const OBSThemeVariable &newVar = vars[newKey];
realVar = &newVar;
}
if (realVar != varPtr)
var = *realVar;
return true;
}
static QString EvalCalc(const QHash<QString, OBSThemeVariable> &vars,
const OBSThemeVariable &var, const int recursion = 0);
static OBSThemeVariable
ParseCalcVariable(const QHash<QString, OBSThemeVariable> &vars,
const QString &value, const int recursion = 0)
{
OBSThemeVariable var;
const QByteArray utf8 = value.toUtf8();
const char *data = utf8.constData();
if (isdigit(*data)) {
double f = os_strtod(data);
var.type = OBSThemeVariable::Number;
var.value = f;
const char *dataEnd = data + utf8.size();
while (data < dataEnd) {
if (*data && !isdigit(*data) && *data != '.') {
var.suffix =
QString::fromUtf8(data, dataEnd - data);
var.type = OBSThemeVariable::Size;
break;
}
data++;
}
} else {
/* Treat value as an alias/key and resolve it */
var.type = OBSThemeVariable::Alias;
var.value = value;
ResolveVariable(vars, var);
/* Handle nested calc()s */
if (var.type == OBSThemeVariable::Calc) {
QString val = EvalCalc(vars, var, recursion + 1);
var = ParseCalcVariable(vars, val);
}
/* Only number or size would be valid here */
if (var.type != OBSThemeVariable::Number &&
var.type != OBSThemeVariable::Size) {
blog(LOG_ERROR,
"calc() operand is not a size or number: %s",
QT_TO_UTF8(var.value.toString()));
throw invalid_argument("Operand not of numeric type");
}
}
return var;
}
static QString EvalCalc(const QHash<QString, OBSThemeVariable> &vars,
const OBSThemeVariable &var, const int recursion)
{
if (recursion >= 10) {
/* Abort after 10 levels of recursion */
blog(LOG_ERROR, "Maximum calc() recursion levels hit!");
return "'Invalid expression'";
}
QStringList args = var.value.toStringList();
if (args.length() != 3) {
blog(LOG_ERROR,
"calc() had invalid number of arguments: %lld (%s)",
args.length(), QT_TO_UTF8(args.join(", ")));
return "'Invalid expression'";
}
QString &opt = args[1];
if (opt != '*' && opt != '+' && opt != '-' && opt != '/') {
blog(LOG_ERROR, "Unknown/invalid calc() operator: %s",
QT_TO_UTF8(opt));
return "'Invalid expression'";
}
OBSThemeVariable val1, val2;
try {
val1 = ParseCalcVariable(vars, args[0], recursion);
val2 = ParseCalcVariable(vars, args[2], recursion);
} catch (...) {
return "'Invalid expression'";
}
/* Ensure that suffixes match (if any) */
if (!val1.suffix.isEmpty() && !val2.suffix.isEmpty() &&
val1.suffix != val2.suffix) {
blog(LOG_ERROR,
"calc() requires suffixes to match or only one to be present! %s != %s",
QT_TO_UTF8(val1.suffix), QT_TO_UTF8(val2.suffix));
return "'Invalid expression'";
}
double val = numeric_limits<double>::quiet_NaN();
double d1 = val1.userValue.isValid() ? val1.userValue.toDouble()
: val1.value.toDouble();
double d2 = val2.userValue.isValid() ? val2.userValue.toDouble()
: val2.value.toDouble();
if (!isfinite(d1) || !isfinite(d2)) {
blog(LOG_ERROR,
"calc() received at least one invalid value:"
" op1: %f, op2: %f",
d1, d2);
return "'Invalid expression'";
}
if (opt == "+")
val = d1 + d2;
else if (opt == "-")
val = d1 - d2;
else if (opt == "*")
val = d1 * d2;
else if (opt == "/")
val = d1 / d2;
if (!isnormal(val)) {
blog(LOG_ERROR,
"Invalid calc() math resulted in non-normal number:"
" %f %s %f = %f",
d1, QT_TO_UTF8(opt), d2, val);
return "'Invalid expression'";
}
bool isInteger = ceill(val) == val;
QString result = QString::number(val, 'f', isInteger ? 0 : -1);
/* Carry-over suffix */
if (!val1.suffix.isEmpty())
result += val1.suffix;
else if (!val2.suffix.isEmpty())
result += val2.suffix;
return result;
}
static qsizetype FindEndOfOBSMetadata(const QString &content)
{
/* Find end of last OBS-specific section and strip it, kinda jank but should work */
qsizetype end = 0;
for (auto section : {"OBSThemeMeta", "OBSThemeVars", "OBSTheme"}) {
qsizetype idx = content.indexOf(section, 0);
if (idx > end) {
end = content.indexOf('}', idx) + 1;
}
}
return end;
}
static QString PrepareQSS(const QHash<QString, OBSThemeVariable> &vars,
const QStringList &contents)
{
QString stylesheet;
QString needleTemplate("var(--%1)");
for (const QString &content : contents) {
qsizetype offset = FindEndOfOBSMetadata(content);
if (offset >= 0) {
stylesheet += "\n";
stylesheet += content.sliced(offset);
}
}
for (const OBSThemeVariable &var_ : vars) {
OBSThemeVariable var(var_);
if (!ResolveVariable(vars, var))
continue;
QString needle = needleTemplate.arg(var_.name);
QString replace;
QVariant value = var.userValue.isValid() ? var.userValue
: var.value;
if (var.type == OBSThemeVariable::Color) {
replace = value.value<QColor>().name(QColor::HexRgb);
} else if (var.type == OBSThemeVariable::Calc) {
replace = EvalCalc(vars, var);
} else if (var.type == OBSThemeVariable::Size ||
var.type == OBSThemeVariable::Number) {
double val = value.toDouble();
bool isInteger = ceill(val) == val;
replace = QString::number(val, 'f', isInteger ? 0 : -1);
if (!var.suffix.isEmpty())
replace += var.suffix;
} else {
replace = value.toString();
}
stylesheet = stylesheet.replace(needle, replace);
}
return stylesheet;
}
template<typename T> static void FillEnumMap(QHash<QString, T> &map)
{
QMetaEnum meta = QMetaEnum::fromType<T>();
int numKeys = meta.keyCount();
for (int i = 0; i < numKeys; i++) {
const char *key = meta.key(i);
QString keyName(key);
map[keyName.toLower()] = static_cast<T>(meta.keyToValue(key));
}
}
static QPalette PreparePalette(const QHash<QString, OBSThemeVariable> &vars,
const QPalette &defaultPalette)
{
static QHash<QString, QPalette::ColorRole> roleMap;
static QHash<QString, QPalette::ColorGroup> groupMap;
if (roleMap.empty())
FillEnumMap<QPalette::ColorRole>(roleMap);
if (groupMap.empty())
FillEnumMap<QPalette::ColorGroup>(groupMap);
QPalette pal(defaultPalette);
for (const OBSThemeVariable &var_ : vars) {
if (!var_.name.startsWith("palette_"))
continue;
if (var_.name.count("_") < 1 || var_.name.count("_") > 2)
continue;
OBSThemeVariable var(var_);
if (!ResolveVariable(vars, var) ||
var.type != OBSThemeVariable::Color)
continue;
/* Determine role and optionally group based on name.
* Format is: palette_<role>[_<group>] */
QPalette::ColorRole role = QPalette::NoRole;
QPalette::ColorGroup group = QPalette::All;
QStringList parts = var_.name.split("_");
if (parts.length() >= 2) {
QString key = parts[1].toLower();
if (!roleMap.contains(key)) {
blog(LOG_WARNING,
"Palette role \"%s\" is not valid!",
QT_TO_UTF8(parts[1]));
continue;
}
role = roleMap[key];
}
if (parts.length() == 3) {
QString key = parts[2].toLower();
if (!groupMap.contains(key)) {
blog(LOG_WARNING,
"Palette group \"%s\" is not valid!",
QT_TO_UTF8(parts[2]));
continue;
}
group = groupMap[key];
}
QVariant value = var.userValue.isValid() ? var.userValue
: var.value;
QColor color = value.value<QColor>().name(QColor::HexRgb);
pal.setColor(group, role, color);
}
return pal;
}
OBSTheme *OBSApp::GetTheme(const QString &name)
{
if (!themes.contains(name))
return nullptr;
return &themes[name];
}
bool OBSApp::SetTheme(const QString &name)
{
OBSTheme *theme = GetTheme(name);
if (!theme)
return false;
if (themeWatcher) {
themeWatcher->blockSignals(true);
themeWatcher->removePaths(themeWatcher->files());
}
setStyleSheet("");
currentTheme = theme;
QStringList contents;
QHash<QString, OBSThemeVariable> vars;
/* Build list of themes to load (in order) */
QStringList themeIds(theme->dependencies);
themeIds << theme->id;
/* Find and add high contrast adjustment layer if available */
if (HighContrastEnabled()) {
for (const OBSTheme &theme_ : themes) {
if (!theme_.isHighContrast)
continue;
if (theme_.parent != theme->id)
continue;
themeIds << theme_.id;
break;
}
}
QStringList filenames;
for (const QString &themeId : themeIds) {
OBSTheme *cur = GetTheme(themeId);
QFile file(cur->location);
filenames << file.fileName();
if (!file.open(QIODeviceBase::ReadOnly))
return false;
const QByteArray content = file.readAll();
for (OBSThemeVariable &var :
ParseThemeVariables(content.constData())) {
vars[var.name] = std::move(var);
}
contents.emplaceBack(content.constData());
}
const QString stylesheet = PrepareQSS(vars, contents);
const QPalette palette = PreparePalette(vars, defaultPalette);
setPalette(palette);
setStyleSheet(stylesheet);
#ifdef _DEBUG
/* Write resulting QSS to file in config dir "themes" folder. */
string filename("obs-studio/themes/");
filename += theme->id.toStdString();
filename += ".out";
filesystem::path debugOut;
char configPath[512];
if (GetConfigPath(configPath, sizeof(configPath), filename.c_str())) {
debugOut = absolute(filesystem::u8path(configPath));
filesystem::create_directories(debugOut.parent_path());
}
QFile debugFile(debugOut);
if (debugFile.open(QIODeviceBase::WriteOnly)) {
debugFile.write(stylesheet.toUtf8());
debugFile.flush();
}
#endif
#ifdef __APPLE__
SetMacOSDarkMode(theme->isDark);
#endif
emit StyleChanged();
if (themeWatcher) {
themeWatcher->addPaths(filenames);
/* Give it 250 ms before re-enabling the watcher to prevent too
* many reloads when edited with an auto-saving IDE. */
QTimer::singleShot(250, this,
[&] { themeWatcher->blockSignals(false); });
}
return true;
}
void OBSApp::themeFileChanged(const QString &path)
{
themeWatcher->blockSignals(true);
blog(LOG_INFO, "Theme file \"%s\" changed, reloading...",
QT_TO_UTF8(path));
SetTheme(currentTheme->id);
}
static map<string, string> themeMigrations = {
{"Yami", DEFAULT_THEME},
{"Grey", "com.obsproject.Yami.Grey"},
{"Rachni", "com.obsproject.Yami.Rachni"},
{"Light", "com.obsproject.Yami.Light"},
{"Dark", "com.obsproject.Yami.Classic"},
{"Acri", "com.obsproject.Yami.Acri"},
{"System", "com.obsproject.System"},
};
bool OBSApp::InitTheme()
{
defaultPalette = palette();
#if !defined(_WIN32) && !defined(__APPLE__)
setStyle(new OBSProxyStyle("Fusion"));
#else
setStyle(new OBSProxyStyle());
#endif
/* Set search paths for custom 'theme:' URI prefix */
string searchDir;
if (GetDataFilePath("themes", searchDir)) {
auto installSearchDir = filesystem::u8path(searchDir);
QDir::addSearchPath("theme", absolute(installSearchDir));
}
char userDir[512];
if (GetConfigPath(userDir, sizeof(userDir), "obs-studio/themes")) {
auto configSearchDir = filesystem::u8path(userDir);
QDir::addSearchPath("theme", absolute(configSearchDir));
}
/* Load list of themes and read their metadata */
FindThemes();
if (config_get_bool(globalConfig, "Appearance", "AutoReload")) {
/* Set up Qt file watcher to automatically reload themes */
themeWatcher = new QFileSystemWatcher(this);
connect(themeWatcher.get(), &QFileSystemWatcher::fileChanged,
this, &OBSApp::themeFileChanged);
}
/* Migrate old theme config key */
if (config_has_user_value(globalConfig, "General", "CurrentTheme3") &&
!config_has_user_value(globalConfig, "Appearance", "Theme")) {
const char *old = config_get_string(globalConfig, "General",
"CurrentTheme3");
if (themeMigrations.count(old)) {
config_set_string(globalConfig, "Appearance", "Theme",
themeMigrations[old].c_str());
}
}
QString themeName =
config_get_string(globalConfig, "Appearance", "Theme");
if (themeName.isEmpty() || !GetTheme(themeName)) {
if (!themeName.isEmpty()) {
blog(LOG_WARNING,
"Loading theme \"%s\" failed, falling back to "
"default theme (\"%s\").",
QT_TO_UTF8(themeName), DEFAULT_THEME);
}
#ifdef _WIN32
themeName = HighContrastEnabled() ? "com.obsproject.System"
: DEFAULT_THEME;
#else
themeName = DEFAULT_THEME;
#endif
}
if (!SetTheme(themeName)) {
blog(LOG_ERROR,
"Loading default theme \"%s\" failed, falling back to "
"system theme as last resort.",
QT_TO_UTF8(themeName));
return SetTheme("com.obsproject.System");
}
return true;
}