From 3ca2578ad6a2bd084a045139c7a5d03e29cf0e84 Mon Sep 17 00:00:00 2001 From: Warchamp7 Date: Mon, 11 May 2026 13:40:34 -0400 Subject: [PATCH 1/7] frontend: Add HealthCheck service --- frontend/cmake/ui-utility.cmake | 6 ++ frontend/data/locale/en-US.ini | 6 ++ frontend/utility/HealthCheckAction.cpp | 44 +++++++++ frontend/utility/HealthCheckAction.hpp | 51 ++++++++++ frontend/utility/HealthCheckItem.cpp | 70 ++++++++++++++ frontend/utility/HealthCheckItem.hpp | 84 ++++++++++++++++ frontend/utility/HealthCheckService.cpp | 121 ++++++++++++++++++++++++ frontend/utility/HealthCheckService.hpp | 63 ++++++++++++ 8 files changed, 445 insertions(+) create mode 100644 frontend/utility/HealthCheckAction.cpp create mode 100644 frontend/utility/HealthCheckAction.hpp create mode 100644 frontend/utility/HealthCheckItem.cpp create mode 100644 frontend/utility/HealthCheckItem.hpp create mode 100644 frontend/utility/HealthCheckService.cpp create mode 100644 frontend/utility/HealthCheckService.hpp diff --git a/frontend/cmake/ui-utility.cmake b/frontend/cmake/ui-utility.cmake index 387385da8d2f0d..3aa1151d857e5d 100644 --- a/frontend/cmake/ui-utility.cmake +++ b/frontend/cmake/ui-utility.cmake @@ -19,6 +19,12 @@ target_sources( utility/GoLiveAPI_Network.hpp utility/GoLiveAPI_PostData.cpp utility/GoLiveAPI_PostData.hpp + utility/HealthCheckAction.cpp + utility/HealthCheckAction.hpp + utility/HealthCheckItem.cpp + utility/HealthCheckItem.hpp + utility/HealthCheckService.cpp + utility/HealthCheckService.hpp utility/MissingFilesModel.cpp utility/MissingFilesModel.hpp utility/MissingFilesPathItemDelegate.cpp diff --git a/frontend/data/locale/en-US.ini b/frontend/data/locale/en-US.ini index a86d2a729a33c6..d8c109c6ef11d7 100644 --- a/frontend/data/locale/en-US.ini +++ b/frontend/data/locale/en-US.ini @@ -1681,3 +1681,9 @@ PluginManager.Section.Manage.Title="Manage Enabled Plugins" # Custom Widget Localization Accessible.Widget.Name.AlignmentSelector="Alignment Selector" + +# OBS Configuration Health Check +HealthCheck.Status.Invalid="Invalid" +HealthCheck.Status.Valid="Valid" +HealthCheck.Status.Warning="Warning" +HealthCheck.Status.Critical="Critical" diff --git a/frontend/utility/HealthCheckAction.cpp b/frontend/utility/HealthCheckAction.cpp new file mode 100644 index 00000000000000..fe04904ddba669 --- /dev/null +++ b/frontend/utility/HealthCheckAction.cpp @@ -0,0 +1,44 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 "HealthCheckAction.hpp" + +namespace OBS { +HealthCheckAction::HealthCheckAction(PassKey, QObject *parent) : QObject(parent) {} + +void HealthCheckAction::setText(QString text) +{ + text_ = text; +} + +const QString &HealthCheckAction::text() +{ + return text_; +} + +void HealthCheckAction::setCallback(std::function func) +{ + callback = func; +} + +void HealthCheckAction::trigger() +{ + if (callback) { + callback(); + } +} +} // namespace OBS diff --git a/frontend/utility/HealthCheckAction.hpp b/frontend/utility/HealthCheckAction.hpp new file mode 100644 index 00000000000000..bc3dac5ced3f56 --- /dev/null +++ b/frontend/utility/HealthCheckAction.hpp @@ -0,0 +1,51 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 + +namespace OBS { +class HealthCheckItem; +class HealthCheckAction : public QObject { + +public: + // Limits constructor to only HealthCheckItem. + class PassKey { + friend class HealthCheckItem; + PassKey() = default; + }; + + HealthCheckAction(PassKey, QObject *parent); + ~HealthCheckAction() = default; + + HealthCheckAction(const HealthCheckAction &) = delete; + HealthCheckAction &operator=(const HealthCheckAction &) = delete; + + void setText(QString text); + const QString &text(); + + void setCallback(std::function func); + +private: + QString text_{""}; + std::function callback = nullptr; + +public slots: + void trigger(); +}; +} // namespace OBS diff --git a/frontend/utility/HealthCheckItem.cpp b/frontend/utility/HealthCheckItem.cpp new file mode 100644 index 00000000000000..ca56f143ea3d2d --- /dev/null +++ b/frontend/utility/HealthCheckItem.cpp @@ -0,0 +1,70 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 "HealthCheckItem.hpp" + +#include + +namespace OBS { +HealthCheckItem::HealthCheckItem(PassKey, QObject *parent, QString id, QString title) + : QObject(parent), + id_(std::move(id)), + title_(std::move(title)) +{ +} + +void HealthCheckItem::setMessage(QString message) +{ + message_ = message; + + emit statusChanged(); +} + +void HealthCheckItem::setStatus(HealthStatus status) +{ + status_ = status; + + emit statusChanged(); +} + +void HealthCheckItem::setStatus(HealthStatus status, QString message) +{ + status_ = status; + message_ = message; + + emit statusChanged(); +} + +QString HealthCheckItem::statusText() const +{ + return HealthCheckItem::statusText(status()); +} + +QString HealthCheckItem::statusText(HealthStatus status) +{ + switch (status) { + case HealthStatus::Valid: + return QTStr("HealthCheck.Status.Valid"); + case HealthStatus::Warning: + return QTStr("HealthCheck.Status.Warning"); + case HealthStatus::Critical: + return QTStr("HealthCheck.Status.Critical"); + default: + return QTStr("HealthCheck.Status.Invalid"); + } +} +} // namespace OBS diff --git a/frontend/utility/HealthCheckItem.hpp b/frontend/utility/HealthCheckItem.hpp new file mode 100644 index 00000000000000..f749fb3d4054a0 --- /dev/null +++ b/frontend/utility/HealthCheckItem.hpp @@ -0,0 +1,84 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 +#include + +#include +#include + +namespace OBS { +enum class HealthStatus { Valid, Warning, Critical }; + +class HealthCheckService; +class HealthCheckAction; + +// Created via HealthCheckService::createItem(). +class HealthCheckItem : public QObject { + Q_OBJECT + +public: + // Limits constructor to only HealthCheckService. + class PassKey { + friend class HealthCheckService; + PassKey() = default; + }; + + HealthCheckItem(PassKey, QObject *parent, QString id, QString title); + ~HealthCheckItem() = default; + + HealthCheckItem(const HealthCheckItem &) = delete; + HealthCheckItem &operator=(const HealthCheckItem &) = delete; + + const QString &id() const { return id_; } + const QString &title() const { return title_; } + + void setMessage(QString message); + const QString &message() const { return message_; } + + void setStatus(HealthStatus status); + void setStatus(HealthStatus status, QString message); + const HealthStatus &status() const { return status_; } + QString statusText() const; + + static QString statusText(HealthStatus status); + + HealthCheckAction *action() { return action_; } + HealthCheckAction *createAction() + { + action_ = new HealthCheckAction(HealthCheckAction::PassKey{}, this); + return action_; + }; + +private: + QString id_{""}; + + QString title_{""}; + QString message_{""}; + + HealthStatus status_ = HealthStatus::Valid; + + QPointer action_; + +signals: + void statusChanged(); +}; +} // namespace OBS diff --git a/frontend/utility/HealthCheckService.cpp b/frontend/utility/HealthCheckService.cpp new file mode 100644 index 00000000000000..b37f58230a900a --- /dev/null +++ b/frontend/utility/HealthCheckService.cpp @@ -0,0 +1,121 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 "HealthCheckService.hpp" + +#include +#include + +namespace OBS { +HealthCheckService::HealthCheckService(QObject *parent) : QObject(parent) {} + +void HealthCheckService::registerItem(HealthCheckItem *item) +{ + if (!item) { + return; + } + + QString id = item->id(); + registry[id] = QPointer(item); + + connect(item, &QObject::destroyed, this, [this, id]() { unregisterItem(id); }); + connect(item, &HealthCheckItem::statusChanged, this, [this, item]() { + if (item->status() != getGlobalStatus()) { + refreshGlobalStatus(); + } + }); + + emit itemListChanged(); +} + +void HealthCheckService::unregisterItem(const QString &id) +{ + registry.erase(id); + refreshGlobalStatus(); + + emit itemListChanged(); +} + +std::vector> HealthCheckService::getInvalidItems() +{ + std::vector> activeIssues; + + for (const auto &[id, item] : registry) { + if (item.isNull()) { + continue; + } + + if (item->status() != HealthStatus::Valid) { + activeIssues.emplace_back(item); + } + } + + return activeIssues; +} + +HealthStatus HealthCheckService::getGlobalStatus() +{ + return globalStatus; +} + +void HealthCheckService::refreshGlobalStatus() +{ + totalInvalidCount = 0; + HealthStatus globalStatus = HealthStatus::Valid; + + auto it = registry.begin(); + while (it != registry.end()) { + if (it->second.isNull()) { + it = registry.erase(it); + continue; + } + + HealthStatus itemStatus = it->second->status(); + + if (itemStatus != HealthStatus::Valid) { + ++totalInvalidCount; + } + + switch (globalStatus) { + case HealthStatus::Warning: + if (itemStatus == HealthStatus::Critical) { + globalStatus = itemStatus; + } + break; + case HealthStatus::Valid: + globalStatus = itemStatus; + break; + case HealthStatus::Critical: + default: + break; + } + + ++it; + } + + this->globalStatus = globalStatus; + emit globalStatusChanged(globalStatus); +} + +HealthCheckItem *HealthCheckService::createItem(QObject *parent, QString id, QString title) +{ + auto *item = new HealthCheckItem(HealthCheckItem::PassKey{}, parent, id, title); + registerItem(item); + + return item; +} +} // namespace OBS diff --git a/frontend/utility/HealthCheckService.hpp b/frontend/utility/HealthCheckService.hpp new file mode 100644 index 00000000000000..aee7273ac78b64 --- /dev/null +++ b/frontend/utility/HealthCheckService.hpp @@ -0,0 +1,63 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 +#include + +#include +#include + +class HealthCheckDialog; + +namespace OBS { +class HealthCheckService : public QObject { + Q_OBJECT + +public: + HealthCheckService(QObject *parent); + ~HealthCheckService() = default; + + HealthCheckService(const HealthCheckService &) = delete; + HealthCheckService &operator=(const HealthCheckService &) = delete; + + void registerItem(HealthCheckItem *item); + void unregisterItem(const QString &id); + + std::vector> getInvalidItems(); + int getInvalidCount() { return totalInvalidCount; } + + HealthStatus getGlobalStatus(); + + void refreshGlobalStatus(); + + HealthCheckItem *createItem(QObject *parent, QString id, QString title); + +private: + int totalInvalidCount; + HealthStatus globalStatus{HealthStatus::Valid}; + + std::unordered_map> registry; + +signals: + void globalStatusChanged(HealthStatus status); + void itemListChanged(); +}; +} // namespace OBS From 8ab9706818eabc751edf73bb11b829218eecb8e9 Mon Sep 17 00:00:00 2001 From: Warchamp7 Date: Thu, 14 May 2026 16:11:09 -0400 Subject: [PATCH 2/7] frontend: Add colored notice widgets --- frontend/cmake/ui-components.cmake | 4 ++ frontend/components/NoticeButton.cpp | 68 ++++++++++++++++++++++++++++ frontend/components/NoticeButton.hpp | 39 ++++++++++++++++ frontend/components/NoticeLabel.cpp | 65 ++++++++++++++++++++++++++ frontend/components/NoticeLabel.hpp | 41 +++++++++++++++++ frontend/data/themes/Yami.obt | 68 ++++++++++++++++++++++++++++ 6 files changed, 285 insertions(+) create mode 100644 frontend/components/NoticeButton.cpp create mode 100644 frontend/components/NoticeButton.hpp create mode 100644 frontend/components/NoticeLabel.cpp create mode 100644 frontend/components/NoticeLabel.hpp diff --git a/frontend/cmake/ui-components.cmake b/frontend/cmake/ui-components.cmake index 34f4c0e7fe3a1d..42a811a5086e7f 100644 --- a/frontend/cmake/ui-components.cmake +++ b/frontend/cmake/ui-components.cmake @@ -55,6 +55,10 @@ target_sources( components/Multiview.cpp components/Multiview.hpp components/MuteCheckBox.hpp + components/NoticeButton.cpp + components/NoticeButton.hpp + components/NoticeLabel.cpp + components/NoticeLabel.hpp components/OBSAdvAudioCtrl.cpp components/OBSAdvAudioCtrl.hpp components/OBSPreviewScalingComboBox.cpp diff --git a/frontend/components/NoticeButton.cpp b/frontend/components/NoticeButton.cpp new file mode 100644 index 00000000000000..de046e2d7ebde5 --- /dev/null +++ b/frontend/components/NoticeButton.cpp @@ -0,0 +1,68 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 "NoticeButton.hpp" + +#include + +namespace OBS { +NoticeButton::NoticeButton(QWidget *parent) : QPushButton(parent), idian::Utils(this) +{ + setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); + idian::Utils::addClass("text-bold"); +} + +NoticeButton::NoticeButton(QWidget *parent, QString text, OBS::NoticeStyle style) : NoticeButton(parent) +{ + setText(text); + setStyle(style); +} + +void NoticeButton::setStyle(NoticeStyle style) +{ + applyStyle(currentStyle, false); + applyStyle(style, true); + + currentStyle = style; +} + +void NoticeButton::applyStyle(NoticeStyle style, bool enable) +{ + switch (style) { + case NoticeStyle::Primary: + idian::Utils::toggleClass("primary", enable); + break; + case NoticeStyle::Secondary: + idian::Utils::toggleClass("secondary", enable); + break; + case NoticeStyle::Info: + idian::Utils::toggleClass("info", enable); + break; + case NoticeStyle::Success: + idian::Utils::toggleClass("success", enable); + break; + case NoticeStyle::Warning: + idian::Utils::toggleClass("warning", enable); + break; + case NoticeStyle::Danger: + idian::Utils::toggleClass("danger", enable); + break; + default: + break; + } +} +} // namespace OBS diff --git a/frontend/components/NoticeButton.hpp b/frontend/components/NoticeButton.hpp new file mode 100644 index 00000000000000..cc01425191c8bf --- /dev/null +++ b/frontend/components/NoticeButton.hpp @@ -0,0 +1,39 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 + +namespace OBS { +class NoticeButton : public QPushButton, public idian::Utils { + Q_OBJECT + +public: + NoticeButton(QWidget *parent); + NoticeButton(QWidget *parent, QString text, NoticeStyle style); + ~NoticeButton() = default; + + void setStyle(NoticeStyle style); + void applyStyle(NoticeStyle style, bool enable); + +protected: + NoticeStyle currentStyle = NoticeStyle::None; +}; +} // namespace OBS diff --git a/frontend/components/NoticeLabel.cpp b/frontend/components/NoticeLabel.cpp new file mode 100644 index 00000000000000..0cc33d040de1e1 --- /dev/null +++ b/frontend/components/NoticeLabel.cpp @@ -0,0 +1,65 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 "NoticeLabel.hpp" + +namespace OBS { +NoticeLabel::NoticeLabel(QWidget *parent) : QLabel(parent), idian::Utils(this) +{ + setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); + idian::Utils::addClass("text-bold"); +} + +NoticeLabel::NoticeLabel(QWidget *parent, NoticeStyle style) : NoticeLabel(parent) +{ + setStyle(style); +} + +void NoticeLabel::setStyle(NoticeStyle style) +{ + applyStyle(currentStyle, false); + applyStyle(style, true); + + currentStyle = style; +} + +void NoticeLabel::applyStyle(NoticeStyle style, bool enable) +{ + switch (style) { + case NoticeStyle::Primary: + idian::Utils::toggleClass("primary", enable); + break; + case NoticeStyle::Secondary: + idian::Utils::toggleClass("secondary", enable); + break; + case NoticeStyle::Info: + idian::Utils::toggleClass("info", enable); + break; + case NoticeStyle::Success: + idian::Utils::toggleClass("success", enable); + break; + case NoticeStyle::Warning: + idian::Utils::toggleClass("warning", enable); + break; + case NoticeStyle::Danger: + idian::Utils::toggleClass("danger", enable); + break; + default: + break; + } +} +} // namespace OBS diff --git a/frontend/components/NoticeLabel.hpp b/frontend/components/NoticeLabel.hpp new file mode 100644 index 00000000000000..098a3ca864fc71 --- /dev/null +++ b/frontend/components/NoticeLabel.hpp @@ -0,0 +1,41 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 + +namespace OBS { +enum class NoticeStyle { None, Primary, Secondary, Info, Success, Warning, Danger }; + +class NoticeLabel : public QLabel, public idian::Utils { + Q_OBJECT + +public: + NoticeLabel(QWidget *parent); + NoticeLabel(QWidget *parent, NoticeStyle style); + ~NoticeLabel() = default; + + void setStyle(NoticeStyle style); + void applyStyle(NoticeStyle style, bool enable); + +protected: + NoticeStyle currentStyle = NoticeStyle::None; +}; +} // namespace OBS diff --git a/frontend/data/themes/Yami.obt b/frontend/data/themes/Yami.obt index 739a0329e0084d..a4bca66eda6348 100644 --- a/frontend/data/themes/Yami.obt +++ b/frontend/data/themes/Yami.obt @@ -333,6 +333,74 @@ color: var(--green3); } +/* Color styles */ +QWidget.primary { + background: var(--blue5); + border: 1px solid var(--blue3); + border-radius: var(--border_radius); +} + +QWidget.secondary { + background: var(--teal5); + border: 1px solid var(--teal3); + border-radius: var(--border_radius); +} + +QWidget.info { + background: var(--grey8); + border: 1px solid var(--grey6); + border-radius: var(--border_radius); +} + +QWidget.success { + background: var(--green5); + border: 1px solid var(--green3); + border-radius: var(--border_radius); +} + +QWidget.warning { + background: var(--yellow5); + border: 1px solid var(--yellow3); + border-radius: var(--border_radius); +} + +QWidget.danger { + background: var(--red5); + border: 1px solid var(--red3); + border-radius: var(--border_radius); +} + +QPushButton.primary:hover { + background-color: var(--blue4); + border-color: var(--blue2); +} + +QPushButton.secondary:hover { + background-color: var(--teal4); + border-color: var(--teal2); +} + +QPushButton.info:hover { + background-color: var(--grey7); + border-color: var(--grey4); +} + +QPushButton.success:hover { + background-color: var(--green4); + border-color: var(--green2); +} + +QPushButton.warning:hover { + background-color: var(--yellow4); + border-color: var(--yellow2); +} + +QPushButton.danger:hover { + background-color: var(--red4); + border-color: var(--red2); +} + + .frame-notice { background: var(--grey8); border: 1px solid var(--grey6); From 3f8300060874bb10672cbad032d208b0e925584d Mon Sep 17 00:00:00 2001 From: Warchamp7 Date: Thu, 14 May 2026 00:00:21 -0400 Subject: [PATCH 3/7] frontend: Refactor Idian widgets The idian widgets were built when we had a much lower understanding of how Qt expects you to build custom complex widgets and without a direct use-case. They were also built far too rigid for what every attempted use of them thus far has necessitated. These changes break them into simpler 'pieces' and provide getters for internals that are intended to be accessed. --- frontend/data/themes/Yami.obt | 86 +++--- frontend/dialogs/OBSIdianPlayground.cpp | 102 ++++--- frontend/widgets/OBSBasic.hpp | 4 + frontend/widgets/OBSBasic_MainControls.cpp | 9 +- shared/qt/idian/CMakeLists.txt | 14 +- shared/qt/idian/components/ExpandButton.cpp | 52 ++++ shared/qt/idian/components/RowInfo.cpp | 93 ++++++ shared/qt/idian/components/ToggleSwitch.cpp | 2 + .../idian/include/Idian/CollapsibleGroup.hpp | 67 ++++ .../qt/idian/include/Idian/ExpandButton.hpp | 44 +++ shared/qt/idian/include/Idian/Idian.hpp | 3 +- .../Idian/{Group.hpp => ListHeader.hpp} | 30 +- shared/qt/idian/include/Idian/Row.hpp | 145 ++------- shared/qt/idian/include/Idian/RowInfo.hpp | 54 ++++ .../Idian/{PropertiesList.hpp => RowList.hpp} | 22 +- shared/qt/idian/include/Idian/Utils.cpp | 3 + shared/qt/idian/widgets/CollapsibleGroup.cpp | 107 +++++++ shared/qt/idian/widgets/Group.cpp | 126 -------- shared/qt/idian/widgets/ListHeader.cpp | 101 ++++++ shared/qt/idian/widgets/Row.cpp | 289 ++---------------- .../{PropertiesList.cpp => RowList.cpp} | 55 ++-- 21 files changed, 733 insertions(+), 675 deletions(-) create mode 100644 shared/qt/idian/components/ExpandButton.cpp create mode 100644 shared/qt/idian/components/RowInfo.cpp create mode 100644 shared/qt/idian/include/Idian/CollapsibleGroup.hpp create mode 100644 shared/qt/idian/include/Idian/ExpandButton.hpp rename shared/qt/idian/include/Idian/{Group.hpp => ListHeader.hpp} (69%) create mode 100644 shared/qt/idian/include/Idian/RowInfo.hpp rename shared/qt/idian/include/Idian/{PropertiesList.hpp => RowList.hpp} (75%) create mode 100644 shared/qt/idian/widgets/CollapsibleGroup.cpp delete mode 100644 shared/qt/idian/widgets/Group.cpp create mode 100644 shared/qt/idian/widgets/ListHeader.cpp rename shared/qt/idian/widgets/{PropertiesList.cpp => RowList.cpp} (68%) diff --git a/frontend/data/themes/Yami.obt b/frontend/data/themes/Yami.obt index a4bca66eda6348..b3558ffcb42d7f 100644 --- a/frontend/data/themes/Yami.obt +++ b/frontend/data/themes/Yami.obt @@ -221,7 +221,6 @@ --action_row_border: 3px; --action_row_input_width: calc(var(--action_row_base) * 4); --action_row_collapse: calc(var(--action_row_base) + var(--padding_large)); - --action_row_collapse_radius: calc(var(--action_row_collapse) / 2); --action_row_padding: calc(var(--padding_large) * 1.5); --action_row_padding_x: calc(var(--action_row_padding) * 2); --action_row_padding_nested: calc(var(--action_row_padding_x) * 1.5); @@ -2543,25 +2542,25 @@ SourceSelectButton:pressed:hover { } /* Idian Widgets */ -idian--Group { +idian--ListHeader { border-radius: var(--border_radius); font-weight: bold; margin: 0 0 var(--spacing_base); + padding: 0 var(--padding_base); min-width: 300px; - max-width: 600px; } -idian--Group .header .title { +idian--ListHeader .title { font-weight: bold; padding: var(--padding_large) 0; } -idian--Group .header .description { +idian--ListHeader .description { color: var(--text_muted); padding: var(--spacing_small) 0; } -idian--PropertiesList { +idian--RowList { border-width: 0; padding: 0; margin: var(--spacing_base) 0; @@ -2571,11 +2570,12 @@ idian--Row { background: var(--grey5); margin: 0; padding: var(--action_row_padding) var(--action_row_padding_x); + border-radius: var(--border_radius); } idian--Row.keyFocus { background: var(--grey4); - border: var(--highlight_width) solid var(--grey4); + border: var(--highlight_width) solid var(--highlight_color); } idian--Row.cursor-pointer.hover { @@ -2583,17 +2583,21 @@ idian--Row.cursor-pointer.hover { border: var(--highlight_width) solid var(--grey1); } -idian--Row.first { +idian--RowList idian--Row { + border-radius: 0; +} + +idian--RowList idian--Row.first { border-top-left-radius: var(--border_radius); border-top-right-radius: var(--border_radius); } -idian--Row.last { +idian--RowList idian--Row.last { border-bottom-left-radius: var(--border_radius); border-bottom-right-radius: var(--border_radius); } -idian--Row > QLabel.description { +idian--RowInfo QLabel.description { font-size: var(--font_small); color: var(--text_muted); } @@ -2690,7 +2694,7 @@ idian--Row QDoubleSpinBox { padding: var(--padding_base) var(--action_row_padding_x); } -idian--PropertiesListSpacer { +idian--RowListSpacer { max-height: var(--spacing_small); min-height: var(--spacing_small); background-color: var(--bg_window); @@ -2727,35 +2731,54 @@ idian--CheckBox.keyFocus.checked::indicator { border-color: var(--highlight_color); } -idian--CollapsibleRow { +idian--CollapsibleGroup { margin: 0; padding: 0; border: none; } -idian--CollapsibleRow.keyFocus { +idian--CollapsibleGroup.expanded > idian--Row { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +idian--CollapsibleGroup idian--Row.keyFocus { border: var(--highlight_width) solid var(--highlight_color); } -idian--CollapsibleRow idian--PropertiesList { +idian--CollapsibleGroup idian--RowList { border-radius: 0; border-left: 1px solid var(--grey5); border-right: 1px solid var(--grey5); border-bottom: 1px solid var(--grey5); + border-bottom: 1px solid var(--grey5); margin: var(--spacing_small) 0px 0px; } -idian--CollapsibleRow idian--PropertiesList idian--Row { +idian--CollapsibleGroup idian--RowList idian--Row { background-color: var(--grey6); padding-left: var(--action_row_padding_nested); } -idian--CollapsibleRow idian--Row.first, -idian--CollapsibleRow idian--Row.last { +idian--CollapsibleGroup idian--RowList idian--Row.first { + border-radius: 0; +} + +idian--CollapsibleGroup idian--RowList idian--Row.last { + border-bottom-left-radius: var(--border_radius); + border-bottom-right-radius: var(--border_radius); +} + +idian--RowList idian--CollapsibleGroup idian--Row.last { border-radius: 0; } -idian--CollapsibleRow idian--PropertiesList idian--ToggleSwitch { +idian--RowList idian--CollapsibleGroup.last idian--Row.last { + border-bottom-left-radius: var(--border_radius); + border-bottom-right-radius: var(--border_radius); +} + +idian--CollapsibleGroup idian--RowList idian--ToggleSwitch { qproperty-background: var(--grey7); qproperty-background_hover: var(--grey6); } @@ -2771,7 +2794,7 @@ idian--ExpandButton { idian--ExpandButton::indicator { background: var(--grey5); - border-radius: var(--action_row_collapse_radius); + border-radius: var(--border_radius); padding: var(--padding_large); image: url(theme:Dark/down.svg); border: var(--highlight_width) solid var(--grey5); @@ -2782,32 +2805,13 @@ idian--ExpandButton::indicator:checked { } idian--ExpandButton.keyFocus, -idian--ExpandButton.keyFocus::indicator { +idian--Row.keyFocus idian--ExpandButton::indicator { border-color: var(--highlight_color); } -idian--RowFrame .btn-frame { - background: var(--grey5); - padding: var(--action_row_padding) var(--action_row_padding_x); -} - -idian--RowFrame.hover .btn-frame { - background: var(--grey4); -} - -idian--RowFrame.hover idian--Row { +idian--ExpandButton.hover::indicator, +idian--Row.hover idian--ExpandButton.row-buddy::indicator { background: var(--grey4); - border: var(--highlight_width) solid var(--grey1); - border-right: none; -} - -idian--RowFrame.hover .row-buddy { - background: var(--grey4); - border: var(--highlight_width) solid var(--grey1); - border-left: none; -} - -idian--RowFrame.hover idian--ExpandButton::indicator { border-color: var(--grey1); } diff --git a/frontend/dialogs/OBSIdianPlayground.cpp b/frontend/dialogs/OBSIdianPlayground.cpp index c28db477443e16..da45f0aab8fdb5 100644 --- a/frontend/dialogs/OBSIdianPlayground.cpp +++ b/frontend/dialogs/OBSIdianPlayground.cpp @@ -17,7 +17,10 @@ #include "OBSIdianPlayground.hpp" +#include #include +#include +#include #include @@ -31,7 +34,9 @@ OBSIdianPlayground::OBSIdianPlayground(QWidget *parent) : QDialog(parent), ui(ne setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Minimum); - Group *test; + QLayout *contents = ui->scrollAreaWidgetContents->layout(); + + RowList *rowList; Row *tmp; ComboBox *cbox = new ComboBox; @@ -39,31 +44,29 @@ OBSIdianPlayground::OBSIdianPlayground(QWidget *parent) : QDialog(parent), ui(ne cbox->addItem("Test 2"); // Group box 1 - test = new Group(this); + rowList = new RowList(this); tmp = new Row(); - tmp->setTitle("Row with a dropdown"); - tmp->setSuffix(cbox); - test->properties()->addRow(tmp); + tmp->addWidget(new RowInfo(tmp, "Row with a dropdown")); + tmp->addBuddy(cbox); + rowList->addRow(tmp); cbox = new ComboBox; cbox->addItem("Test 3"); cbox->addItem("Test 4"); tmp = new Row(); - tmp->setTitle("Row with a dropdown"); - tmp->setDescription("And a subtitle!"); - tmp->setSuffix(cbox); - test->properties()->addRow(tmp); + tmp->addWidget(new RowInfo(tmp, "Row with a dropdown", "And a subtitle!")); + tmp->addBuddy(cbox); + rowList->addRow(tmp); tmp = new Row(); - tmp->setTitle("Toggle Switch"); - tmp->setSuffix(new ToggleSwitch()); - test->properties()->addRow(tmp); - ui->scrollAreaWidgetContents->layout()->addWidget(test); + tmp->addWidget(new RowInfo(tmp, "Toggle Switch")); + tmp->addBuddy(new ToggleSwitch()); + rowList->addRow(tmp); + contents->addWidget(rowList); tmp = new Row(); - tmp->setTitle("Delayed toggle switch"); - tmp->setDescription("The state can be set separately"); + tmp->addWidget(new RowInfo(tmp, "Delayed toggle switch", "The state can be set separately")); auto tswitch = new ToggleSwitch; tswitch->setDelayed(true); connect(tswitch, &ToggleSwitch::pendingChecked, this, [=]() { @@ -74,64 +77,65 @@ OBSIdianPlayground::OBSIdianPlayground(QWidget *parent) : QDialog(parent), ui(ne // Do async disable stuff, then set toggle status when complete QTimer::singleShot(1000, [=]() { tswitch->setStatus(false); }); }); - tmp->setSuffix(tswitch); - test->properties()->addRow(tmp); + tmp->addBuddy(tswitch); + rowList->addRow(tmp); // Group box 2 - test = new Group(); - test->setTitle("Just a few checkboxes"); + contents->addWidget(new ListHeader(rowList, "Just a few checkboxes")); + rowList = new RowList(); tmp = new Row(); - tmp->setTitle("Box 1"); - tmp->setPrefix(new CheckBox); - test->properties()->addRow(tmp); + tmp->addBuddy(new CheckBox); + tmp->addWidget(new RowInfo(tmp, "Box 1")); + rowList->addRow(tmp); tmp = new Row(); - tmp->setTitle("Box 2"); - tmp->setPrefix(new CheckBox); - test->properties()->addRow(tmp); + tmp->addBuddy(new CheckBox); + tmp->addWidget(new RowInfo(tmp, "Box 2")); + rowList->addRow(tmp); - ui->scrollAreaWidgetContents->layout()->addWidget(test); + ui->scrollAreaWidgetContents->layout()->addWidget(rowList); // Group box 2 - test = new Group(); - test->setTitle("Another Group"); - test->setDescription("With a subtitle"); + rowList = new RowList(); + contents->addWidget(new ListHeader(rowList, "Another Group", "With a subtitle")); tmp = new Row(); - tmp->setTitle("Placeholder"); - tmp->setSuffix(new ToggleSwitch); - test->properties()->addRow(tmp); + tmp->addWidget(new RowInfo(tmp, "Placeholder")); + tmp->addBuddy(new ToggleSwitch); + rowList->addRow(tmp); - CollapsibleRow *tmp2 = new CollapsibleRow(this); - tmp2->setTitle("A Collapsible row!"); + CollapsibleGroup *tmp2 = new CollapsibleGroup(this); + tmp2->row()->layout()->insertWidget(0, new RowInfo(tmp, "A Collapsible row!")); tmp2->setCheckable(true); - test->addRow(tmp2); + rowList->addRow(tmp2); tmp = new Row(); - tmp->setTitle("Spin box demo"); - tmp->setSuffix(new DoubleSpinBox()); - tmp2->addRow(tmp); + tmp->addWidget(new RowInfo(tmp, "Spin box demo")); + tmp->addBuddy(new DoubleSpinBox()); + tmp2->list()->addRow(tmp); tmp = new Row(); - tmp->setTitle("Just another placeholder"); - tmp->setSuffix(new ToggleSwitch(true)); - tmp2->addRow(tmp); + tmp->addWidget(new RowInfo(tmp, "Just another placeholder")); + tmp->addBuddy(new ToggleSwitch(true)); + tmp2->list()->addRow(tmp); tmp = new Row(); - tmp->setTitle("Placeholder 2"); - tmp->setSuffix(new ToggleSwitch); - test->properties()->addRow(tmp); + tmp->addWidget(new RowInfo(tmp, "Placeholder 2")); + tmp->addBuddy(new ToggleSwitch); + rowList->addRow(tmp); ui->scrollAreaWidgetContents->setContentsMargins(0, 0, 0, 0); ui->scrollAreaWidgetContents->layout()->setContentsMargins(0, 0, 0, 0); - ui->scrollAreaWidgetContents->layout()->addWidget(test); + ui->scrollAreaWidgetContents->layout()->addWidget(rowList); ui->scrollAreaWidgetContents->layout()->setAlignment(Qt::AlignTop | Qt::AlignHCenter); // Test Checkable Group - Group *test2 = new Group(); - test2->setTitle("Checkable Group"); - test2->setDescription("Description goes here"); - test2->setCheckable(true); + auto *test2 = new RowList(this); + + auto *header = new ListHeader(this, "Checkable Group", "Description goes here"); + header->setCheckable(true); + test2->addHeader(header); + ui->scrollAreaWidgetContents->layout()->addWidget(test2); } diff --git a/frontend/widgets/OBSBasic.hpp b/frontend/widgets/OBSBasic.hpp index efb90ac332b456..f96b400e05be1b 100644 --- a/frontend/widgets/OBSBasic.hpp +++ b/frontend/widgets/OBSBasic.hpp @@ -588,6 +588,10 @@ private slots: QPointer colorWidgetAction; QPointer colorSelect; +#ifdef ENABLE_IDIAN_PLAYGROUND + QPointer playground; +#endif + QList visDialogs; QList modalDialogs; QList visMsgBoxes; diff --git a/frontend/widgets/OBSBasic_MainControls.cpp b/frontend/widgets/OBSBasic_MainControls.cpp index 65a7f1a88ad959..01d40e0722c76d 100644 --- a/frontend/widgets/OBSBasic_MainControls.cpp +++ b/frontend/widgets/OBSBasic_MainControls.cpp @@ -644,10 +644,11 @@ void OBSBasic::on_stats_triggered() void OBSBasic::on_idianPlayground_triggered() { #ifdef ENABLE_IDIAN_PLAYGROUND - OBSIdianPlayground playground(this); - playground.setModal(true); - playground.show(); - playground.exec(); + if (!playground) { + playground = new OBSIdianPlayground(this); + playground->setAttribute(Qt::WA_DeleteOnClose); + playground->show(); + } #endif } diff --git a/shared/qt/idian/CMakeLists.txt b/shared/qt/idian/CMakeLists.txt index 7ed39f3526f309..88e3d7c445d746 100644 --- a/shared/qt/idian/CMakeLists.txt +++ b/shared/qt/idian/CMakeLists.txt @@ -11,24 +11,30 @@ target_sources( components/CheckBox.cpp components/ComboBox.cpp components/DoubleSpinBox.cpp + components/ExpandButton.cpp + components/RowInfo.cpp components/SpinBox.cpp components/ToggleSwitch.cpp include/Idian/CheckBox.hpp + include/Idian/CollapsibleGroup.hpp include/Idian/ComboBox.hpp include/Idian/DoubleSpinBox.hpp - include/Idian/Group.hpp + include/Idian/ExpandButton.hpp include/Idian/Idian.hpp - include/Idian/PropertiesList.hpp + include/Idian/ListHeader.hpp include/Idian/Row.hpp + include/Idian/RowInfo.hpp + include/Idian/RowList.hpp include/Idian/SpinBox.hpp include/Idian/StateEventFilter.cpp include/Idian/StateEventFilter.hpp include/Idian/ToggleSwitch.hpp include/Idian/Utils.cpp include/Idian/Utils.hpp - widgets/Group.cpp - widgets/PropertiesList.cpp + widgets/CollapsibleGroup.cpp + widgets/ListHeader.cpp widgets/Row.cpp + widgets/RowList.cpp ) target_sources(idian PUBLIC include/Idian/Idian.hpp) diff --git a/shared/qt/idian/components/ExpandButton.cpp b/shared/qt/idian/components/ExpandButton.cpp new file mode 100644 index 00000000000000..29bba1ea16d63b --- /dev/null +++ b/shared/qt/idian/components/ExpandButton.cpp @@ -0,0 +1,52 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 + +namespace idian { +ExpandButton::ExpandButton(QWidget *parent) : QAbstractButton(parent) +{ + widgetUtils = new Utils(this); + widgetUtils->applyStateStylingEventFilter(this); + + setCheckable(true); +} + +void ExpandButton::paintEvent(QPaintEvent *) +{ + QStyleOptionButton opt; + opt.initFrom(this); + QPainter p(this); + + bool checked = isChecked(); + + opt.state.setFlag(QStyle::State_On, checked); + opt.state.setFlag(QStyle::State_Off, !checked); + + opt.state.setFlag(QStyle::State_Sunken, checked); + + p.setRenderHint(QPainter::Antialiasing, true); + p.setRenderHint(QPainter::SmoothPixmapTransform, true); + + style()->drawPrimitive(QStyle::PE_PanelButtonCommand, &opt, &p, this); + style()->drawPrimitive(QStyle::PE_IndicatorCheckBox, &opt, &p, this); +} +} // namespace idian diff --git a/shared/qt/idian/components/RowInfo.cpp b/shared/qt/idian/components/RowInfo.cpp new file mode 100644 index 00000000000000..7991012747a040 --- /dev/null +++ b/shared/qt/idian/components/RowInfo.cpp @@ -0,0 +1,93 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 + +namespace idian { +RowInfo::RowInfo(QWidget *parent) : QWidget(parent) +{ + widgetUtils = new idian::Utils(this); + + layout_ = new QVBoxLayout(); + layout_->setSpacing(0); + layout_->setContentsMargins(0, 0, 0, 0); + + setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + + setLayout(layout_); + + nameLabel = new QLabel(); + nameLabel->setVisible(false); + widgetUtils->addClass(nameLabel, "title"); + + descriptionLabel = new QLabel(); + descriptionLabel->setVisible(false); + widgetUtils->addClass(descriptionLabel, "description"); + + layout_->addWidget(nameLabel); + layout_->addWidget(descriptionLabel); +} + +RowInfo::RowInfo(QWidget *parent, QString title) : RowInfo(parent) +{ + setTitle(title); +} + +RowInfo::RowInfo(QWidget *parent, QString title, QString description) : RowInfo(parent) +{ + setTitle(title); + setDescription(description); +} + +void RowInfo::setTitle(const QString &name) +{ + if (name.isEmpty()) { + showTitle(false); + return; + } + + nameLabel->setText(name); + setAccessibleName(name); + showTitle(true); +} + +void RowInfo::setDescription(const QString &description) +{ + if (description.isEmpty()) { + showDescription(false); + return; + } + + descriptionLabel->setText(description); + setAccessibleDescription(description); + showDescription(true); +} + +void RowInfo::showTitle(bool visible) +{ + nameLabel->setVisible(visible); +} + +void RowInfo::showDescription(bool visible) +{ + descriptionLabel->setVisible(visible); +} +} // namespace idian diff --git a/shared/qt/idian/components/ToggleSwitch.cpp b/shared/qt/idian/components/ToggleSwitch.cpp index 29d478a49944a9..2117733da0c7b3 100644 --- a/shared/qt/idian/components/ToggleSwitch.cpp +++ b/shared/qt/idian/components/ToggleSwitch.cpp @@ -50,6 +50,8 @@ ToggleSwitch::ToggleSwitch(QWidget *parent) installEventFilter(this); + setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); + connect(this, &ToggleSwitch::clicked, this, &ToggleSwitch::onClicked); connect(animHandle, &QVariantAnimation::valueChanged, this, &ToggleSwitch::updateBackgroundColor); diff --git a/shared/qt/idian/include/Idian/CollapsibleGroup.hpp b/shared/qt/idian/include/Idian/CollapsibleGroup.hpp new file mode 100644 index 00000000000000..d0fc92050be904 --- /dev/null +++ b/shared/qt/idian/include/Idian/CollapsibleGroup.hpp @@ -0,0 +1,67 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 + +class QPixmap; +class QVBoxLayout; + +namespace idian { +class Row; +class RowInfo; +class ExpandButton; +class RowList; +class ToggleSwitch; + +class CollapsibleGroup : public QFrame, public Utils { + Q_OBJECT + +public: + CollapsibleGroup(QWidget *parent = nullptr); + + void setCheckable(bool check); + bool isCheckable() { return checkable; } + + void setChecked(bool checked); + bool isChecked(); + + void setExpanded(bool expand = true); + + Row *row() { return rowWidget; } + RowList *list() { return propertyList; } + +protected: + void toggleVisibility(); + + QVBoxLayout *mainLayout; + + Row *rowWidget; + ExpandButton *expandButton; + + RowList *propertyList; + + ToggleSwitch *toggleSwitch = nullptr; + bool checkable = false; + +signals: + void toggled(bool checked); +}; +} // namespace idian diff --git a/shared/qt/idian/include/Idian/ExpandButton.hpp b/shared/qt/idian/include/Idian/ExpandButton.hpp new file mode 100644 index 00000000000000..e9368efd99c5c3 --- /dev/null +++ b/shared/qt/idian/include/Idian/ExpandButton.hpp @@ -0,0 +1,44 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 + +class QPixmap; + +namespace idian { +class Utils; + +class ExpandButton : public QAbstractButton { + Q_OBJECT + +public: + explicit ExpandButton(QWidget *parent = nullptr); + +protected: + void paintEvent(QPaintEvent *) override; + +private: + Utils *widgetUtils; + + QPixmap extendDown; + QPixmap extendUp; + + friend class CollapsibleGroup; +}; +} // namespace idian diff --git a/shared/qt/idian/include/Idian/Idian.hpp b/shared/qt/idian/include/Idian/Idian.hpp index c69add2d88daa7..aa8dc3fb6a46fc 100644 --- a/shared/qt/idian/include/Idian/Idian.hpp +++ b/shared/qt/idian/include/Idian/Idian.hpp @@ -24,8 +24,7 @@ #include #include #include -#include -#include +#include #include #include #include diff --git a/shared/qt/idian/include/Idian/Group.hpp b/shared/qt/idian/include/Idian/ListHeader.hpp similarity index 69% rename from shared/qt/idian/include/Idian/Group.hpp rename to shared/qt/idian/include/Idian/ListHeader.hpp index 030361995f07c0..9c90d03d6e5f47 100644 --- a/shared/qt/idian/include/Idian/Group.hpp +++ b/shared/qt/idian/include/Idian/ListHeader.hpp @@ -17,8 +17,7 @@ #pragma once -#include -#include +#include #include #include @@ -28,15 +27,13 @@ namespace idian { -class Group : public QFrame, public Utils { +class ListHeader : public QFrame, public Utils { Q_OBJECT public: - Group(QWidget *parent = nullptr); - - PropertiesList *properties() const { return propertyList; } - - void addRow(GenericRow *row) const; + ListHeader(QWidget *parent = nullptr); + ListHeader(QWidget *parent, QString title); + ListHeader(QWidget *parent, QString title, QString description); void setTitle(QString name); void setDescription(QString desc); @@ -48,24 +45,17 @@ class Group : public QFrame, public Utils { bool isCheckable() { return checkable; } private: - QVBoxLayout *layout = nullptr; - - QWidget *headerContainer = nullptr; - QHBoxLayout *headerLayout = nullptr; - QWidget *labelContainer = nullptr; - QVBoxLayout *labelLayout = nullptr; - QWidget *controlContainer = nullptr; - QVBoxLayout *controlLayout = nullptr; + Utils *widgetUtils; - QWidget *contentsContainer = nullptr; - QVBoxLayout *contentsLayout = nullptr; + QHBoxLayout *layout_ = nullptr; QLabel *nameLabel = nullptr; QLabel *descriptionLabel = nullptr; - PropertiesList *propertyList = nullptr; - ToggleSwitch *toggleSwitch = nullptr; bool checkable = false; + +signals: + void toggled(bool enable); }; } // namespace idian diff --git a/shared/qt/idian/include/Idian/Row.hpp b/shared/qt/idian/include/Idian/Row.hpp index 4f324957dc0696..20875a8408e8b1 100644 --- a/shared/qt/idian/include/Idian/Row.hpp +++ b/shared/qt/idian/include/Idian/Row.hpp @@ -17,10 +17,7 @@ #pragma once -#include -#include -#include -#include +#include #include #include @@ -33,53 +30,38 @@ #include namespace idian { - -// Base class mostly so adding stuff to a list is easier -class GenericRow : public QFrame, public Utils { - Q_OBJECT - -public: - GenericRow(QWidget *parent = nullptr) : QFrame(parent), Utils(this) - { - setAttribute(Qt::WA_Hover, true); - - applyStateStylingEventFilter(this); - setAccessibleName(""); - }; - - virtual void setTitle(const QString &title) = 0; - virtual void setDescription(const QString &description) = 0; -}; +class ExpandButton; +class RowList; +class RowInfo; // Row widget containing one or more controls -class Row : public GenericRow { +class Row : public QFrame, public Utils { Q_OBJECT public: Row(QWidget *parent = nullptr); - void setPrefix(QWidget *w, bool autoConnect = true); - void setSuffix(QWidget *w, bool autoConnect = true); - - bool hasPrefix() { return prefix_; } - bool hasSuffix() { return suffix_; } - - QWidget *prefix() const { return prefix_; } - QWidget *suffix() const { return suffix_; } - - void setPrefixEnabled(bool enabled); - void setSuffixEnabled(bool enabled); + void setChangeCursor(bool change); - virtual void setTitle(const QString &title) override; - virtual void setDescription(const QString &description) override; + QHBoxLayout *layout() { return rowLayout; } - void showTitle(bool visible); - void showDescription(bool visible); + // Convenience function to add a widget to the rows layout. + void addWidget(QWidget *widget) { layout()->addWidget(widget); } + // Convenience function to add a widget to the rows layout and then set it as the buddy. + void addBuddy(QWidget *widget) + { + layout()->addWidget(widget); + setBuddy(widget); + } + + // Sets this widget as the buddy of the Row. + // The idian StateEventFilter on Row means certain style-able classes are applied to it based on interaction + // events. The buddy widget will be repolished when these events happen. This allows the buddy widget to be + // styled differently based on the state of the row. For certain widgets, a slot will also be triggered + // on the buddy widget. void setBuddy(QWidget *w); - void setChangeCursor(bool change); - signals: void clicked(); @@ -88,96 +70,13 @@ class Row : public GenericRow { void leaveEvent(QEvent *) override; void mouseReleaseEvent(QMouseEvent *) override; void keyReleaseEvent(QKeyEvent *) override; - bool hasDescription() const { return descriptionLabel != nullptr; } private: - QGridLayout *layout; - - QVBoxLayout *labelLayout = nullptr; - - QLabel *nameLabel = nullptr; - QLabel *descriptionLabel = nullptr; - - QWidget *prefix_ = nullptr; - QWidget *suffix_ = nullptr; + QHBoxLayout *rowLayout; QWidget *buddyWidget = nullptr; void connectBuddyWidget(QWidget *widget); bool changeCursor = false; }; - -// Collapsible row expand button -class ExpandButton : public QAbstractButton, public Utils { - Q_OBJECT - -private: - QPixmap extendDown; - QPixmap extendUp; - - friend class CollapsibleRow; - -protected: - explicit ExpandButton(QWidget *parent = nullptr); - - void paintEvent(QPaintEvent *) override; -}; - -class RowFrame : protected QFrame, protected Utils { - Q_OBJECT - -signals: - void clicked(); - -protected: - explicit RowFrame(QWidget *parent = nullptr); - - void enterEvent(QEnterEvent *) override; - void leaveEvent(QEvent *) override; - -private: - friend class CollapsibleRow; -}; - -// Collapsible Generic OBS property container -class CollapsibleRow : public GenericRow { - Q_OBJECT - -public: - CollapsibleRow(QWidget *parent = nullptr); - - void setCheckable(bool check); - bool isCheckable() { return checkable; } - - void setChecked(bool checked); - bool isChecked() { return toggleSwitch->isChecked(); }; - - virtual void setTitle(const QString &title) override; - virtual void setDescription(const QString &description) override; - - void addRow(GenericRow *actionRow); - -signals: - void toggled(bool checked); - -private: - void toggleVisibility(); - - QPixmap extendDown; - QPixmap extendUp; - - QVBoxLayout *layout; - RowFrame *rowWidget; - QHBoxLayout *rowLayout; - - Row *actionRow; - QFrame *expandFrame; - QHBoxLayout *btnLayout; - ExpandButton *expandButton; - PropertiesList *propertyList; - - ToggleSwitch *toggleSwitch = nullptr; - bool checkable = false; -}; - } // namespace idian diff --git a/shared/qt/idian/include/Idian/RowInfo.hpp b/shared/qt/idian/include/Idian/RowInfo.hpp new file mode 100644 index 00000000000000..010d35d188fd75 --- /dev/null +++ b/shared/qt/idian/include/Idian/RowInfo.hpp @@ -0,0 +1,54 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 + +class QLabel; +class QVBoxLayout; + +namespace idian { +class Utils; + +class RowInfo : public QWidget { + Q_OBJECT + +public: + RowInfo(QWidget *parent); + RowInfo(QWidget *parent, QString title); + RowInfo(QWidget *parent, QString title, QString description); + ~RowInfo() = default; + + void setTitle(const QString &title); + void setDescription(const QString &description); + + void showTitle(bool visible); + void showDescription(bool visible); + + QLabel *title() { return nameLabel; } + QLabel *description() { return descriptionLabel; } + +private: + Utils *widgetUtils; + + QVBoxLayout *layout_ = nullptr; + + QLabel *nameLabel = nullptr; + QLabel *descriptionLabel = nullptr; +}; +} // namespace idian diff --git a/shared/qt/idian/include/Idian/PropertiesList.hpp b/shared/qt/idian/include/Idian/RowList.hpp similarity index 75% rename from shared/qt/idian/include/Idian/PropertiesList.hpp rename to shared/qt/idian/include/Idian/RowList.hpp index b0fe840d65fcfe..518e2cef3c427e 100644 --- a/shared/qt/idian/include/Idian/PropertiesList.hpp +++ b/shared/qt/idian/include/Idian/RowList.hpp @@ -17,6 +17,7 @@ #pragma once +#include #include #include @@ -24,32 +25,29 @@ #include namespace idian { -class GenericRow; - -class PropertiesList : public QFrame { +class RowList : public QFrame { Q_OBJECT public: - PropertiesList(QWidget *parent = nullptr); + RowList(QWidget *parent = nullptr); - void addRow(GenericRow *row); + void addHeader(QWidget *widget); + void addRow(QWidget *row); void clear(); - QList rows() const { return rowsList; } - private: - GenericRow *first = nullptr; - GenericRow *last = nullptr; + QWidget *first = nullptr; + QWidget *last = nullptr; QVBoxLayout *layout; - QList rowsList; + QVBoxLayout *rowLayout; }; // Spacer with only cosmetic functionality -class PropertiesListSpacer : public QFrame { +class RowListSpacer : public QFrame { Q_OBJECT public: - PropertiesListSpacer(QWidget *parent = nullptr) : QFrame(parent) + RowListSpacer(QWidget *parent = nullptr) : QFrame(parent) { setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } diff --git a/shared/qt/idian/include/Idian/Utils.cpp b/shared/qt/idian/include/Idian/Utils.cpp index f1ecd4068222d6..890eb3beebd98f 100644 --- a/shared/qt/idian/include/Idian/Utils.cpp +++ b/shared/qt/idian/include/Idian/Utils.cpp @@ -52,6 +52,9 @@ QPixmap Utils::recolorPixmap(const QPixmap &src, const QColor &color) return QPixmap::fromImage(img); } +// Updates the dynamic property 'class' on a widget with values in response to certain interaction events. +// Ex. `hover` when the widget is hovered. +// Widgets can then be styled via CSS class-style rules like .hover. void Utils::applyStateStylingEventFilter(QWidget *widget) { widget->installEventFilter(new StateEventFilter(this, widget)); diff --git a/shared/qt/idian/widgets/CollapsibleGroup.cpp b/shared/qt/idian/widgets/CollapsibleGroup.cpp new file mode 100644 index 00000000000000..223cac9715187c --- /dev/null +++ b/shared/qt/idian/widgets/CollapsibleGroup.cpp @@ -0,0 +1,107 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 + +namespace idian { +CollapsibleGroup::CollapsibleGroup(QWidget *parent) : QFrame(parent), Utils(this) +{ + mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout->setSpacing(0); + setLayout(mainLayout); + + rowWidget = new Row(this); + + mainLayout->addWidget(rowWidget); + + auto *headerInfo = new RowInfo(rowWidget); + rowWidget->layout()->addWidget(headerInfo); + + expandButton = new ExpandButton(this); + rowWidget->layout()->addWidget(expandButton); + rowWidget->setBuddy(expandButton); + + propertyList = new RowList(this); + propertyList->setVisible(false); + mainLayout->addWidget(propertyList); + + connect(expandButton, &QAbstractButton::clicked, this, &CollapsibleGroup::toggleVisibility); +} + +void CollapsibleGroup::setCheckable(bool check) +{ + checkable = check; + + if (checkable && !toggleSwitch) { + propertyList->setEnabled(false); + Utils::polishChildren(propertyList); + + toggleSwitch = new ToggleSwitch(false); + + rowWidget->layout()->insertWidget(rowWidget->layout()->count() - 1, toggleSwitch); + connect(toggleSwitch, &ToggleSwitch::toggled, propertyList, &RowList::setEnabled); + connect(toggleSwitch, &ToggleSwitch::toggled, this, &CollapsibleGroup::toggled); + } + + if (!checkable && toggleSwitch) { + propertyList->setEnabled(true); + Utils::polishChildren(propertyList); + + toggleSwitch->deleteLater(); + } +} + +void CollapsibleGroup::setChecked(bool checked) +{ + if (!isCheckable()) { + throw std::logic_error("Called setChecked on a non-checkable collapse row."); + } + + toggleSwitch->setChecked(checked); +} + +bool CollapsibleGroup::isChecked() +{ + return toggleSwitch->isChecked(); +} + +void CollapsibleGroup::toggleVisibility() +{ + bool visible = !propertyList->isVisible(); + + setExpanded(visible); +} + +void CollapsibleGroup::setExpanded(bool expand) +{ + Utils::toggleClass("expanded", expand); + Utils::repolish(rowWidget); + + propertyList->setVisible(expand); + expandButton->setChecked(expand); +} +} // namespace idian diff --git a/shared/qt/idian/widgets/Group.cpp b/shared/qt/idian/widgets/Group.cpp deleted file mode 100644 index 89b14e4b9e0085..00000000000000 --- a/shared/qt/idian/widgets/Group.cpp +++ /dev/null @@ -1,126 +0,0 @@ -/****************************************************************************** - 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 - -using idian::Group; - -Group::Group(QWidget *parent) : QFrame(parent), Utils(this) -{ - layout = new QVBoxLayout(this); - layout->setSpacing(0); - layout->setContentsMargins(0, 0, 0, 0); - - headerContainer = new QWidget(); - headerLayout = new QHBoxLayout(); - headerLayout->setSpacing(0); - headerLayout->setContentsMargins(0, 0, 0, 0); - headerContainer->setLayout(headerLayout); - Utils::addClass(headerContainer, "header"); - - labelContainer = new QWidget(); - labelLayout = new QVBoxLayout(); - labelLayout->setSpacing(0); - labelLayout->setContentsMargins(0, 0, 0, 0); - labelContainer->setLayout(labelLayout); - labelContainer->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); - - controlContainer = new QWidget(); - controlLayout = new QVBoxLayout(); - controlLayout->setSpacing(0); - controlLayout->setContentsMargins(0, 0, 0, 0); - controlContainer->setLayout(controlLayout); - - headerLayout->addWidget(labelContainer); - headerLayout->addWidget(controlContainer); - - contentsContainer = new QWidget(); - contentsLayout = new QVBoxLayout(); - contentsLayout->setSpacing(0); - contentsLayout->setContentsMargins(0, 0, 0, 0); - contentsContainer->setLayout(contentsLayout); - Utils::addClass(contentsContainer, "contents"); - - layout->addWidget(headerContainer); - layout->addWidget(contentsContainer); - - propertyList = new PropertiesList(this); - - setLayout(layout); - - contentsLayout->addWidget(propertyList); - - nameLabel = new QLabel(); - Utils::addClass(nameLabel, "title"); - nameLabel->setVisible(false); - labelLayout->addWidget(nameLabel); - - descriptionLabel = new QLabel(); - Utils::addClass(descriptionLabel, "description"); - descriptionLabel->setVisible(false); - labelLayout->addWidget(descriptionLabel); -} - -void Group::addRow(GenericRow *row) const -{ - propertyList->addRow(row); -} - -void Group::setTitle(QString name) -{ - nameLabel->setText(name); - setAccessibleName(name); - showTitle(true); -} - -void Group::setDescription(QString desc) -{ - descriptionLabel->setText(desc); - setAccessibleDescription(desc); - showDescription(true); -} - -void Group::showTitle(bool visible) -{ - nameLabel->setVisible(visible); -} - -void Group::showDescription(bool visible) -{ - descriptionLabel->setVisible(visible); -} - -void Group::setCheckable(bool check) -{ - checkable = check; - - if (checkable && !toggleSwitch) { - toggleSwitch = new ToggleSwitch(true); - controlLayout->addWidget(toggleSwitch); - connect(toggleSwitch, &ToggleSwitch::toggled, this, - [this](bool checked) { propertyList->setEnabled(checked); }); - } - - if (!checkable && toggleSwitch) { - controlLayout->removeWidget(toggleSwitch); - toggleSwitch->deleteLater(); - } -} diff --git a/shared/qt/idian/widgets/ListHeader.cpp b/shared/qt/idian/widgets/ListHeader.cpp new file mode 100644 index 00000000000000..b0047c46f30072 --- /dev/null +++ b/shared/qt/idian/widgets/ListHeader.cpp @@ -0,0 +1,101 @@ +/****************************************************************************** + 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 + +using idian::ListHeader; + +ListHeader::ListHeader(QWidget *parent) : QFrame(parent), Utils(this) +{ + widgetUtils = new idian::Utils(this); + + layout_ = new QHBoxLayout(); + layout_->setSpacing(0); + layout_->setContentsMargins(0, 0, 0, 0); + + setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + + setLayout(layout_); + + auto *textLayout = new QVBoxLayout(); + nameLabel = new QLabel(); + nameLabel->setVisible(false); + widgetUtils->addClass(nameLabel, "title"); + + descriptionLabel = new QLabel(); + descriptionLabel->setVisible(false); + widgetUtils->addClass(descriptionLabel, "description"); + + textLayout->addWidget(nameLabel); + textLayout->addWidget(descriptionLabel); + + layout_->addLayout(textLayout); +} + +ListHeader::ListHeader(QWidget *parent, QString title) : ListHeader(parent) +{ + setTitle(title); +} + +ListHeader::ListHeader(QWidget *parent, QString title, QString description) : ListHeader(parent) +{ + setTitle(title); + setDescription(description); +} + +void ListHeader::setTitle(QString name) +{ + nameLabel->setText(name); + setAccessibleName(name); + showTitle(true); +} + +void ListHeader::setDescription(QString desc) +{ + descriptionLabel->setText(desc); + setAccessibleDescription(desc); + showDescription(true); +} + +void ListHeader::showTitle(bool visible) +{ + nameLabel->setVisible(visible); +} + +void ListHeader::showDescription(bool visible) +{ + descriptionLabel->setVisible(visible); +} + +void ListHeader::setCheckable(bool check) +{ + checkable = check; + + if (checkable && !toggleSwitch) { + toggleSwitch = new ToggleSwitch(true); + layout_->addWidget(toggleSwitch); + connect(toggleSwitch, &ToggleSwitch::toggled, this, [this](bool checked) { emit toggled(checked); }); + } + + if (!checkable && toggleSwitch) { + layout_->removeWidget(toggleSwitch); + toggleSwitch->deleteLater(); + } +} diff --git a/shared/qt/idian/widgets/Row.cpp b/shared/qt/idian/widgets/Row.cpp index 6a5f83da8a21df..016aace653ea7d 100644 --- a/shared/qt/idian/widgets/Row.cpp +++ b/shared/qt/idian/widgets/Row.cpp @@ -15,7 +15,14 @@ along with this program. If not, see . ******************************************************************************/ +#include +#include +#include +#include #include +#include +#include +#include #include #include @@ -25,121 +32,19 @@ #include namespace idian { - -Row::Row(QWidget *parent) : GenericRow(parent) -{ - layout = new QGridLayout(this); - layout->setVerticalSpacing(0); - layout->setContentsMargins(0, 0, 0, 0); - - labelLayout = new QVBoxLayout(); - labelLayout->setSpacing(0); - labelLayout->setContentsMargins(0, 0, 0, 0); - - setFocusPolicy(Qt::StrongFocus); - setLayout(layout); - - layout->setColumnMinimumWidth(0, 0); - layout->setColumnStretch(0, 0); - layout->setColumnStretch(1, 40); - layout->setColumnStretch(2, 55); - - nameLabel = new QLabel(); - nameLabel->setVisible(false); - Utils::addClass(nameLabel, "title"); - - descriptionLabel = new QLabel(); - descriptionLabel->setVisible(false); - Utils::addClass(descriptionLabel, "description"); - - labelLayout->addWidget(nameLabel); - labelLayout->addWidget(descriptionLabel); - - layout->addLayout(labelLayout, 0, 1, Qt::AlignLeft); -} - -void Row::setPrefix(QWidget *w, bool auto_connect) -{ - setSuffixEnabled(false); - - prefix_ = w; - - if (auto_connect) - this->connectBuddyWidget(w); - - prefix_->setParent(this); - layout->addWidget(prefix_, 0, 0, Qt::AlignLeft); - layout->setColumnStretch(0, 3); -} - -void Row::setSuffix(QWidget *w, bool auto_connect) -{ - setPrefixEnabled(false); - - suffix_ = w; - - if (auto_connect) - this->connectBuddyWidget(w); - - suffix_->setParent(this); - layout->addWidget(suffix_, 0, 2, Qt::AlignRight | Qt::AlignVCenter); -} - -void Row::setPrefixEnabled(bool enabled) -{ - if (!prefix_) - return; - if (enabled) - setSuffixEnabled(false); - if (enabled == prefix_->isEnabled() && enabled == prefix_->isVisible()) - return; - - layout->setColumnStretch(0, enabled ? 3 : 0); - prefix_->setEnabled(enabled); - prefix_->setVisible(enabled); -} - -void Row::setSuffixEnabled(bool enabled) +Row::Row(QWidget *parent) : QFrame(parent), Utils(this) { - if (!suffix_) - return; - if (enabled) - setPrefixEnabled(false); - if (enabled == suffix_->isEnabled() && enabled == suffix_->isVisible()) - return; - - suffix_->setEnabled(enabled); - suffix_->setVisible(enabled); -} - -void Row::setTitle(const QString &name) -{ - nameLabel->setText(name); - setAccessibleName(name); - showTitle(true); -} - -void Row::setDescription(const QString &description) -{ - descriptionLabel->setText(description); - setAccessibleDescription(description); - showDescription(true); -} - -void Row::showTitle(bool visible) -{ - nameLabel->setVisible(visible); -} + rowLayout = new QHBoxLayout(this); + rowLayout->setContentsMargins(0, 0, 0, 0); -void Row::showDescription(bool visible) -{ - descriptionLabel->setVisible(visible); + setLayout(rowLayout); } void Row::setBuddy(QWidget *widget) { buddyWidget = widget; Utils::addClass(widget, "row-buddy"); + connectBuddyWidget(widget); } void Row::setChangeCursor(bool change) @@ -160,11 +65,7 @@ void Row::enterEvent(QEnterEvent *event) if (buddyWidget) Utils::repolish(buddyWidget); - if (hasPrefix() || hasSuffix()) { - Utils::polishChildren(); - } - - GenericRow::enterEvent(event); + QFrame::enterEvent(event); } void Row::leaveEvent(QEvent *event) @@ -172,11 +73,7 @@ void Row::leaveEvent(QEvent *event) if (buddyWidget) Utils::repolish(buddyWidget); - if (hasPrefix() || hasSuffix()) { - Utils::polishChildren(); - } - - GenericRow::leaveEvent(event); + QFrame::leaveEvent(event); } void Row::mouseReleaseEvent(QMouseEvent *event) @@ -197,14 +94,15 @@ void Row::keyReleaseEvent(QKeyEvent *event) void Row::connectBuddyWidget(QWidget *widget) { - setAccessibleName(nameLabel->text()); - setFocusProxy(widget); - setBuddy(widget); + setAttribute(Qt::WA_Hover, true); + setFocusPolicy(Qt::StrongFocus); + applyStateStylingEventFilter(this); // If element is a ToggleSwitch and checkable, forward clicks to the widget ToggleSwitch *obsToggle = dynamic_cast(widget); if (obsToggle && obsToggle->isCheckable()) { setChangeCursor(true); + widget->setFocusProxy(this); connect(this, &Row::clicked, obsToggle, &ToggleSwitch::click); return; @@ -214,6 +112,7 @@ void Row::connectBuddyWidget(QWidget *widget) QAbstractButton *button = dynamic_cast(widget); if (button && button->isCheckable()) { setChangeCursor(true); + widget->setFocusProxy(this); connect(this, &Row::clicked, button, &QAbstractButton::click); return; @@ -223,158 +122,10 @@ void Row::connectBuddyWidget(QWidget *widget) ComboBox *obsCombo = dynamic_cast(widget); if (obsCombo) { setChangeCursor(true); + widget->setFocusProxy(this); connect(this, &Row::clicked, obsCombo, &ComboBox::togglePopup); return; } } - -// Button for expanding a collapsible ActionRow -ExpandButton::ExpandButton(QWidget *parent) : QAbstractButton(parent), Utils(this) -{ - Utils::applyStateStylingEventFilter(this); - setCheckable(true); -} - -void ExpandButton::paintEvent(QPaintEvent *) -{ - QStyleOptionButton opt; - opt.initFrom(this); - QPainter p(this); - - bool checked = isChecked(); - - opt.state.setFlag(QStyle::State_On, checked); - opt.state.setFlag(QStyle::State_Off, !checked); - - opt.state.setFlag(QStyle::State_Sunken, checked); - - p.setRenderHint(QPainter::Antialiasing, true); - p.setRenderHint(QPainter::SmoothPixmapTransform, true); - - style()->drawPrimitive(QStyle::PE_PanelButtonCommand, &opt, &p, this); - style()->drawPrimitive(QStyle::PE_IndicatorCheckBox, &opt, &p, this); -} - -// Row variant that can be expanded to show another properties list -CollapsibleRow::CollapsibleRow(QWidget *parent) : GenericRow(parent) -{ - layout = new QVBoxLayout; - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - setLayout(layout); - - rowWidget = new RowFrame(); - rowLayout = new QHBoxLayout(); - rowLayout->setContentsMargins(0, 0, 0, 0); - rowLayout->setSpacing(0); - rowWidget->setLayout(rowLayout); - - actionRow = new Row(); - actionRow->setChangeCursor(false); - - rowLayout->addWidget(actionRow); - - propertyList = new PropertiesList(this); - propertyList->setVisible(false); - - expandFrame = new QFrame(); - btnLayout = new QHBoxLayout(); - btnLayout->setContentsMargins(0, 0, 0, 0); - btnLayout->setSpacing(0); - expandFrame->setLayout(btnLayout); - Utils::addClass(expandFrame, "btn-frame"); - actionRow->setBuddy(expandFrame); - - expandButton = new ExpandButton(this); - btnLayout->addWidget(expandButton); - - rowLayout->addWidget(expandFrame); - - layout->addWidget(rowWidget); - layout->addWidget(propertyList); - - actionRow->setFocusProxy(expandButton); - - connect(expandButton, &QAbstractButton::clicked, this, &CollapsibleRow::toggleVisibility); - connect(actionRow, &Row::clicked, expandButton, &QAbstractButton::click); -} - -void CollapsibleRow::setCheckable(bool check) -{ - checkable = check; - - if (checkable && !toggleSwitch) { - propertyList->setEnabled(false); - Utils::polishChildren(propertyList); - - toggleSwitch = new ToggleSwitch(false); - - actionRow->setSuffix(toggleSwitch, false); - connect(toggleSwitch, &ToggleSwitch::toggled, propertyList, &PropertiesList::setEnabled); - connect(toggleSwitch, &ToggleSwitch::toggled, this, &CollapsibleRow::toggled); - } - - if (!checkable && toggleSwitch) { - propertyList->setEnabled(true); - Utils::polishChildren(propertyList); - - actionRow->suffix()->deleteLater(); - } -} - -void CollapsibleRow::setChecked(bool checked) -{ - if (!isCheckable()) { - throw std::logic_error("Called setChecked on a non-checkable row."); - } - - toggleSwitch->setChecked(checked); -} - -void CollapsibleRow::setTitle(const QString &name) -{ - actionRow->setTitle(name); -} - -void CollapsibleRow::setDescription(const QString &description) -{ - actionRow->setDescription(description); -} - -void CollapsibleRow::toggleVisibility() -{ - bool visible = !propertyList->isVisible(); - - propertyList->setVisible(visible); - expandButton->setChecked(visible); -} - -void CollapsibleRow::addRow(GenericRow *actionRow) -{ - propertyList->addRow(actionRow); -} - -RowFrame::RowFrame(QWidget *parent) : QFrame(parent), Utils(this) -{ - setAttribute(Qt::WA_Hover, true); - - Utils::applyStateStylingEventFilter(this); -} - -void RowFrame::enterEvent(QEnterEvent *event) -{ - setCursor(Qt::PointingHandCursor); - - Utils::polishChildren(); - - QWidget::enterEvent(event); -} - -void RowFrame::leaveEvent(QEvent *event) -{ - Utils::polishChildren(); - - QWidget::leaveEvent(event); -} } // namespace idian diff --git a/shared/qt/idian/widgets/PropertiesList.cpp b/shared/qt/idian/widgets/RowList.cpp similarity index 68% rename from shared/qt/idian/widgets/PropertiesList.cpp rename to shared/qt/idian/widgets/RowList.cpp index 342bbea1914834..fae414e53f8d54 100644 --- a/shared/qt/idian/widgets/PropertiesList.cpp +++ b/shared/qt/idian/widgets/RowList.cpp @@ -15,70 +15,75 @@ along with this program. If not, see . ******************************************************************************/ -#include - -#include +#include #include -#include +#include -using idian::PropertiesList; +using idian::RowList; -PropertiesList::PropertiesList(QWidget *parent) : QFrame(parent) +RowList::RowList(QWidget *parent) : QFrame(parent) { layout = new QVBoxLayout(); layout->setSpacing(0); layout->setContentsMargins(0, 0, 0, 0); setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Minimum); - rowsList = QList(); - setLayout(layout); + + rowLayout = new QVBoxLayout(); + + layout->addLayout(rowLayout); +} + +void idian::RowList::addHeader(QWidget *widget) +{ + layout->insertWidget(layout->indexOf(rowLayout) - 1, widget); } // Note: This function takes ownership of the added widget // and it may be deleted when the properties list is destroyed // or the clear() method is called! -void PropertiesList::addRow(GenericRow *row) +void RowList::addRow(QWidget *widget) { - // Add custom spacer once more than one element exists - if (layout->count() > 0) - layout->addWidget(new PropertiesListSpacer(this)); + // Add custom spacer when more than one row exists + if (rowLayout->count() > 0) { + rowLayout->addWidget(new RowListSpacer(this)); + } // Custom properties to work around :first and :last not existing. if (!first) { - Utils::addClass(row, "first"); - first = row; + Utils::addClass(widget, "first"); + first = widget; } // Remove last property from existing last item - if (last) + if (last) { Utils::removeClass(last, "last"); + } // Most recently added item is also always last - Utils::addClass(row, "last"); - last = row; + Utils::addClass(widget, "last"); + last = widget; - row->setParent(this); - rowsList.append(row); - layout->addWidget(row); + rowLayout->addWidget(widget); adjustSize(); } -void PropertiesList::clear() +void RowList::clear() { - rowsList.clear(); first = nullptr; last = nullptr; - QLayoutItem *item = layout->takeAt(0); + QLayoutItem *item = rowLayout->takeAt(0); while (item) { - if (item->widget()) + if (item->widget()) { item->widget()->deleteLater(); + } delete item; - item = layout->takeAt(0); + item = rowLayout->takeAt(0); } adjustSize(); From 11b62c5b6aadda50606c30075380da254d19b42a Mon Sep 17 00:00:00 2001 From: Warchamp7 Date: Thu, 14 May 2026 00:03:12 -0400 Subject: [PATCH 4/7] frontend: Implement HealthCheckService --- frontend/OBSApp.cpp | 11 ++++++++++- frontend/OBSApp.hpp | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/OBSApp.cpp b/frontend/OBSApp.cpp index 45d307ff6061d5..672a6893b97ccd 100644 --- a/frontend/OBSApp.cpp +++ b/frontend/OBSApp.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #if defined(_WIN32) || defined(ENABLE_SPARKLE_UPDATER) @@ -1266,7 +1267,6 @@ bool OBSApp::OBSInit() thumbnailManager = new ThumbnailManager(this); mainWindow = new OBSBasic(); - mainWindow->setAttribute(Qt::WA_DeleteOnClose, true); #ifndef __APPLE__ @@ -1997,6 +1997,15 @@ void OBSApp::loadAppModules(struct obs_module_failure_info &mfi) pluginManager_->postLoad(); } +OBS::HealthCheckService *OBSApp::healthService() +{ + if (healthService_.isNull()) { + healthService_ = new OBS::HealthCheckService(this); + } + + return healthService_; +} + void OBSApp::pluginManagerOpenDialog() { pluginManager_->open(); diff --git a/frontend/OBSApp.hpp b/frontend/OBSApp.hpp index dabca78761c053..432ff37f5a30d1 100644 --- a/frontend/OBSApp.hpp +++ b/frontend/OBSApp.hpp @@ -51,6 +51,7 @@ class CrashHandler; enum class LogFileType { NoType, CurrentAppLog, LastAppLog, CrashLog }; enum class LogFileState { NoState, New, Uploaded }; +class HealthCheckService; class PluginManager; } // namespace OBS @@ -92,6 +93,7 @@ class OBSApp : public QApplication { std::deque translatorHooks; ThumbnailManager *thumbnailManager = nullptr; + QPointer healthService_; std::unique_ptr pluginManager_; @@ -232,6 +234,7 @@ private slots: void loadAppModules(struct obs_module_failure_info &mfi); + OBS::HealthCheckService *healthService(); ThumbnailManager *thumbnails() const { return thumbnailManager; } // Plugin Manager Accessors From 05f76f77b361351d6bb98180ab72e54ddbdc8c62 Mon Sep 17 00:00:00 2001 From: Warchamp7 Date: Thu, 14 May 2026 00:01:59 -0400 Subject: [PATCH 5/7] frontend: Add HealthCheck dialog and widgets --- frontend/OBSApp.cpp | 15 +++ frontend/OBSApp.hpp | 4 + frontend/cmake/ui-components.cmake | 4 + frontend/cmake/ui-dialogs.cmake | 2 + frontend/cmake/ui-widgets.cmake | 2 + frontend/components/HealthCheckInfoRow.cpp | 45 ++++++++ frontend/components/HealthCheckInfoRow.hpp | 32 ++++++ .../components/HealthCheckStatusLabel.cpp | 39 +++++++ .../components/HealthCheckStatusLabel.hpp | 34 ++++++ frontend/data/locale/en-US.ini | 1 + frontend/data/themes/Yami.obt | 17 +++ frontend/dialogs/HealthCheckDialog.cpp | 100 ++++++++++++++++++ frontend/dialogs/HealthCheckDialog.hpp | 28 +++++ frontend/widgets/HealthCheckWidget.cpp | 44 ++++++++ frontend/widgets/HealthCheckWidget.hpp | 39 +++++++ 15 files changed, 406 insertions(+) create mode 100644 frontend/components/HealthCheckInfoRow.cpp create mode 100644 frontend/components/HealthCheckInfoRow.hpp create mode 100644 frontend/components/HealthCheckStatusLabel.cpp create mode 100644 frontend/components/HealthCheckStatusLabel.hpp create mode 100644 frontend/dialogs/HealthCheckDialog.cpp create mode 100644 frontend/dialogs/HealthCheckDialog.hpp create mode 100644 frontend/widgets/HealthCheckWidget.cpp create mode 100644 frontend/widgets/HealthCheckWidget.hpp diff --git a/frontend/OBSApp.cpp b/frontend/OBSApp.cpp index 672a6893b97ccd..9414d717edf17b 100644 --- a/frontend/OBSApp.cpp +++ b/frontend/OBSApp.cpp @@ -2006,6 +2006,21 @@ OBS::HealthCheckService *OBSApp::healthService() return healthService_; } +void OBSApp::openHealthCheckDialog() +{ + if (!mainWindow) { + return; + } + + if (!healthCheckDialog) { + healthCheckDialog = new HealthCheckDialog(mainWindow); + healthCheckDialog->setAttribute(Qt::WA_DeleteOnClose); + healthCheckDialog->show(); + } else { + healthCheckDialog->raise(); + } +} + void OBSApp::pluginManagerOpenDialog() { pluginManager_->open(); diff --git a/frontend/OBSApp.hpp b/frontend/OBSApp.hpp index 432ff37f5a30d1..e1da362d8ceddc 100644 --- a/frontend/OBSApp.hpp +++ b/frontend/OBSApp.hpp @@ -17,6 +17,7 @@ #pragma once +#include #include #include #include @@ -94,6 +95,7 @@ class OBSApp : public QApplication { ThumbnailManager *thumbnailManager = nullptr; QPointer healthService_; + QPointer healthCheckDialog; std::unique_ptr pluginManager_; @@ -235,6 +237,8 @@ private slots: void loadAppModules(struct obs_module_failure_info &mfi); OBS::HealthCheckService *healthService(); + void openHealthCheckDialog(); + ThumbnailManager *thumbnails() const { return thumbnailManager; } // Plugin Manager Accessors diff --git a/frontend/cmake/ui-components.cmake b/frontend/cmake/ui-components.cmake index 42a811a5086e7f..c898347728e197 100644 --- a/frontend/cmake/ui-components.cmake +++ b/frontend/cmake/ui-components.cmake @@ -44,6 +44,10 @@ target_sources( components/FocusList.hpp components/GameCaptureToolbar.cpp components/GameCaptureToolbar.hpp + components/HealthCheckInfoRow.cpp + components/HealthCheckInfoRow.hpp + components/HealthCheckStatusLabel.cpp + components/HealthCheckStatusLabel.hpp components/ImageSourceToolbar.cpp components/ImageSourceToolbar.hpp components/MediaControls.cpp diff --git a/frontend/cmake/ui-dialogs.cmake b/frontend/cmake/ui-dialogs.cmake index ec1c96c44ea83b..094d7dc0f2b7e9 100644 --- a/frontend/cmake/ui-dialogs.cmake +++ b/frontend/cmake/ui-dialogs.cmake @@ -13,6 +13,8 @@ target_link_libraries(obs-studio PRIVATE OBS::properties-view) target_sources( obs-studio PRIVATE + dialogs/HealthCheckDialog.cpp + dialogs/HealthCheckDialog.hpp dialogs/LogUploadDialog.cpp dialogs/LogUploadDialog.hpp dialogs/NameDialog.cpp diff --git a/frontend/cmake/ui-widgets.cmake b/frontend/cmake/ui-widgets.cmake index dd60fbe4bbef2f..be47e555091ba7 100644 --- a/frontend/cmake/ui-widgets.cmake +++ b/frontend/cmake/ui-widgets.cmake @@ -14,6 +14,8 @@ target_sources( widgets/AudioMixer.hpp widgets/ColorSelect.cpp widgets/ColorSelect.hpp + widgets/HealthCheckWidget.cpp + widgets/HealthCheckWidget.hpp widgets/OBSBasic.cpp widgets/OBSBasic.hpp widgets/OBSBasic_Browser.cpp diff --git a/frontend/components/HealthCheckInfoRow.cpp b/frontend/components/HealthCheckInfoRow.cpp new file mode 100644 index 00000000000000..073e7acb5837db --- /dev/null +++ b/frontend/components/HealthCheckInfoRow.cpp @@ -0,0 +1,45 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 "HealthCheckInfoRow.hpp" + +#include + +#include + +#include + +HealthCheckInfoRow::HealthCheckInfoRow(QWidget *parent, OBS::HealthCheckItem *item) : idian::Row(parent) +{ + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); + + auto *rowInfo = new idian::RowInfo(this, "", item->message()); + addWidget(rowInfo); + + rowInfo->description()->setWordWrap(true); + rowInfo->description()->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum); + + if (item->action() == nullptr) { + return; + } + + QPushButton *healthActionButton = new QPushButton(this); + healthActionButton->setText(item->action()->text()); + + addWidget(healthActionButton); + connect(healthActionButton, &QPushButton::pressed, item->action(), &OBS::HealthCheckAction::trigger); +} diff --git a/frontend/components/HealthCheckInfoRow.hpp b/frontend/components/HealthCheckInfoRow.hpp new file mode 100644 index 00000000000000..08cfdcc8ab1947 --- /dev/null +++ b/frontend/components/HealthCheckInfoRow.hpp @@ -0,0 +1,32 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 + +namespace OBS { +class HealthCheckItem; +} + +class HealthCheckInfoRow : public idian::Row { + Q_OBJECT + +public: + HealthCheckInfoRow(QWidget *parent, OBS::HealthCheckItem *item); + ~HealthCheckInfoRow() = default; +}; diff --git a/frontend/components/HealthCheckStatusLabel.cpp b/frontend/components/HealthCheckStatusLabel.cpp new file mode 100644 index 00000000000000..053c3214bff390 --- /dev/null +++ b/frontend/components/HealthCheckStatusLabel.cpp @@ -0,0 +1,39 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 "HealthCheckStatusLabel.hpp" + +#include + +#include + +HealthCheckStatusLabel::HealthCheckStatusLabel(QWidget *parent, OBS::HealthCheckItem *item) : OBS::NoticeLabel(parent) +{ + setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); + setText(item->statusText()); + + switch (item->status()) { + case OBS::HealthStatus::Warning: + setStyle(OBS::NoticeStyle::Warning); + break; + case OBS::HealthStatus::Critical: + setStyle(OBS::NoticeStyle::Danger); + break; + default: + break; + } +} diff --git a/frontend/components/HealthCheckStatusLabel.hpp b/frontend/components/HealthCheckStatusLabel.hpp new file mode 100644 index 00000000000000..25f4fd4e8d28eb --- /dev/null +++ b/frontend/components/HealthCheckStatusLabel.hpp @@ -0,0 +1,34 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 + +namespace OBS { +class HealthCheckItem; +} + +class HealthCheckStatusLabel : public OBS::NoticeLabel { + Q_OBJECT + +public: + HealthCheckStatusLabel(QWidget *parent, OBS::HealthCheckItem *item); + ~HealthCheckStatusLabel() = default; +}; diff --git a/frontend/data/locale/en-US.ini b/frontend/data/locale/en-US.ini index d8c109c6ef11d7..065219938b318a 100644 --- a/frontend/data/locale/en-US.ini +++ b/frontend/data/locale/en-US.ini @@ -1687,3 +1687,4 @@ HealthCheck.Status.Invalid="Invalid" HealthCheck.Status.Valid="Valid" HealthCheck.Status.Warning="Warning" HealthCheck.Status.Critical="Critical" +HealthCheck.StatusBar="%1 Issue(s) detected" diff --git a/frontend/data/themes/Yami.obt b/frontend/data/themes/Yami.obt index b3558ffcb42d7f..ff73b4abf82f1a 100644 --- a/frontend/data/themes/Yami.obt +++ b/frontend/data/themes/Yami.obt @@ -2854,3 +2854,20 @@ AlignmentSelector::indicator:disabled { AlignmentSelector::indicator:checked:disabled { background: var(--grey3); } + +HealthCheckWidget idian--RowList { + border-radius: 0; + border-bottom-left-radius: var(--border_radius); + border-bottom-right-radius: var(--border_radius); +} + +HealthCheckWidget idian--RowList idian--Row { + padding: var(--action_row_padding) var(--action_row_padding_x); +} + +HealthCheckStatusLabel { + border-radius: var(--border_radius); + border: 1px solid var(--grey3); + padding: var(--padding_large) var(--padding_xlarge); + font-weight: bold; +} diff --git a/frontend/dialogs/HealthCheckDialog.cpp b/frontend/dialogs/HealthCheckDialog.cpp new file mode 100644 index 00000000000000..c0e4d3e1fe58b6 --- /dev/null +++ b/frontend/dialogs/HealthCheckDialog.cpp @@ -0,0 +1,100 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 "HealthCheckDialog.hpp" + +#include + +#include +#include +#include + +#include +#include +#include +#include + +HealthCheckDialog::HealthCheckDialog(QWidget *parent) : QWidget(parent) +{ + setWindowFlag(Qt::Window, true); + setWindowTitle("Detected Issues"); + resize(600, 320); + + auto *buttons = new QDialogButtonBox({QDialogButtonBox::Close}, this); + connect(buttons, &QDialogButtonBox::rejected, this, &QWidget::close); + + QVBoxLayout *windowLayout = new QVBoxLayout(this); + setLayout(windowLayout); + + auto *healthScroll = new QScrollArea(this); + + auto *healthScrollContents = new QFrame(healthScroll); + auto *scrollLayout = new QVBoxLayout(healthScrollContents); + scrollLayout->setContentsMargins(0, 0, 0, 0); + + healthScrollContents->setLayout(scrollLayout); + healthScrollContents->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::MinimumExpanding); + healthScroll->setFrameShape(QFrame::NoFrame); + healthScroll->setLineWidth(0); + + healthScroll->setWidget(healthScrollContents); + healthScroll->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + healthScroll->setWidgetResizable(true); + healthScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + + healthScroll->viewport()->setAutoFillBackground(false); + healthScrollContents->setAutoFillBackground(false); + + auto healthIssues = App()->healthService()->getInvalidItems(); + + auto criticalLayout = new QVBoxLayout(); + auto warningLayout = new QVBoxLayout(); + + scrollLayout->addLayout(criticalLayout); + scrollLayout->addLayout(warningLayout); + + auto addWidgetToStatusLayout = [criticalLayout, warningLayout](HealthCheckWidget *widget) { + if (widget->item()->status() == OBS::HealthStatus::Critical) { + criticalLayout->addWidget(widget); + } else if (widget->item()->status() == OBS::HealthStatus::Warning) { + warningLayout->addWidget(widget); + } + }; + + for (const auto &item : healthIssues) { + auto *healthCheckWidget = new HealthCheckWidget(healthScrollContents, item); + + addWidgetToStatusLayout(healthCheckWidget); + + connect(item, &OBS::HealthCheckItem::statusChanged, healthCheckWidget, [healthCheckWidget, item]() { + if (item->status() == OBS::HealthStatus::Valid) { + healthCheckWidget->hide(); + } + }); + } + + connect(App()->healthService(), &OBS::HealthCheckService::globalStatusChanged, this, [this]() { + if (App()->healthService()->getInvalidCount() == 0) { + close(); + } + }); + + scrollLayout->addStretch(1); + + windowLayout->addWidget(healthScroll); + windowLayout->addWidget(buttons); +} diff --git a/frontend/dialogs/HealthCheckDialog.hpp b/frontend/dialogs/HealthCheckDialog.hpp new file mode 100644 index 00000000000000..b3cf5f40b12c50 --- /dev/null +++ b/frontend/dialogs/HealthCheckDialog.hpp @@ -0,0 +1,28 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 + +class HealthCheckDialog : public QWidget { + Q_OBJECT + +public: + HealthCheckDialog(QWidget *parent); + ~HealthCheckDialog() = default; +}; diff --git a/frontend/widgets/HealthCheckWidget.cpp b/frontend/widgets/HealthCheckWidget.cpp new file mode 100644 index 00000000000000..ed92825ef92c79 --- /dev/null +++ b/frontend/widgets/HealthCheckWidget.cpp @@ -0,0 +1,44 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 "HealthCheckWidget.hpp" + +#include +#include +#include + +#include + +#include + +HealthCheckWidget::HealthCheckWidget(QWidget *parent, OBS::HealthCheckItem *item) + : idian::CollapsibleGroup(parent), + healthItem(item) +{ + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); + setExpanded(true); + + auto *statusLabel = new HealthCheckStatusLabel(this, item); + row()->layout()->insertWidget(0, statusLabel); + + auto *titleLabel = new QLabel(QString("%2").arg(item->title()), this); + idian::Utils::addClass(titleLabel, "text-title"); + row()->layout()->insertWidget(1, titleLabel); + + auto *infoRow = new HealthCheckInfoRow(this, item); + list()->addRow(infoRow); +} diff --git a/frontend/widgets/HealthCheckWidget.hpp b/frontend/widgets/HealthCheckWidget.hpp new file mode 100644 index 00000000000000..e618ecea4e02f7 --- /dev/null +++ b/frontend/widgets/HealthCheckWidget.hpp @@ -0,0 +1,39 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + 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 + +namespace OBS { +class HealthCheckItem; +} + +class HealthCheckWidget : public idian::CollapsibleGroup { + Q_OBJECT + +public: + HealthCheckWidget(QWidget *parent, OBS::HealthCheckItem *item); + ~HealthCheckWidget() = default; + + OBS::HealthCheckItem *item() { return healthItem; } + +private: + QPointer healthItem; +}; From 29094c1d94f02debd9f82fa9085cc15a73192475 Mon Sep 17 00:00:00 2001 From: Warchamp7 Date: Thu, 14 May 2026 16:19:19 -0400 Subject: [PATCH 6/7] frontend: Add HealthCheck to status bar --- frontend/data/themes/Yami.obt | 9 +++++++ frontend/forms/StatusBarWidget.ui | 37 ++++++++++++++++++++++++-- frontend/widgets/OBSBasicStatusBar.cpp | 30 +++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/frontend/data/themes/Yami.obt b/frontend/data/themes/Yami.obt index ff73b4abf82f1a..b129cdaa6a656a 100644 --- a/frontend/data/themes/Yami.obt +++ b/frontend/data/themes/Yami.obt @@ -983,6 +983,15 @@ StatusBarWidget > QFrame { padding: 0px var(--padding_xlarge) var(--padding_small); } +StatusBarWidget #healthFrame { + border-left-width: 0px; + border-right-width: 1px; +} + +StatusBarWidget QPushButton { + padding: 0px var(--padding_xlarge); +} + /* Group Box */ QGroupBox { diff --git a/frontend/forms/StatusBarWidget.ui b/frontend/forms/StatusBarWidget.ui index 594dcb10af4de8..d3a101a659f158 100644 --- a/frontend/forms/StatusBarWidget.ui +++ b/frontend/forms/StatusBarWidget.ui @@ -24,17 +24,50 @@ 0 - 0 + 2 0 - 6 + 2 0 + + + + + 0 + 0 + + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + + 0 + + + 0 + + + 0 + + + 0 + + + + diff --git a/frontend/widgets/OBSBasicStatusBar.cpp b/frontend/widgets/OBSBasicStatusBar.cpp index 1a202ed2d78942..25b83b61613f5b 100644 --- a/frontend/widgets/OBSBasicStatusBar.cpp +++ b/frontend/widgets/OBSBasicStatusBar.cpp @@ -1,6 +1,9 @@ #include "OBSBasicStatusBar.hpp" #include "ui_StatusBarWidget.h" +#include +#include +#include #include #include "moc_OBSBasicStatusBar.cpp" @@ -36,6 +39,33 @@ OBSBasicStatusBar::OBSBasicStatusBar(QWidget *parent) statusWidget->ui->issuesFrame->hide(); statusWidget->ui->kbps->hide(); + if (auto *healthService = App()->healthService()) { + auto *healthIssuesButton = new OBS::NoticeButton(this); + statusWidget->ui->healthFrame->layout()->addWidget(healthIssuesButton); + statusWidget->ui->healthFrame->setVisible(false); + + connect(healthService, &OBS::HealthCheckService::globalStatusChanged, healthIssuesButton, + [statusWidget = this->statusWidget, healthService, + healthIssuesButton](OBS::HealthStatus status) { + int invalidCount = healthService->getInvalidCount(); + + healthIssuesButton->setText(QTStr("HealthCheck.StatusBar").arg(invalidCount)); + switch (status) { + case OBS::HealthStatus::Warning: + healthIssuesButton->setStyle(OBS::NoticeStyle::Warning); + break; + case OBS::HealthStatus::Critical: + healthIssuesButton->setStyle(OBS::NoticeStyle::Danger); + break; + default: + break; + } + statusWidget->ui->healthFrame->setVisible(status != OBS::HealthStatus::Valid); + }); + + connect(healthIssuesButton, &QPushButton::clicked, App(), &OBSApp::openHealthCheckDialog); + } + addPermanentWidget(statusWidget, 1); setMinimumHeight(statusWidget->height()); From 157dc399665e2b202cc9a63e8d6d95f2ccf4b6e1 Mon Sep 17 00:00:00 2001 From: Warchamp7 Date: Thu, 14 May 2026 19:45:28 -0400 Subject: [PATCH 7/7] frontend: Switch missing files popup to health check --- frontend/OBSApp.cpp | 6 --- frontend/OBSApp.hpp | 1 - frontend/data/locale/en-US.ini | 3 ++ frontend/dialogs/OBSMissingFiles.cpp | 4 ++ frontend/dialogs/OBSMissingFiles.hpp | 3 ++ frontend/obs-main.cpp | 12 +----- frontend/widgets/OBSBasic.hpp | 3 ++ .../widgets/OBSBasic_SceneCollections.cpp | 41 +++++++++++++++++-- 8 files changed, 51 insertions(+), 22 deletions(-) diff --git a/frontend/OBSApp.cpp b/frontend/OBSApp.cpp index 9414d717edf17b..b0c5871f979862 100644 --- a/frontend/OBSApp.cpp +++ b/frontend/OBSApp.cpp @@ -72,7 +72,6 @@ extern bool safe_mode; extern bool multi; extern bool disable_3p_plugins; extern bool opt_disable_updater; -extern bool opt_disable_missing_files_check; extern string opt_starting_collection; extern string opt_starting_profile; @@ -1342,11 +1341,6 @@ bool OBSApp::IsUpdaterDisabled() return opt_disable_updater; } -bool OBSApp::IsMissingFilesCheckDisabled() -{ - return opt_disable_missing_files_check; -} - #ifdef __APPLE__ #define INPUT_AUDIO_SOURCE "coreaudio_input_capture" #define OUTPUT_AUDIO_SOURCE "coreaudio_output_capture" diff --git a/frontend/OBSApp.hpp b/frontend/OBSApp.hpp index e1da362d8ceddc..b50357b6ea98a9 100644 --- a/frontend/OBSApp.hpp +++ b/frontend/OBSApp.hpp @@ -199,7 +199,6 @@ private slots: std::string GetVersionString(bool platform = true) const; bool IsPortableMode(); bool IsUpdaterDisabled(); - bool IsMissingFilesCheckDisabled(); const char *InputAudioSource() const; const char *OutputAudioSource() const; diff --git a/frontend/data/locale/en-US.ini b/frontend/data/locale/en-US.ini index 065219938b318a..988f61334eb60c 100644 --- a/frontend/data/locale/en-US.ini +++ b/frontend/data/locale/en-US.ini @@ -1688,3 +1688,6 @@ HealthCheck.Status.Valid="Valid" HealthCheck.Status.Warning="Warning" HealthCheck.Status.Critical="Critical" HealthCheck.StatusBar="%1 Issue(s) detected" +HealthCheck.MissingFiles.Title="Missing Files" +HealthCheck.MissingFiles.Info="%1 file(s) appear to be missing from your current Scene Collection.\n\nMissing files can cause performance issues and should be either fixed or removed." +HealthCheck.MissingFiles.Action="Review Missing Files" diff --git a/frontend/dialogs/OBSMissingFiles.cpp b/frontend/dialogs/OBSMissingFiles.cpp index 029945301fe7e5..f01fd847ea780f 100644 --- a/frontend/dialogs/OBSMissingFiles.cpp +++ b/frontend/dialogs/OBSMissingFiles.cpp @@ -106,6 +106,10 @@ void OBSMissingFiles::saveFiles() } } + if (filesModel->found() == filesModel->files.length()) { + emit allFilesResolved(); + } + QDialog::accept(); } diff --git a/frontend/dialogs/OBSMissingFiles.hpp b/frontend/dialogs/OBSMissingFiles.hpp index 2d5bb41e600b77..588bbbcca16105 100644 --- a/frontend/dialogs/OBSMissingFiles.hpp +++ b/frontend/dialogs/OBSMissingFiles.hpp @@ -50,4 +50,7 @@ class OBSMissingFiles : public QDialog { public slots: void dataChanged(); + +signals: + void allFilesResolved(); }; diff --git a/frontend/obs-main.cpp b/frontend/obs-main.cpp index 8acdba5fdcbf1b..a06c016dfb3ed6 100644 --- a/frontend/obs-main.cpp +++ b/frontend/obs-main.cpp @@ -73,7 +73,6 @@ bool opt_minimize_tray = false; bool opt_allow_opengl = false; bool opt_always_on_top = false; bool opt_disable_updater = false; -bool opt_disable_missing_files_check = false; string opt_starting_collection; string opt_starting_profile; string opt_starting_scene; @@ -990,9 +989,6 @@ int main(int argc, char *argv[]) } else if (arg_is(argv[i], "--disable-updater", nullptr)) { opt_disable_updater = true; - } else if (arg_is(argv[i], "--disable-missing-files-check", nullptr)) { - opt_disable_missing_files_check = true; - } else if (arg_is(argv[i], "--steam", nullptr)) { steam = true; @@ -1018,8 +1014,7 @@ int main(int argc, char *argv[]) "--verbose: Make log more verbose.\n" "--always-on-top: Start in 'always on top' mode.\n\n" "--unfiltered_log: Make log unfiltered.\n\n" - "--disable-updater: Disable built-in updater (Windows/Mac only)\n\n" - "--disable-missing-files-check: Disable the missing files dialog which can appear on startup.\n\n"; + "--disable-updater: Disable built-in updater (Windows/Mac only)\n\n"; #ifdef _WIN32 MessageBoxA(NULL, help.c_str(), "Help", MB_OK | MB_ICONASTERISK); @@ -1046,11 +1041,6 @@ int main(int argc, char *argv[]) opt_disable_updater = os_file_exists(BASE_PATH "/disable_updater") || os_file_exists(BASE_PATH "/disable_updater.txt"); } - - if (!opt_disable_missing_files_check) { - opt_disable_missing_files_check = os_file_exists(BASE_PATH "/disable_missing_files_check") || - os_file_exists(BASE_PATH "/disable_missing_files_check.txt"); - } #endif fstream logFile; diff --git a/frontend/widgets/OBSBasic.hpp b/frontend/widgets/OBSBasic.hpp index f96b400e05be1b..855878584b9dd1 100644 --- a/frontend/widgets/OBSBasic.hpp +++ b/frontend/widgets/OBSBasic.hpp @@ -66,6 +66,7 @@ class QWidgetAction; struct QuickTransition; namespace OBS { +class HealthCheckItem; class SceneCollection; struct Rect; enum class LogFileType; @@ -1072,6 +1073,7 @@ private slots: bool projectChanged = false; bool clearingFailed = false; + QPointer missingFilesHealthCheck; QPointer missDialog; OBSSceneCollectionCache collections; @@ -1085,6 +1087,7 @@ private slots: void ClearSceneData(); void LogScenes(); void SaveProjectNow(); + obs_missing_files_t *getMissingFiles(); void ShowMissingFilesDialog(obs_missing_files_t *files); void SetupNewSceneCollection(const std::string &collectionName); diff --git a/frontend/widgets/OBSBasic_SceneCollections.cpp b/frontend/widgets/OBSBasic_SceneCollections.cpp index 0aa3e6331c8ae6..ac5302517097fd 100644 --- a/frontend/widgets/OBSBasic_SceneCollections.cpp +++ b/frontend/widgets/OBSBasic_SceneCollections.cpp @@ -20,6 +20,8 @@ #include #include #include +#include +#include #include #include @@ -1477,8 +1479,22 @@ void OBSBasic::LoadData(obs_data_t *data, SceneCollection &collection) LogScenes(); - if (!App()->IsMissingFilesCheckDisabled()) - ShowMissingFilesDialog(files); + size_t missingFilesCount = obs_missing_files_count(files); + if (!missingFilesHealthCheck) { + missingFilesHealthCheck = App()->healthService()->createItem(this, "missing_files", + QTStr("HealthCheck.MissingFiles.Title")); + missingFilesHealthCheck->setMessage( + QTStr("HealthCheck.MissingFiles.Info").arg(QString::number(missingFilesCount))); + + OBS::HealthCheckAction *healthAction = missingFilesHealthCheck->createAction(); + healthAction->setText(QTStr("HealthCheck.MissingFiles.Action")); + healthAction->setCallback([this]() { on_actionShowMissingFiles_triggered(); }); + } + + missingFilesCount > 0 ? missingFilesHealthCheck->setStatus(OBS::HealthStatus::Warning) + : missingFilesHealthCheck->setStatus(OBS::HealthStatus::Valid); + + obs_missing_files_destroy(files); disableSaving--; @@ -1638,7 +1654,12 @@ void OBSBasic::ClearSceneData() void OBSBasic::ShowMissingFilesDialog(obs_missing_files_t *files) { - if (obs_missing_files_count(files) > 0) { + size_t missingCount = obs_missing_files_count(files); + if (missingCount > 0) { + missingFilesHealthCheck->setStatus(OBS::HealthStatus::Warning); + missingFilesHealthCheck->setMessage( + QTStr("HealthCheck.MissingFiles.Info").arg(QString::number(missingCount))); + /* When loading the missing files dialog on launch, the * window hasn't fully initialized by this point on macOS, * so put this at the end of the current task queue. Fixes @@ -1648,8 +1669,13 @@ void OBSBasic::ShowMissingFilesDialog(obs_missing_files_t *files) missDialog->setAttribute(Qt::WA_DeleteOnClose, true); missDialog->show(); missDialog->raise(); + + connect(missDialog, &OBSMissingFiles::allFilesResolved, this, + [&]() { missingFilesHealthCheck->setStatus(OBS::HealthStatus::Valid); }); }); } else { + missingFilesHealthCheck->setStatus(OBS::HealthStatus::Valid); + obs_missing_files_destroy(files); /* Only raise dialog if triggered manually */ @@ -1659,7 +1685,7 @@ void OBSBasic::ShowMissingFilesDialog(obs_missing_files_t *files) } } -void OBSBasic::on_actionShowMissingFiles_triggered() +obs_missing_files_t *OBSBasic::getMissingFiles() { obs_missing_files_t *files = obs_missing_files_create(); @@ -1669,5 +1695,12 @@ void OBSBasic::on_actionShowMissingFiles_triggered() }; obs_enum_all_sources(cb_sources, files); + + return files; +} + +void OBSBasic::on_actionShowMissingFiles_triggered() +{ + obs_missing_files_t *files = getMissingFiles(); ShowMissingFilesDialog(files); }