diff --git a/UI/CMakeLists.txt b/UI/CMakeLists.txt
index 1dbc267e4..d060dd96b 100644
--- a/UI/CMakeLists.txt
+++ b/UI/CMakeLists.txt
@@ -71,6 +71,8 @@ target_sources(
multiview.hpp
obf.c
obf.h
+ obs-app-theming.cpp
+ obs-app-theming.hpp
obs-app.cpp
obs-app.hpp
obs-proxy-style.cpp
diff --git a/UI/cmake/legacy.cmake b/UI/cmake/legacy.cmake
index 806e382bc..c59013173 100644
--- a/UI/cmake/legacy.cmake
+++ b/UI/cmake/legacy.cmake
@@ -133,6 +133,8 @@ target_sources(
auth-listener.hpp
obf.c
obf.h
+ obs-app-theming.cpp
+ obs-app-theming.hpp
obs-app.cpp
obs-app.hpp
obs-proxy-style.cpp
@@ -250,6 +252,7 @@ target_sources(
window-basic-settings.cpp
window-basic-settings.hpp
window-basic-settings-a11y.cpp
+ window-basic-settings-appearance.cpp
window-basic-settings-stream.cpp
window-basic-source-select.cpp
window-basic-source-select.hpp
diff --git a/UI/cmake/ui-windows.cmake b/UI/cmake/ui-windows.cmake
index 1c08ba5ee..40675ecc6 100644
--- a/UI/cmake/ui-windows.cmake
+++ b/UI/cmake/ui-windows.cmake
@@ -28,6 +28,7 @@ target_sources(
window-basic-properties.cpp
window-basic-properties.hpp
window-basic-settings-a11y.cpp
+ window-basic-settings-appearance.cpp
window-basic-settings-stream.cpp
window-basic-settings.cpp
window-basic-settings.hpp
diff --git a/UI/data/locale/en-US.ini b/UI/data/locale/en-US.ini
index a5c043843..b9325190d 100644
--- a/UI/data/locale/en-US.ini
+++ b/UI/data/locale/en-US.ini
@@ -862,7 +862,6 @@ Basic.Settings.Confirm="You have unsaved changes. Save changes?"
# basic mode 'general' settings
Basic.Settings.General="General"
-Basic.Settings.General.Theme="Theme"
Basic.Settings.General.Language="Language"
Basic.Settings.General.Updater="Updates"
Basic.Settings.General.UpdateChannel="Update Channel"
@@ -928,6 +927,9 @@ Basic.Settings.General.ChannelDescription.beta="Potentially unstable pre-release
# basic mode 'appearance' settings
Basic.Settings.Appearance="Appearance"
Basic.Settings.Appearance.General="General"
+Basic.Settings.Appearance.General.Theme="Theme"
+Basic.Settings.Appearance.General.Variant="Style"
+Basic.Settings.Appearance.General.NoVariant="No Styles Available"
# basic mode 'stream' settings
Basic.Settings.Stream="Stream"
diff --git a/UI/forms/OBSBasicSettings.ui b/UI/forms/OBSBasicSettings.ui
index 18d02da6f..13772c172 100644
--- a/UI/forms/OBSBasicSettings.ui
+++ b/UI/forms/OBSBasicSettings.ui
@@ -948,7 +948,7 @@
-
- Basic.Settings.General.Theme
+ Basic.Settings.Appearance.General.Theme
theme
@@ -958,6 +958,19 @@
-
+ -
+
+
+ Basic.Settings.Appearance.General.Variant
+
+
+ themeVariant
+
+
+
+ -
+
+
@@ -7991,6 +8004,7 @@
multiviewDrawAreas
multiviewLayout
theme
+ themeVariant
service
moreInfoButton
connectAccount
diff --git a/UI/obs-app-theming.cpp b/UI/obs-app-theming.cpp
new file mode 100644
index 000000000..984a7136a
--- /dev/null
+++ b/UI/obs-app-theming.cpp
@@ -0,0 +1,984 @@
+/******************************************************************************
+ Copyright (C) 2023 by Dennis Sädtler
+
+ 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 .
+******************************************************************************/
+
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#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 &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 ParseThemeVariables(const char *themeData)
+{
+ CFParser cfp;
+ int ret;
+
+ std::vector 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 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 &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 &vars,
+ const OBSThemeVariable &var, const int recursion = 0);
+
+static OBSThemeVariable
+ParseCalcVariable(const QHash &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 &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::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 &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().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 static void FillEnumMap(QHash &map)
+{
+ QMetaEnum meta = QMetaEnum::fromType();
+
+ 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(meta.keyToValue(key));
+ }
+}
+
+static QPalette PreparePalette(const QHash &vars,
+ const QPalette &defaultPalette)
+{
+ static QHash roleMap;
+ static QHash groupMap;
+
+ if (roleMap.empty())
+ FillEnumMap(roleMap);
+ if (groupMap.empty())
+ FillEnumMap(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_[_] */
+ 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().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 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 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;
+}
diff --git a/UI/obs-app-theming.hpp b/UI/obs-app-theming.hpp
new file mode 100644
index 000000000..cbaed7f24
--- /dev/null
+++ b/UI/obs-app-theming.hpp
@@ -0,0 +1,66 @@
+/******************************************************************************
+ Copyright (C) 2023 by Dennis Sädtler
+
+ 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 .
+******************************************************************************/
+
+#pragma once
+
+#include
+
+#include
+
+struct OBSThemeVariable;
+
+struct OBSTheme {
+ /* internal name, must be unique */
+ QString id;
+ QString name;
+ QString author;
+ QString extends;
+
+ /* First ancestor base theme */
+ QString parent;
+ /* Dependencies from root to direct ancestor */
+ QStringList dependencies;
+ /* File path */
+ std::filesystem::path location;
+ std::filesystem::path filename; /* Filename without extension */
+
+ bool isDark;
+ bool isVisible; /* Whether it should be shown to the user */
+ bool isBaseTheme; /* Whether it is a "style" or variant */
+ bool isHighContrast; /* Whether it is a high-contrast adjustment layer */
+};
+
+struct OBSThemeVariable {
+ enum VariableType {
+ Color, /* RGB color value*/
+ Size, /* Number with suffix denoting size (e.g. px, pt, em) */
+ Number, /* Number without suffix */
+ String, /* Raw string (e.g. color name, border style, etc.) */
+ Alias, /* Points at another variable, value will be the key */
+ Calc, /* Simple calculation with two operands */
+ };
+
+ /* Whether the variable should be editable in the UI */
+ bool editable = false;
+ /* Used for VariableType::Size only */
+ QString suffix;
+
+ VariableType type;
+ QString name;
+ QVariant value;
+ QVariant userValue; /* If overwritten by user, use this value instead */
+};
diff --git a/UI/obs-app.cpp b/UI/obs-app.cpp
index 887d6bb63..781838e5e 100644
--- a/UI/obs-app.cpp
+++ b/UI/obs-app.cpp
@@ -923,383 +923,6 @@ bool OBSApp::InitLocale()
return true;
}
-void OBSApp::AddExtraThemeColor(QPalette &pal, int group, const char *name,
- uint32_t color)
-{
- std::function func;
-
-#define DEF_PALETTE_ASSIGN(name) \
- do { \
- func = [&](QPalette::ColorGroup group) { \
- pal.setColor(group, QPalette::name, \
- QColor::fromRgb(color)); \
- }; \
- } while (false)
-
- if (astrcmpi(name, "alternateBase") == 0) {
- DEF_PALETTE_ASSIGN(AlternateBase);
- } else if (astrcmpi(name, "base") == 0) {
- DEF_PALETTE_ASSIGN(Base);
- } else if (astrcmpi(name, "brightText") == 0) {
- DEF_PALETTE_ASSIGN(BrightText);
- } else if (astrcmpi(name, "button") == 0) {
- DEF_PALETTE_ASSIGN(Button);
- } else if (astrcmpi(name, "buttonText") == 0) {
- DEF_PALETTE_ASSIGN(ButtonText);
- } else if (astrcmpi(name, "brightText") == 0) {
- DEF_PALETTE_ASSIGN(BrightText);
- } else if (astrcmpi(name, "dark") == 0) {
- DEF_PALETTE_ASSIGN(Dark);
- } else if (astrcmpi(name, "highlight") == 0) {
- DEF_PALETTE_ASSIGN(Highlight);
- } else if (astrcmpi(name, "highlightedText") == 0) {
- DEF_PALETTE_ASSIGN(HighlightedText);
- } else if (astrcmpi(name, "light") == 0) {
- DEF_PALETTE_ASSIGN(Light);
- } else if (astrcmpi(name, "link") == 0) {
- DEF_PALETTE_ASSIGN(Link);
- } else if (astrcmpi(name, "linkVisited") == 0) {
- DEF_PALETTE_ASSIGN(LinkVisited);
- } else if (astrcmpi(name, "mid") == 0) {
- DEF_PALETTE_ASSIGN(Mid);
- } else if (astrcmpi(name, "midlight") == 0) {
- DEF_PALETTE_ASSIGN(Midlight);
- } else if (astrcmpi(name, "shadow") == 0) {
- DEF_PALETTE_ASSIGN(Shadow);
- } else if (astrcmpi(name, "text") == 0 ||
- astrcmpi(name, "foreground") == 0) {
- DEF_PALETTE_ASSIGN(Text);
- } else if (astrcmpi(name, "toolTipBase") == 0) {
- DEF_PALETTE_ASSIGN(ToolTipBase);
- } else if (astrcmpi(name, "toolTipText") == 0) {
- DEF_PALETTE_ASSIGN(ToolTipText);
- } else if (astrcmpi(name, "windowText") == 0) {
- DEF_PALETTE_ASSIGN(WindowText);
- } else if (astrcmpi(name, "window") == 0 ||
- astrcmpi(name, "background") == 0) {
- DEF_PALETTE_ASSIGN(Window);
- } else {
- return;
- }
-
-#undef DEF_PALETTE_ASSIGN
-
- switch (group) {
- case QPalette::Disabled:
- case QPalette::Active:
- case QPalette::Inactive:
- func((QPalette::ColorGroup)group);
- break;
- default:
- func((QPalette::ColorGroup)QPalette::Disabled);
- func((QPalette::ColorGroup)QPalette::Active);
- func((QPalette::ColorGroup)QPalette::Inactive);
- }
-}
-
-struct CFParser {
- cf_parser cfp = {};
- inline ~CFParser() { cf_parser_free(&cfp); }
- inline operator cf_parser *() { return &cfp; }
- inline cf_parser *operator->() { return &cfp; }
-};
-
-void OBSApp::ParseExtraThemeData(const char *path)
-{
- BPtr data = os_quick_read_utf8_file(path);
- QPalette pal = palette();
- CFParser cfp;
- int ret;
-
- cf_parser_parse(cfp, data, path);
-
- while (cf_go_to_token(cfp, "OBSTheme", nullptr)) {
- if (!cf_next_token(cfp))
- return;
-
- int group = -1;
-
- if (cf_token_is(cfp, ":")) {
- ret = cf_next_token_should_be(cfp, ":", nullptr,
- nullptr);
- if (ret != PARSE_SUCCESS)
- continue;
-
- if (!cf_next_token(cfp))
- return;
-
- if (cf_token_is(cfp, "disabled")) {
- group = QPalette::Disabled;
- } else if (cf_token_is(cfp, "active")) {
- group = QPalette::Active;
- } else if (cf_token_is(cfp, "inactive")) {
- group = QPalette::Inactive;
- } else {
- continue;
- }
-
- if (!cf_next_token(cfp))
- return;
- }
-
- if (!cf_token_is(cfp, "{"))
- continue;
-
- for (;;) {
- if (!cf_next_token(cfp))
- return;
-
- ret = cf_token_is_type(cfp, CFTOKEN_NAME, "name",
- nullptr);
- if (ret != PARSE_SUCCESS)
- break;
-
- DStr name;
- dstr_copy_strref(name, &cfp->cur_token->str);
-
- ret = cf_next_token_should_be(cfp, ":", ";", nullptr);
- if (ret != PARSE_SUCCESS)
- continue;
-
- if (!cf_next_token(cfp))
- return;
-
- const char *array;
- uint32_t color = 0;
-
- if (cf_token_is(cfp, "#")) {
- array = cfp->cur_token->str.array;
- color = strtol(array + 1, nullptr, 16);
-
- } else if (cf_token_is(cfp, "rgb")) {
- ret = cf_next_token_should_be(cfp, "(", ";",
- nullptr);
- if (ret != PARSE_SUCCESS)
- continue;
- if (!cf_next_token(cfp))
- return;
-
- array = cfp->cur_token->str.array;
- color |= strtol(array, nullptr, 10) << 16;
-
- ret = cf_next_token_should_be(cfp, ",", ";",
- nullptr);
- if (ret != PARSE_SUCCESS)
- continue;
- if (!cf_next_token(cfp))
- return;
-
- array = cfp->cur_token->str.array;
- color |= strtol(array, nullptr, 10) << 8;
-
- ret = cf_next_token_should_be(cfp, ",", ";",
- nullptr);
- if (ret != PARSE_SUCCESS)
- continue;
- if (!cf_next_token(cfp))
- return;
-
- array = cfp->cur_token->str.array;
- color |= strtol(array, nullptr, 10);
-
- } else if (cf_token_is(cfp, "white")) {
- color = 0xFFFFFF;
-
- } else if (cf_token_is(cfp, "black")) {
- color = 0;
- }
-
- if (!cf_go_to_token(cfp, ";", nullptr))
- return;
-
- AddExtraThemeColor(pal, group, name->array, color);
- }
-
- ret = cf_token_should_be(cfp, "}", "}", nullptr);
- if (ret != PARSE_SUCCESS)
- continue;
- }
-
- setPalette(pal);
-}
-
-OBSThemeMeta *OBSApp::ParseThemeMeta(const char *path)
-{
- BPtr data = os_quick_read_utf8_file(path);
- CFParser cfp;
- int ret;
-
- if (!cf_parser_parse(cfp, data, path))
- return nullptr;
-
- if (cf_token_is(cfp, "OBSThemeMeta") ||
- cf_go_to_token(cfp, "OBSThemeMeta", nullptr)) {
-
- if (!cf_next_token(cfp))
- return nullptr;
-
- if (!cf_token_is(cfp, "{"))
- return nullptr;
-
- OBSThemeMeta *meta = new OBSThemeMeta();
-
- 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;
-
- DStr name;
- dstr_copy_strref(name, &cfp->cur_token->str);
-
- 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;
-
- char *str;
- str = cf_literal_to_str(cfp->cur_token->str.array,
- cfp->cur_token->str.len);
-
- if (strcmp(name->array, "dark") == 0 && str) {
- meta->dark = strcmp(str, "true") == 0;
- } else if (strcmp(name->array, "parent") == 0 && str) {
- meta->parent = std::string(str);
- } else if (strcmp(name->array, "author") == 0 && str) {
- meta->author = std::string(str);
- }
- bfree(str);
-
- if (!cf_go_to_token(cfp, ";", nullptr)) {
- delete meta;
- return nullptr;
- }
- }
- return meta;
- }
- return nullptr;
-}
-
-std::string OBSApp::GetTheme(std::string name, std::string path)
-{
- /* Check user dir first, then preinstalled themes. */
- if (path == "") {
- char userDir[512];
- name = "themes/" + name + ".qss";
- string temp = "obs-studio/" + name;
- int ret = GetConfigPath(userDir, sizeof(userDir), temp.c_str());
-
- if (ret > 0 && QFile::exists(userDir)) {
- path = string(userDir);
- } else if (!GetDataFilePath(name.c_str(), path)) {
- OBSErrorBox(NULL, "Failed to find %s.", name.c_str());
- return "";
- }
- }
- return path;
-}
-
-std::string OBSApp::SetParentTheme(std::string name)
-{
- string path = GetTheme(name, "");
- if (path.empty())
- return path;
-
- setPalette(defaultPalette);
-
- ParseExtraThemeData(path.c_str());
- return path;
-}
-
-bool OBSApp::SetTheme(std::string name, std::string path)
-{
- theme = name;
-
- path = GetTheme(name, path);
- if (path.empty())
- return false;
-
- setStyleSheet("");
- unique_ptr themeMeta;
- themeMeta.reset(ParseThemeMeta(path.c_str()));
- string parentPath;
-
- if (themeMeta && !themeMeta->parent.empty()) {
- parentPath = SetParentTheme(themeMeta->parent);
- }
-
- string lpath = path;
- if (parentPath.empty()) {
- setPalette(defaultPalette);
- } else {
- lpath = parentPath;
- }
-
- QString mpath = QString("file:///") + lpath.c_str();
- ParseExtraThemeData(path.c_str());
- setStyleSheet(mpath);
- if (themeMeta) {
- themeDarkMode = themeMeta->dark;
- } else {
- QColor color = palette().text().color();
- themeDarkMode = !(color.redF() < 0.5);
- }
-
-#ifdef __APPLE__
- SetMacOSDarkMode(themeDarkMode);
-#endif
-
- emit StyleChanged();
- return true;
-}
-
-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));
- }
-
- const char *themeName =
- config_get_string(globalConfig, "General", "CurrentTheme3");
- if (!themeName)
- themeName = DEFAULT_THEME;
-
- if (strcmp(themeName, "Default") == 0)
- themeName = "System";
-
- if (strcmp(themeName, "System") != 0 && SetTheme(themeName))
- return true;
-
- return SetTheme("System");
-}
-
#if defined(_WIN32) || defined(ENABLE_SPARKLE_UPDATER)
void ParseBranchesJson(const std::string &jsonString, vector &out,
std::string &error)
diff --git a/UI/obs-app.hpp b/UI/obs-app.hpp
index 94c3f349b..ee5c24848 100644
--- a/UI/obs-app.hpp
+++ b/UI/obs-app.hpp
@@ -20,6 +20,8 @@
#include
#include
#include
+#include
+
#ifndef _WIN32
#include
#else
@@ -38,6 +40,7 @@
#include
#include "window-main.hpp"
+#include "obs-app-theming.hpp"
std::string CurrentTimeString();
std::string CurrentDateTimeString();
@@ -74,12 +77,6 @@ public:
typedef std::function VoidFunc;
-struct OBSThemeMeta {
- bool dark;
- std::string parent;
- std::string author;
-};
-
struct UpdateBranch {
QString name;
QString display_name;
@@ -93,9 +90,7 @@ class OBSApp : public QApplication {
private:
std::string locale;
- std::string theme;
- bool themeDarkMode = true;
ConfigFile globalConfig;
TextLookup textLookup;
QPointer mainWindow;
@@ -123,11 +118,11 @@ private:
inline void ResetHotkeyState(bool inFocus);
QPalette defaultPalette;
+ OBSTheme *currentTheme = nullptr;
+ QHash themes;
+ QPointer themeWatcher;
- void ParseExtraThemeData(const char *path);
- static OBSThemeMeta *ParseThemeMeta(const char *path);
- void AddExtraThemeColor(QPalette &pal, int group, const char *name,
- uint32_t color);
+ void FindThemes();
bool notify(QObject *receiver, QEvent *e) override;
@@ -139,6 +134,9 @@ private slots:
void commitData(QSessionManager &manager);
#endif
+private slots:
+ void themeFileChanged(const QString &);
+
public:
OBSApp(int &argc, char **argv, profiler_name_store_t *store);
~OBSApp();
@@ -160,11 +158,14 @@ public:
inline const char *GetLocale() const { return locale.c_str(); }
- inline const char *GetTheme() const { return theme.c_str(); }
- std::string GetTheme(std::string name, std::string path);
- std::string SetParentTheme(std::string name);
- bool SetTheme(std::string name, std::string path = "");
- inline bool IsThemeDark() const { return themeDarkMode; };
+ OBSTheme *GetTheme() const { return currentTheme; }
+ QList GetThemes() const { return themes.values(); }
+ OBSTheme *GetTheme(const QString &name);
+ bool SetTheme(const QString &name);
+ bool IsThemeDark() const
+ {
+ return currentTheme ? currentTheme->isDark : false;
+ }
void SetBranchData(const std::string &data);
std::vector GetBranches();
diff --git a/UI/ui-config.h.in b/UI/ui-config.h.in
index 3e40f75b4..25ec4699d 100644
--- a/UI/ui-config.h.in
+++ b/UI/ui-config.h.in
@@ -15,4 +15,4 @@
#define YOUTUBE_CLIENTID_HASH 0x@YOUTUBE_CLIENTID_HASH@
#define YOUTUBE_SECRET_HASH 0x@YOUTUBE_SECRET_HASH@
-#define DEFAULT_THEME "Yami"
+#define DEFAULT_THEME "com.obsproject.Yami.Original"
diff --git a/UI/window-basic-settings-appearance.cpp b/UI/window-basic-settings-appearance.cpp
new file mode 100644
index 000000000..58c8401b6
--- /dev/null
+++ b/UI/window-basic-settings-appearance.cpp
@@ -0,0 +1,127 @@
+#include "window-basic-settings.hpp"
+#include "window-basic-main.hpp"
+#include "obs-frontend-api.h"
+#include "qt-wrappers.hpp"
+#include "platform.hpp"
+#include "obs-app.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "util/profiler.hpp"
+
+using namespace std;
+
+void OBSBasicSettings::InitAppearancePage()
+{
+ savedTheme = App()->GetTheme();
+ const QString currentBaseTheme =
+ savedTheme->isBaseTheme ? savedTheme->id : savedTheme->parent;
+
+ for (const OBSTheme &theme : App()->GetThemes()) {
+ if (theme.isBaseTheme &&
+ (HighContrastEnabled() || theme.isVisible ||
+ theme.id == currentBaseTheme)) {
+ ui->theme->addItem(theme.name, theme.id);
+ }
+ }
+
+ int idx = ui->theme->findData(currentBaseTheme);
+ if (idx != -1)
+ ui->theme->setCurrentIndex(idx);
+
+ ui->themeVariant->setPlaceholderText(
+ QTStr("Basic.Settings.Appearance.General.NoVariant"));
+}
+
+void OBSBasicSettings::LoadThemeList(bool reload)
+{
+ ProfileScope("OBSBasicSettings::LoadThemeList");
+
+ const OBSTheme *currentTheme = App()->GetTheme();
+ const QString currentBaseTheme = currentTheme->isBaseTheme
+ ? currentTheme->id
+ : currentTheme->parent;
+
+ /* Nothing to do if current and last base theme were the same */
+ const QString baseThemeId = ui->theme->currentData().toString();
+ if (reload && baseThemeId == currentBaseTheme)
+ return;
+
+ ui->themeVariant->blockSignals(true);
+ ui->themeVariant->clear();
+
+ auto themes = App()->GetThemes();
+ std::sort(themes.begin(), themes.end(),
+ [](const OBSTheme &a, const OBSTheme &b) -> bool {
+ return QString::compare(a.name, b.name,
+ Qt::CaseInsensitive) < 0;
+ });
+
+ QString defaultVariant;
+ const OBSTheme *baseTheme = App()->GetTheme(baseThemeId);
+
+ for (const OBSTheme &theme : themes) {
+ /* Skip non-visible themes */
+ if (!theme.isVisible || theme.isHighContrast)
+ continue;
+ /* Skip non-child themes */
+ if (theme.isBaseTheme || theme.parent != baseThemeId)
+ continue;
+
+ ui->themeVariant->addItem(theme.name, theme.id);
+ if (baseTheme && theme.filename == baseTheme->filename)
+ defaultVariant = theme.id;
+ }
+
+ int idx = ui->themeVariant->findData(currentTheme->id);
+ if (idx != -1)
+ ui->themeVariant->setCurrentIndex(idx);
+
+ ui->themeVariant->setEnabled(ui->themeVariant->count() > 0);
+ ui->themeVariant->blockSignals(false);
+ /* If no variant is selected but variants are available set the first one. */
+ if (idx == -1 && ui->themeVariant->count() > 0) {
+ idx = ui->themeVariant->findData(defaultVariant);
+ ui->themeVariant->setCurrentIndex(idx != -1 ? idx : 0);
+ }
+}
+
+void OBSBasicSettings::LoadAppearanceSettings(bool reload)
+{
+ LoadThemeList(reload);
+
+ if (reload) {
+ QString themeId = ui->theme->currentData().toString();
+ if (ui->themeVariant->currentIndex() != -1)
+ themeId = ui->themeVariant->currentData().toString();
+
+ App()->SetTheme(themeId);
+ }
+}
+
+void OBSBasicSettings::SaveAppearanceSettings()
+{
+ config_t *config = GetGlobalConfig();
+
+ OBSTheme *currentTheme = App()->GetTheme();
+ if (savedTheme != currentTheme) {
+ config_set_string(config, "Appearance", "Theme",
+ QT_TO_UTF8(currentTheme->id));
+ }
+}
+
+void OBSBasicSettings::on_theme_activated(int)
+{
+ LoadAppearanceSettings(true);
+}
+
+void OBSBasicSettings::on_themeVariant_activated(int)
+{
+ LoadAppearanceSettings(true);
+}
diff --git a/UI/window-basic-settings.cpp b/UI/window-basic-settings.cpp
index 2a19e9131..6d1b4ae94 100644
--- a/UI/window-basic-settings.cpp
+++ b/UI/window-basic-settings.cpp
@@ -346,6 +346,7 @@ void RestrictResetBitrates(initializer_list boxes, int maxbitrate);
#define VIDEO_RES &OBSBasicSettings::VideoChangedResolution
#define VIDEO_CHANGED &OBSBasicSettings::VideoChanged
#define A11Y_CHANGED &OBSBasicSettings::A11yChanged
+#define APPEAR_CHANGED &OBSBasicSettings::AppearanceChanged
#define ADV_CHANGED &OBSBasicSettings::AdvancedChanged
#define ADV_RESTART &OBSBasicSettings::AdvancedChangedRestart
/* clang-format on */
@@ -369,7 +370,6 @@ OBSBasicSettings::OBSBasicSettings(QWidget *parent)
/* clang-format off */
HookWidget(ui->language, COMBO_CHANGED, GENERAL_CHANGED);
- HookWidget(ui->theme, COMBO_CHANGED, GENERAL_CHANGED);
HookWidget(ui->updateChannelBox, COMBO_CHANGED, GENERAL_CHANGED);
HookWidget(ui->enableAutoUpdates, CHECK_CHANGED, GENERAL_CHANGED);
HookWidget(ui->openStatsOnStartup, CHECK_CHANGED, GENERAL_CHANGED);
@@ -406,6 +406,8 @@ OBSBasicSettings::OBSBasicSettings(QWidget *parent)
HookWidget(ui->multiviewDrawNames, CHECK_CHANGED, GENERAL_CHANGED);
HookWidget(ui->multiviewDrawAreas, CHECK_CHANGED, GENERAL_CHANGED);
HookWidget(ui->multiviewLayout, COMBO_CHANGED, GENERAL_CHANGED);
+ HookWidget(ui->theme, COMBO_CHANGED, APPEAR_CHANGED);
+ HookWidget(ui->themeVariant, COMBO_CHANGED, APPEAR_CHANGED);
HookWidget(ui->service, COMBO_CHANGED, STREAM1_CHANGED);
HookWidget(ui->server, COMBO_CHANGED, STREAM1_CHANGED);
HookWidget(ui->customServer, EDIT_CHANGED, STREAM1_CHANGED);
@@ -880,6 +882,7 @@ OBSBasicSettings::OBSBasicSettings(QWidget *parent)
obs_properties_destroy(ppts);
InitStreamPage();
+ InitAppearancePage();
LoadSettings(false);
ui->advOutTrack1->setAccessibleName(
@@ -1266,51 +1269,6 @@ void OBSBasicSettings::LoadLanguageList()
ui->language->model()->sort(0);
}
-void OBSBasicSettings::LoadThemeList()
-{
- /* Save theme if user presses Cancel */
- savedTheme = string(App()->GetTheme());
-
- ui->theme->clear();
- QSet uniqueSet;
- string themeDir;
- char userThemeDir[512];
- int ret = GetConfigPath(userThemeDir, sizeof(userThemeDir),
- "obs-studio/themes/");
- GetDataFilePath("themes/", themeDir);
-
- /* Check user dir first. */
- if (ret > 0) {
- QDirIterator it(QString(userThemeDir), QStringList() << "*.qss",
- QDir::Files);
- while (it.hasNext()) {
- it.next();
- QString name = it.fileInfo().completeBaseName();
- ui->theme->addItem(name, name);
- uniqueSet.insert(name);
- }
- }
-
- /* Check shipped themes. */
- QDirIterator uIt(QString(themeDir.c_str()), QStringList() << "*.qss",
- QDir::Files);
- while (uIt.hasNext()) {
- uIt.next();
- QString name = uIt.fileInfo().completeBaseName();
- QString value = name;
-
- if (name == DEFAULT_THEME)
- name += " " + QTStr("Default");
-
- if (!uniqueSet.contains(value) && name != "Default")
- ui->theme->addItem(name, value);
- }
-
- int idx = ui->theme->findData(QT_UTF8(App()->GetTheme()));
- if (idx != -1)
- ui->theme->setCurrentIndex(idx);
-}
-
#if defined(_WIN32) || defined(ENABLE_SPARKLE_UPDATER)
void TranslateBranchInfo(const QString &name, QString &displayName,
QString &description)
@@ -1383,7 +1341,6 @@ void OBSBasicSettings::LoadGeneralSettings()
loading = true;
LoadLanguageList();
- LoadThemeList();
#if defined(_WIN32) || defined(ENABLE_SPARKLE_UPDATER)
bool enableAutoUpdates = config_get_bool(GetGlobalConfig(), "General",
@@ -3329,6 +3286,8 @@ void OBSBasicSettings::LoadSettings(bool changedOnly)
LoadVideoSettings();
if (!changedOnly || a11yChanged)
LoadA11ySettings();
+ if (!changedOnly || appearanceChanged)
+ LoadAppearanceSettings();
if (!changedOnly || advancedChanged)
LoadAdvancedSettings();
}
@@ -3343,15 +3302,6 @@ void OBSBasicSettings::SaveGeneralSettings()
config_set_string(GetGlobalConfig(), "General", "Language",
language.c_str());
- int themeIndex = ui->theme->currentIndex();
- QString themeData = ui->theme->itemData(themeIndex).toString();
-
- if (WidgetChanged(ui->theme)) {
- savedTheme = themeData.toStdString();
- config_set_string(GetGlobalConfig(), "General", "CurrentTheme3",
- QT_TO_UTF8(themeData));
- }
-
#if defined(_WIN32) || defined(ENABLE_SPARKLE_UPDATER)
if (WidgetChanged(ui->enableAutoUpdates))
config_set_bool(GetGlobalConfig(), "General",
@@ -4142,7 +4092,8 @@ void OBSBasicSettings::SaveSettings()
SaveA11ySettings();
if (advancedChanged)
SaveAdvancedSettings();
-
+ if (appearanceChanged)
+ SaveAppearanceSettings();
if (videoChanged || advancedChanged)
main->ResetVideo();
@@ -4166,6 +4117,8 @@ void OBSBasicSettings::SaveSettings()
AddChangedVal(changed, "hotkeys");
if (a11yChanged)
AddChangedVal(changed, "a11y");
+ if (appearanceChanged)
+ AddChangedVal(changed, "appearance");
if (advancedChanged)
AddChangedVal(changed, "advanced");
@@ -4204,7 +4157,7 @@ bool OBSBasicSettings::QueryChanges()
SaveSettings();
} else {
if (savedTheme != App()->GetTheme())
- App()->SetTheme(savedTheme);
+ App()->SetTheme(savedTheme->id);
LoadSettings(true);
restart = false;
@@ -4299,13 +4252,6 @@ void OBSBasicSettings::reject()
close();
}
-void OBSBasicSettings::on_theme_activated(int idx)
-{
- QString currT = ui->theme->itemData(idx).toString();
-
- App()->SetTheme(currT.toUtf8().constData());
-}
-
void OBSBasicSettings::on_listWidget_itemSelectionChanged()
{
int row = ui->listWidget->currentRow();
@@ -4370,7 +4316,7 @@ void OBSBasicSettings::on_buttonBox_clicked(QAbstractButton *button)
val == QDialogButtonBox::RejectRole) {
if (val == QDialogButtonBox::RejectRole) {
if (savedTheme != App()->GetTheme())
- App()->SetTheme(savedTheme);
+ App()->SetTheme(savedTheme->id);
}
ClearChanged();
close();
@@ -5015,6 +4961,15 @@ void OBSBasicSettings::A11yChanged()
}
}
+void OBSBasicSettings::AppearanceChanged()
+{
+ if (!loading) {
+ appearanceChanged = true;
+ sender()->setProperty("changed", QVariant(true));
+ EnableApplyButton(true);
+ }
+}
+
void OBSBasicSettings::AdvancedChanged()
{
if (!loading) {
diff --git a/UI/window-basic-settings.hpp b/UI/window-basic-settings.hpp
index 62f5800f0..c6f550c14 100644
--- a/UI/window-basic-settings.hpp
+++ b/UI/window-basic-settings.hpp
@@ -28,6 +28,7 @@
#include "auth-base.hpp"
#include "ffmpeg-utils.hpp"
+#include "obs-app-theming.hpp"
class OBSBasic;
class QAbstractButton;
@@ -35,6 +36,7 @@ class QRadioButton;
class QComboBox;
class QCheckBox;
class QLabel;
+class QButtonGroup;
class OBSPropertiesView;
class OBSHotkeyWidget;
@@ -116,12 +118,12 @@ private:
bool videoChanged = false;
bool hotkeysChanged = false;
bool a11yChanged = false;
+ bool appearanceChanged = false;
bool advancedChanged = false;
int pageIndex = 0;
bool loading = true;
bool forceAuthReload = false;
bool forceUpdateCheck = false;
- std::string savedTheme;
int sampleRateIndex = 0;
int channelIndex = 0;
bool llBufferingEnabled = false;
@@ -135,6 +137,8 @@ private:
static constexpr uint32_t ENCODER_HIDE_FLAGS =
(OBS_ENCODER_CAP_DEPRECATED | OBS_ENCODER_CAP_INTERNAL);
+ OBSTheme *savedTheme = nullptr;
+
std::vector formats;
OBSPropertiesView *streamProperties = nullptr;
@@ -200,9 +204,9 @@ private:
inline bool Changed() const
{
- return generalChanged || outputsChanged || stream1Changed ||
- audioChanged || videoChanged || advancedChanged ||
- hotkeysChanged || a11yChanged;
+ return generalChanged || appearanceChanged || outputsChanged ||
+ stream1Changed || audioChanged || videoChanged ||
+ advancedChanged || hotkeysChanged || a11yChanged;
}
inline void EnableApplyButton(bool en)
@@ -220,6 +224,7 @@ private:
hotkeysChanged = false;
a11yChanged = false;
advancedChanged = false;
+ appearanceChanged = false;
EnableApplyButton(false);
}
@@ -253,6 +258,7 @@ private:
void
LoadHotkeySettings(obs_hotkey_id ignoreKey = OBS_INVALID_HOTKEY_ID);
void LoadA11ySettings(bool presetChange = false);
+ void LoadAppearanceSettings(bool reload = false);
void LoadAdvancedSettings();
void LoadSettings(bool changedOnly);
@@ -262,7 +268,7 @@ private:
/* general */
void LoadLanguageList();
- void LoadThemeList();
+ void LoadThemeList(bool firstLoad);
void LoadBranchesList();
/* stream */
@@ -287,6 +293,9 @@ private:
void UpdateMoreInfoLink();
void UpdateAdvNetworkGroup();
+ /* Appearance */
+ void InitAppearancePage();
+
private slots:
void RecreateOutputResolutionWidget();
bool UpdateResFPSLimits();
@@ -351,6 +360,7 @@ private:
void SaveVideoSettings();
void SaveHotkeySettings();
void SaveA11ySettings();
+ void SaveAppearanceSettings();
void SaveAdvancedSettings();
void SaveSettings();
@@ -405,6 +415,7 @@ private:
private slots:
void on_theme_activated(int idx);
+ void on_themeVariant_activated(int idx);
void on_listWidget_itemSelectionChanged();
void on_buttonBox_clicked(QAbstractButton *button);
@@ -459,6 +470,7 @@ private slots:
bool ScanDuplicateHotkeys(QFormLayout *layout);
void ReloadHotkeys(obs_hotkey_id ignoreKey = OBS_INVALID_HOTKEY_ID);
void A11yChanged();
+ void AppearanceChanged();
void AdvancedChanged();
void AdvancedChangedRestart();