mirror of
https://github.com/obsproject/obs-studio.git
synced 2024-09-20 04:42:18 +02:00
503968671d
Co-authored-by: Warchamp7 <warchamp2003@hotmail.com>
985 lines
24 KiB
C++
985 lines
24 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;
|
|
|
|
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;
|
|
themeDir.resize(512);
|
|
|
|
QStringList filters;
|
|
filters << "*.obt" // OBS Base Theme
|
|
<< "*.ovt" // OBS Variant Theme
|
|
<< "*.oha" // OBS High-contrast Adjustment layer
|
|
;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* 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();
|
|
setStyle(new OBSProxyStyle());
|
|
|
|
/* 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;
|
|
}
|