Pārlūkot izejas kodu

UI: Add composable themes feature

Co-authored-by: Warchamp7 <[email protected]>
derrod 2 gadi atpakaļ
vecāks
revīzija
503968671d

+ 2 - 0
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

+ 3 - 0
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

+ 1 - 0
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

+ 3 - 1
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"

+ 15 - 1
UI/forms/OBSBasicSettings.ui

@@ -948,7 +948,7 @@
                    <item row="0" column="0">
                     <widget class="QLabel" name="label_45">
                      <property name="text">
-                      <string>Basic.Settings.General.Theme</string>
+                      <string>Basic.Settings.Appearance.General.Theme</string>
                      </property>
                      <property name="buddy">
                       <cstring>theme</cstring>
@@ -958,6 +958,19 @@
                    <item row="0" column="1">
                     <widget class="QComboBox" name="theme"/>
                    </item>
+                   <item row="1" column="0">
+                    <widget class="QLabel" name="label_10">
+                     <property name="text">
+                      <string>Basic.Settings.Appearance.General.Variant</string>
+                     </property>
+                     <property name="buddy">
+                      <cstring>themeVariant</cstring>
+                     </property>
+                    </widget>
+                   </item>
+                   <item row="1" column="1">
+                    <widget class="QComboBox" name="themeVariant"/>
+                   </item>
                   </layout>
                  </widget>
                 </item>
@@ -7991,6 +8004,7 @@
   <tabstop>multiviewDrawAreas</tabstop>
   <tabstop>multiviewLayout</tabstop>
   <tabstop>theme</tabstop>
+  <tabstop>themeVariant</tabstop>
   <tabstop>service</tabstop>
   <tabstop>moreInfoButton</tabstop>
   <tabstop>connectAccount</tabstop>

+ 984 - 0
UI/obs-app-theming.cpp

@@ -0,0 +1,984 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    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;
+}

+ 66 - 0
UI/obs-app-theming.hpp

@@ -0,0 +1,66 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    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/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QVariant>
+
+#include <filesystem>
+
+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 */
+};

+ 0 - 377
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<void(QPalette::ColorGroup)> 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<char> 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<char> 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<OBSThemeMeta> 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<UpdateBranch> &out,
 		       std::string &error)

+ 18 - 17
UI/obs-app.hpp

@@ -20,6 +20,8 @@
 #include <QApplication>
 #include <QTranslator>
 #include <QPointer>
+#include <QFileSystemWatcher>
+
 #ifndef _WIN32
 #include <QSocketNotifier>
 #else
@@ -38,6 +40,7 @@
 #include <deque>
 
 #include "window-main.hpp"
+#include "obs-app-theming.hpp"
 
 std::string CurrentTimeString();
 std::string CurrentDateTimeString();
@@ -74,12 +77,6 @@ public:
 
 typedef std::function<void()> 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<OBSMainWindow> mainWindow;
@@ -123,11 +118,11 @@ private:
 	inline void ResetHotkeyState(bool inFocus);
 
 	QPalette defaultPalette;
+	OBSTheme *currentTheme = nullptr;
+	QHash<QString, OBSTheme> themes;
+	QPointer<QFileSystemWatcher> 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<OBSTheme> 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<UpdateBranch> GetBranches();

+ 1 - 1
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"

+ 127 - 0
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 <QColorDialog>
+#include <QDirIterator>
+#include <QFile>
+#include <QMetaEnum>
+#include <QObject>
+#include <QRandomGenerator>
+#include <QPainter>
+
+#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);
+}

+ 21 - 66
UI/window-basic-settings.cpp

@@ -346,6 +346,7 @@ void RestrictResetBitrates(initializer_list<QComboBox *> 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<QString> 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) {

+ 17 - 5
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<FFmpegFormat> 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();