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();