Forráskód Böngészése

frontend: Add new Idian widgets

Co-Authored-By: derrod <[email protected]>
Warchamp7 9 hónapja
szülő
commit
e8f6143769
33 módosított fájl, 2519 hozzáadás és 11 törlés
  1. 1 0
      frontend/CMakeLists.txt
  2. 9 0
      frontend/cmake/feature-idian-playground.cmake
  3. 18 0
      frontend/cmake/ui-components.cmake
  4. 2 0
      frontend/cmake/ui-dialogs.cmake
  5. 372 0
      frontend/components/idian/OBSActionRow.cpp
  6. 215 0
      frontend/components/idian/OBSActionRow.hpp
  7. 20 0
      frontend/components/idian/OBSCheckBox.cpp
  8. 42 0
      frontend/components/idian/OBSCheckBox.hpp
  9. 65 0
      frontend/components/idian/OBSComboBox.cpp
  10. 54 0
      frontend/components/idian/OBSComboBox.hpp
  11. 45 0
      frontend/components/idian/OBSDoubleSpinBox.cpp
  12. 38 0
      frontend/components/idian/OBSDoubleSpinBox.hpp
  13. 122 0
      frontend/components/idian/OBSGroupBox.cpp
  14. 68 0
      frontend/components/idian/OBSGroupBox.hpp
  15. 152 0
      frontend/components/idian/OBSIdianWidget.hpp
  16. 80 0
      frontend/components/idian/OBSPropertiesList.cpp
  17. 57 0
      frontend/components/idian/OBSPropertiesList.hpp
  18. 42 0
      frontend/components/idian/OBSSpinBox.cpp
  19. 38 0
      frontend/components/idian/OBSSpinBox.hpp
  20. 247 0
      frontend/components/idian/OBSToggleSwitch.cpp
  21. 121 0
      frontend/components/idian/OBSToggleSwitch.hpp
  22. 37 0
      frontend/components/idian/obs-widgets.hpp
  23. 105 0
      frontend/data/themes/System.obt
  24. 315 11
      frontend/data/themes/Yami.obt
  25. 132 0
      frontend/dialogs/OBSIdianPlayground.cpp
  26. 36 0
      frontend/dialogs/OBSIdianPlayground.hpp
  27. 6 0
      frontend/forms/OBSBasic.ui
  28. 55 0
      frontend/forms/OBSIdianPlayground.ui
  29. 5 0
      frontend/forms/images/hide.svg
  30. 1 0
      frontend/forms/obs.qrc
  31. 4 0
      frontend/widgets/OBSBasic.cpp
  32. 1 0
      frontend/widgets/OBSBasic.hpp
  33. 14 0
      frontend/widgets/OBSBasic_MainControls.cpp

+ 1 - 0
frontend/CMakeLists.txt

@@ -63,6 +63,7 @@ include(cmake/feature-twitch.cmake)
 include(cmake/feature-restream.cmake)
 include(cmake/feature-youtube.cmake)
 include(cmake/feature-whatsnew.cmake)
+include(cmake/feature-idian-playground.cmake)
 
 add_subdirectory(plugins)
 

+ 9 - 0
frontend/cmake/feature-idian-playground.cmake

@@ -0,0 +1,9 @@
+option(ENABLE_WIDGET_PLAYGROUND "Enable building custom widget demo window" OFF)
+
+if(ENABLE_WIDGET_PLAYGROUND)
+  target_sources(
+    obs-studio
+    PRIVATE forms/OBSIdianPlayground.ui dialogs/OBSIdianPlayground.hpp dialogs/OBSIdianPlayground.cpp
+  )
+  target_enable_feature(obs-studio "Widget Playground" ENABLE_WIDGET_PLAYGROUND)
+endif()

+ 18 - 0
frontend/cmake/ui-components.cmake

@@ -81,4 +81,22 @@ target_sources(
     components/VolumeSlider.hpp
     components/WindowCaptureToolbar.cpp
     components/WindowCaptureToolbar.hpp
+    components/idian/OBSActionRow.cpp
+    components/idian/OBSActionRow.hpp
+    components/idian/OBSCheckBox.cpp
+    components/idian/OBSCheckBox.hpp
+    components/idian/OBSComboBox.cpp
+    components/idian/OBSComboBox.hpp
+    components/idian/OBSDoubleSpinBox.cpp
+    components/idian/OBSDoubleSpinBox.hpp
+    components/idian/OBSGroupBox.cpp
+    components/idian/OBSGroupBox.hpp
+    components/idian/OBSIdianWidget.hpp
+    components/idian/OBSPropertiesList.cpp
+    components/idian/OBSPropertiesList.hpp
+    components/idian/OBSSpinBox.cpp
+    components/idian/OBSSpinBox.hpp
+    components/idian/OBSToggleSwitch.cpp
+    components/idian/OBSToggleSwitch.hpp
+    components/idian/obs-widgets.hpp
 )

+ 2 - 0
frontend/cmake/ui-dialogs.cmake

@@ -37,4 +37,6 @@ target_sources(
     dialogs/OBSRemux.hpp
     dialogs/OBSWhatsNew.cpp
     dialogs/OBSWhatsNew.hpp
+    dialogs/OBSIdianPlayground.cpp
+    dialogs/OBSIdianPlayground.hpp
 )

+ 372 - 0
frontend/components/idian/OBSActionRow.cpp

@@ -0,0 +1,372 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include <QComboBox>
+#include <QApplication>
+#include <QSizePolicy>
+
+#include "OBSActionRow.hpp"
+#include <util/base.h>
+#include <QSvgRenderer>
+
+OBSActionRowWidget::OBSActionRowWidget(QWidget *parent) : OBSActionRow(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);
+	OBSIdianUtils::addClass(nameLabel, "title");
+
+	descriptionLabel = new QLabel();
+	descriptionLabel->setVisible(false);
+	OBSIdianUtils::addClass(descriptionLabel, "description");
+
+	labelLayout->addWidget(nameLabel);
+	labelLayout->addWidget(descriptionLabel);
+
+	layout->addLayout(labelLayout, 0, 1, Qt::AlignLeft);
+}
+
+void OBSActionRowWidget::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 OBSActionRowWidget::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 OBSActionRowWidget::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 OBSActionRowWidget::setSuffixEnabled(bool enabled)
+{
+	if (!_suffix)
+		return;
+	if (enabled)
+		setPrefixEnabled(false);
+	if (enabled == _suffix->isEnabled() && enabled == _suffix->isVisible())
+		return;
+
+	_suffix->setEnabled(enabled);
+	_suffix->setVisible(enabled);
+}
+
+void OBSActionRowWidget::setTitle(QString name)
+{
+	nameLabel->setText(name);
+	setAccessibleName(name);
+	showTitle(true);
+}
+
+void OBSActionRowWidget::setDescription(QString description)
+{
+	descriptionLabel->setText(description);
+	setAccessibleDescription(description);
+	showDescription(true);
+}
+
+void OBSActionRowWidget::showTitle(bool visible)
+{
+	nameLabel->setVisible(visible);
+}
+
+void OBSActionRowWidget::showDescription(bool visible)
+{
+	descriptionLabel->setVisible(visible);
+}
+
+void OBSActionRowWidget::setBuddy(QWidget *widget)
+{
+	buddyWidget = widget;
+	OBSIdianUtils::addClass(widget, "row-buddy");
+}
+
+void OBSActionRowWidget::setChangeCursor(bool change)
+{
+	changeCursor = change;
+	OBSIdianUtils::toggleClass("cursor-pointer", change);
+}
+
+void OBSActionRowWidget::enterEvent(QEnterEvent *event)
+{
+	if (!isEnabled())
+		return;
+
+	if (changeCursor) {
+		setCursor(Qt::PointingHandCursor);
+	}
+
+	OBSIdianUtils::addClass("hover");
+
+	if (buddyWidget)
+		OBSIdianUtils::repolish(buddyWidget);
+
+	if (hasPrefix() || hasSuffix()) {
+		OBSIdianUtils::polishChildren();
+	}
+
+	OBSActionRow::enterEvent(event);
+}
+
+void OBSActionRowWidget::leaveEvent(QEvent *event)
+{
+	OBSIdianUtils::removeClass("hover");
+
+	if (buddyWidget)
+		OBSIdianUtils::repolish(buddyWidget);
+
+	if (hasPrefix() || hasSuffix()) {
+		OBSIdianUtils::polishChildren();
+	}
+
+	OBSActionRow::leaveEvent(event);
+}
+
+void OBSActionRowWidget::mouseReleaseEvent(QMouseEvent *event)
+{
+	if (event->button() & Qt::LeftButton) {
+		emit clicked();
+	}
+	QFrame::mouseReleaseEvent(event);
+}
+
+void OBSActionRowWidget::keyReleaseEvent(QKeyEvent *event)
+{
+	if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Enter) {
+		emit clicked();
+	}
+	QFrame::keyReleaseEvent(event);
+}
+
+void OBSActionRowWidget::connectBuddyWidget(QWidget *widget)
+{
+	setAccessibleName(nameLabel->text());
+	setFocusProxy(widget);
+	setBuddy(widget);
+
+	/* If element is an OBSToggleSwitch and checkable, forward
+	 * clicks to the widget */
+	OBSToggleSwitch *obsToggle = dynamic_cast<OBSToggleSwitch *>(widget);
+	if (obsToggle && obsToggle->isCheckable()) {
+		setChangeCursor(true);
+
+		connect(this, &OBSActionRowWidget::clicked, obsToggle, &OBSToggleSwitch::click);
+		return;
+	}
+
+	/* If element is any other QAbstractButton subclass,
+	 * and checkable, forward clicks to the widget. */
+	QAbstractButton *button = dynamic_cast<QAbstractButton *>(widget);
+	if (button && button->isCheckable()) {
+		setChangeCursor(true);
+
+		connect(this, &OBSActionRowWidget::clicked, button, &QAbstractButton::click);
+		return;
+	}
+
+	/* If element is an OBSComboBox, clicks toggle the dropdown. */
+	OBSComboBox *obsCombo = dynamic_cast<OBSComboBox *>(widget);
+	if (obsCombo) {
+		setChangeCursor(true);
+
+		connect(this, &OBSActionRowWidget::clicked, obsCombo, &OBSComboBox::togglePopup);
+		return;
+	}
+}
+
+/*
+* Button for expanding a collapsible ActionRow
+*/
+OBSActionRowExpandButton::OBSActionRowExpandButton(QWidget *parent) : QAbstractButton(parent), OBSIdianUtils(this)
+{
+	setCheckable(true);
+}
+
+void OBSActionRowExpandButton::paintEvent(QPaintEvent *event)
+{
+	UNUSED_PARAMETER(event);
+
+	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);
+}
+
+/*
+* ActionRow variant that can be expanded to show another properties list
+*/
+OBSCollapsibleRowWidget::OBSCollapsibleRowWidget(const QString &name, QWidget *parent) : OBSActionRow(parent)
+{
+	layout = new QVBoxLayout;
+	layout->setContentsMargins(0, 0, 0, 0);
+	layout->setSpacing(0);
+	setLayout(layout);
+
+	rowWidget = new OBSCollapsibleRowFrame();
+	rowLayout = new QHBoxLayout();
+	rowLayout->setContentsMargins(0, 0, 0, 0);
+	rowLayout->setSpacing(0);
+	rowWidget->setLayout(rowLayout);
+
+	actionRow = new OBSActionRowWidget();
+	actionRow->setTitle(name);
+	actionRow->setChangeCursor(false);
+
+	rowLayout->addWidget(actionRow);
+
+	propertyList = new OBSPropertiesList(this);
+	propertyList->setVisible(false);
+
+	expandFrame = new QFrame();
+	btnLayout = new QHBoxLayout();
+	btnLayout->setContentsMargins(0, 0, 0, 0);
+	btnLayout->setSpacing(0);
+	expandFrame->setLayout(btnLayout);
+	OBSIdianUtils::addClass(expandFrame, "btn-frame");
+	actionRow->setBuddy(expandFrame);
+
+	expandButton = new OBSActionRowExpandButton(this);
+	btnLayout->addWidget(expandButton);
+
+	rowLayout->addWidget(expandFrame);
+
+	layout->addWidget(rowWidget);
+	layout->addWidget(propertyList);
+
+	actionRow->setFocusProxy(expandButton);
+
+	connect(expandButton, &QAbstractButton::clicked, this, &OBSCollapsibleRowWidget::toggleVisibility);
+
+	connect(actionRow, &OBSActionRowWidget::clicked, expandButton, &QAbstractButton::click);
+}
+
+OBSCollapsibleRowWidget::OBSCollapsibleRowWidget(const QString &name, const QString &desc, QWidget *parent)
+	: OBSCollapsibleRowWidget(name, parent)
+{
+	actionRow->setDescription(desc);
+}
+
+void OBSCollapsibleRowWidget::setCheckable(bool check)
+{
+	checkable = check;
+
+	if (checkable && !toggleSwitch) {
+		propertyList->setEnabled(false);
+		OBSIdianUtils::polishChildren(propertyList);
+
+		toggleSwitch = new OBSToggleSwitch(false);
+
+		actionRow->setSuffix(toggleSwitch, false);
+		connect(toggleSwitch, &OBSToggleSwitch::toggled, propertyList, &OBSPropertiesList::setEnabled);
+	}
+
+	if (!checkable && toggleSwitch) {
+		propertyList->setEnabled(true);
+		OBSIdianUtils::polishChildren(propertyList);
+
+		actionRow->suffix()->deleteLater();
+	}
+}
+
+void OBSCollapsibleRowWidget::toggleVisibility()
+{
+	bool visible = !propertyList->isVisible();
+
+	propertyList->setVisible(visible);
+	expandButton->setChecked(visible);
+}
+
+void OBSCollapsibleRowWidget::addRow(OBSActionRow *actionRow)
+{
+	propertyList->addRow(actionRow);
+}
+
+OBSCollapsibleRowFrame::OBSCollapsibleRowFrame(QWidget *parent) : QFrame(parent), OBSIdianUtils(this) {}
+
+void OBSCollapsibleRowFrame::enterEvent(QEnterEvent *event)
+{
+	setCursor(Qt::PointingHandCursor);
+
+	OBSIdianUtils::addClass("hover");
+	OBSIdianUtils::polishChildren();
+
+	QWidget::enterEvent(event);
+}
+
+void OBSCollapsibleRowFrame::leaveEvent(QEvent *event)
+{
+	OBSIdianUtils::removeClass("hover");
+	OBSIdianUtils::polishChildren();
+
+	QWidget::leaveEvent(event);
+}

+ 215 - 0
frontend/components/idian/OBSActionRow.hpp

@@ -0,0 +1,215 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QWidget>
+#include <QFrame>
+#include <QLabel>
+#include <QLayout>
+#include <QScrollArea>
+#include <QMouseEvent>
+#include <QCheckBox>
+
+#include "OBSIdianWidget.hpp"
+#include "OBSPropertiesList.hpp"
+#include "OBSToggleSwitch.hpp"
+#include "OBSComboBox.hpp"
+#include "OBSSpinBox.hpp"
+#include "OBSDoubleSpinBox.hpp"
+
+/**
+* Base class mostly so adding stuff to a list is easier
+*/
+class OBSActionRow : public QFrame, public OBSIdianUtils {
+	Q_OBJECT
+
+public:
+	OBSActionRow(QWidget *parent = nullptr) : QFrame(parent), OBSIdianUtils(this) { setAccessibleName(""); };
+};
+
+/**
+* Proxy for QScrollArea for QSS styling
+*/
+class OBSScrollArea : public QScrollArea {
+	Q_OBJECT
+public:
+	OBSScrollArea(QWidget *parent = nullptr) : QScrollArea(parent) {}
+};
+
+/**
+* Generic OBS row widget containing one or more controls
+*/
+class OBSActionRowWidget : public OBSActionRow {
+	Q_OBJECT
+
+public:
+	OBSActionRowWidget(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 setTitle(QString name);
+	void setDescription(QString description);
+
+	void showTitle(bool visible);
+	void showDescription(bool visible);
+
+	void setBuddy(QWidget *w);
+
+	void setChangeCursor(bool change);
+
+signals:
+	void clicked();
+
+protected:
+	void enterEvent(QEnterEvent *) override;
+	void leaveEvent(QEvent *) override;
+	void mouseReleaseEvent(QMouseEvent *) override;
+	void keyReleaseEvent(QKeyEvent *) override;
+	bool hasDescription() const { return descriptionLabel != nullptr; }
+
+	void focusInEvent(QFocusEvent *event) override
+	{
+		OBSIdianUtils::showKeyFocused(event);
+		QFrame::focusInEvent(event);
+	}
+
+	void focusOutEvent(QFocusEvent *event) override
+	{
+		OBSIdianUtils::hideKeyFocused(event);
+		QFrame::focusOutEvent(event);
+	}
+
+private:
+	QGridLayout *layout;
+
+	QVBoxLayout *labelLayout = nullptr;
+
+	QLabel *nameLabel = nullptr;
+	QLabel *descriptionLabel = nullptr;
+
+	QWidget *_prefix = nullptr;
+	QWidget *_suffix = nullptr;
+
+	QWidget *buddyWidget = nullptr;
+
+	void connectBuddyWidget(QWidget *widget);
+	bool changeCursor = false;
+};
+
+/**
+* Collapsible row expand button
+*/
+class OBSActionRowExpandButton : public QAbstractButton, public OBSIdianUtils {
+	Q_OBJECT
+
+private:
+	QPixmap extendDown;
+	QPixmap extendUp;
+
+	friend class OBSCollapsibleRowWidget;
+
+protected:
+	explicit OBSActionRowExpandButton(QWidget *parent = nullptr);
+
+	void paintEvent(QPaintEvent *) override;
+
+	void focusInEvent(QFocusEvent *event) override
+	{
+		OBSIdianUtils::showKeyFocused(event);
+		QAbstractButton::focusInEvent(event);
+	}
+
+	void focusOutEvent(QFocusEvent *event) override
+	{
+		OBSIdianUtils::hideKeyFocused(event);
+		QAbstractButton::focusOutEvent(event);
+	}
+};
+
+class OBSCollapsibleRowFrame : protected QFrame, protected OBSIdianUtils {
+	Q_OBJECT
+
+signals:
+	void clicked();
+
+protected:
+	explicit OBSCollapsibleRowFrame(QWidget *parent = nullptr);
+
+	void enterEvent(QEnterEvent *) override;
+	void leaveEvent(QEvent *) override;
+
+	void focusInEvent(QFocusEvent *event) override
+	{
+		OBSIdianUtils::showKeyFocused(event);
+		QWidget::focusInEvent(event);
+	}
+
+	void focusOutEvent(QFocusEvent *event) override
+	{
+		OBSIdianUtils::hideKeyFocused(event);
+		QWidget::focusOutEvent(event);
+	}
+
+private:
+	friend class OBSCollapsibleRowWidget;
+};
+
+/**
+* Collapsible Generic OBS property container
+*/
+class OBSCollapsibleRowWidget : public OBSActionRow {
+	Q_OBJECT
+
+public:
+	OBSCollapsibleRowWidget(const QString &name, QWidget *parent = nullptr);
+	OBSCollapsibleRowWidget(const QString &name, const QString &desc = nullptr, QWidget *parent = nullptr);
+
+	void setCheckable(bool check);
+	bool isCheckable() { return checkable; }
+
+	void addRow(OBSActionRow *actionRow);
+
+private:
+	void toggleVisibility();
+
+	QPixmap extendDown;
+	QPixmap extendUp;
+
+	QVBoxLayout *layout;
+	OBSCollapsibleRowFrame *rowWidget;
+	QHBoxLayout *rowLayout;
+
+	OBSActionRowWidget *actionRow;
+	QFrame *expandFrame;
+	QHBoxLayout *btnLayout;
+	OBSActionRowExpandButton *expandButton;
+	OBSPropertiesList *propertyList;
+
+	OBSToggleSwitch *toggleSwitch = nullptr;
+	bool checkable = false;
+};

+ 20 - 0
frontend/components/idian/OBSCheckBox.cpp

@@ -0,0 +1,20 @@
+/******************************************************************************
+    Copyright (C) 2024 by Taylor Giampaolo <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "OBSCheckBox.hpp"
+
+OBSCheckBox::OBSCheckBox(QWidget *parent) : QCheckBox(parent), OBSIdianUtils(this) {}

+ 42 - 0
frontend/components/idian/OBSCheckBox.hpp

@@ -0,0 +1,42 @@
+/******************************************************************************
+    Copyright (C) 2024 by Taylor Giampaolo <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QCheckBox>
+
+#include "OBSIdianWidget.hpp"
+
+class OBSCheckBox : public QCheckBox, public OBSIdianUtils {
+	Q_OBJECT;
+
+public:
+	OBSCheckBox(QWidget *parent = nullptr);
+
+protected:
+	void focusInEvent(QFocusEvent *e) override
+	{
+		OBSIdianUtils::showKeyFocused(e);
+		QAbstractButton::focusInEvent(e);
+	}
+
+	void focusOutEvent(QFocusEvent *e) override
+	{
+		OBSIdianUtils::hideKeyFocused(e);
+		QAbstractButton::focusOutEvent(e);
+	}
+};

+ 65 - 0
frontend/components/idian/OBSComboBox.cpp

@@ -0,0 +1,65 @@
+/******************************************************************************
+    Copyright (C) 2024 by Taylor Giampaolo <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "OBSActionRow.hpp"
+#include "OBSComboBox.hpp"
+#include <QTimer>
+#include <util/base.h>
+
+#define UNUSED_PARAMETER(param) (void)param
+
+OBSComboBox::OBSComboBox(QWidget *parent) : QComboBox(parent), OBSIdianUtils(this) {}
+
+void OBSComboBox::showPopup()
+{
+	if (allowOpeningPopup) {
+		allowOpeningPopup = false;
+		QComboBox::showPopup();
+	}
+}
+
+void OBSComboBox::hidePopup()
+{
+	// It would be nice to find a better way to do this.
+	//
+	// When the dropdown is closed, block attempts to open it
+	// again for a short time. This is so clicking a GenericRow
+	// with the dropdown open doesn't immediately close and re-open it.
+	// I have tried all sorts of things involving handling mouse events
+	// and event filters on both GenericRow and ComboBox.
+	//
+	// All my efforts have failed so we get this instead.
+	allowOpeningPopup = false;
+	QTimer::singleShot(120, this, [=]() { allowOpeningPopup = true; });
+
+	QComboBox::hidePopup();
+}
+
+void OBSComboBox::mousePressEvent(QMouseEvent *event)
+{
+	blog(LOG_INFO, "OBSComboBox::mousePressEvent");
+	QComboBox::mousePressEvent(event);
+}
+
+void OBSComboBox::togglePopup()
+{
+	if (view()->isVisible()) {
+		OBSComboBox::hidePopup();
+	} else {
+		OBSComboBox::showPopup();
+	}
+}

+ 54 - 0
frontend/components/idian/OBSComboBox.hpp

@@ -0,0 +1,54 @@
+/******************************************************************************
+    Copyright (C) 2024 by Taylor Giampaolo <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QComboBox>
+#include <QAbstractItemView>
+
+#include "OBSIdianWidget.hpp"
+
+class OBSComboBox : public QComboBox, public OBSIdianUtils {
+	Q_OBJECT
+
+public:
+	OBSComboBox(QWidget *parent = nullptr);
+
+public Q_SLOTS:
+	void togglePopup();
+
+private:
+	bool allowOpeningPopup = true;
+
+protected:
+	void showPopup() override;
+	void hidePopup() override;
+
+	void mousePressEvent(QMouseEvent *event) override;
+
+	void focusInEvent(QFocusEvent *e) override
+	{
+		OBSIdianUtils::showKeyFocused(e);
+		QComboBox::focusInEvent(e);
+	}
+
+	void focusOutEvent(QFocusEvent *e) override
+	{
+		OBSIdianUtils::hideKeyFocused(e);
+		QComboBox::focusOutEvent(e);
+	}
+};

+ 45 - 0
frontend/components/idian/OBSDoubleSpinBox.cpp

@@ -0,0 +1,45 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "OBSDoubleSpinBox.hpp"
+
+OBSDoubleSpinBox::OBSDoubleSpinBox(QWidget *parent) : QFrame(parent)
+{
+	layout = new QHBoxLayout();
+	setLayout(layout);
+
+	layout->setContentsMargins(0, 0, 0, 0);
+
+	decr = new QPushButton("-");
+	decr->setObjectName("obsSpinBoxButton");
+	layout->addWidget(decr);
+
+	setFocusProxy(decr);
+
+	sbox = new QDoubleSpinBox();
+	sbox->setObjectName("obsSpinBox");
+	sbox->setButtonSymbols(QAbstractSpinBox::NoButtons);
+	sbox->setAlignment(Qt::AlignCenter);
+	layout->addWidget(sbox);
+
+	incr = new QPushButton("+");
+	incr->setObjectName("obsSpinBoxButton");
+	layout->addWidget(incr);
+
+	connect(decr, &QPushButton::pressed, sbox, &QDoubleSpinBox::stepDown);
+	connect(incr, &QPushButton::pressed, sbox, &QDoubleSpinBox::stepUp);
+}

+ 38 - 0
frontend/components/idian/OBSDoubleSpinBox.hpp

@@ -0,0 +1,38 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QFrame>
+#include <QLayout>
+#include <QSpinBox>
+#include <QPushButton>
+
+class OBSDoubleSpinBox : public QFrame {
+	Q_OBJECT;
+
+public:
+	OBSDoubleSpinBox(QWidget *parent = nullptr);
+
+	QDoubleSpinBox *spinBox() const { return sbox; }
+
+private:
+	QHBoxLayout *layout;
+	QPushButton *decr;
+	QPushButton *incr;
+	QDoubleSpinBox *sbox;
+};

+ 122 - 0
frontend/components/idian/OBSGroupBox.cpp

@@ -0,0 +1,122 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "OBSIdianWidget.hpp"
+#include "OBSGroupBox.hpp"
+
+OBSGroupBox::OBSGroupBox(QWidget *parent) : QFrame(parent), OBSIdianUtils(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);
+	OBSIdianUtils::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);
+	OBSIdianUtils::addClass(contentsContainer, "contents");
+
+	layout->addWidget(headerContainer);
+	layout->addWidget(contentsContainer);
+
+	propertyList = new OBSPropertiesList(this);
+
+	setLayout(layout);
+
+	contentsLayout->addWidget(propertyList);
+	/*contentsContainer->setSizePolicy(policy);*/
+
+	nameLabel = new QLabel();
+	OBSIdianUtils::addClass(nameLabel, "title");
+	nameLabel->setVisible(false);
+	labelLayout->addWidget(nameLabel);
+
+	descriptionLabel = new QLabel();
+	OBSIdianUtils::addClass(descriptionLabel, "description");
+	descriptionLabel->setVisible(false);
+	labelLayout->addWidget(descriptionLabel);
+}
+
+void OBSGroupBox::addRow(OBSActionRow *actionRow) const
+{
+	propertyList->addRow(actionRow);
+}
+
+void OBSGroupBox::setTitle(QString name)
+{
+	nameLabel->setText(name);
+	setAccessibleName(name);
+	showTitle(true);
+}
+
+void OBSGroupBox::setDescription(QString desc)
+{
+	descriptionLabel->setText(desc);
+	setAccessibleDescription(desc);
+	showDescription(true);
+}
+
+void OBSGroupBox::showTitle(bool visible)
+{
+	nameLabel->setVisible(visible);
+}
+
+void OBSGroupBox::showDescription(bool visible)
+{
+	descriptionLabel->setVisible(visible);
+}
+
+void OBSGroupBox::setCheckable(bool check)
+{
+	checkable = check;
+
+	if (checkable && !toggleSwitch) {
+		toggleSwitch = new OBSToggleSwitch(true);
+		controlLayout->addWidget(toggleSwitch);
+		connect(toggleSwitch, &OBSToggleSwitch::toggled, this,
+			[=](bool checked) { propertyList->setEnabled(checked); });
+	}
+
+	if (!checkable && toggleSwitch) {
+		controlLayout->removeWidget(toggleSwitch);
+		toggleSwitch->deleteLater();
+	}
+}

+ 68 - 0
frontend/components/idian/OBSGroupBox.hpp

@@ -0,0 +1,68 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QLayout>
+#include <QLabel>
+#include <QWidget>
+#include <QMouseEvent>
+
+#include "OBSActionRow.hpp"
+#include "OBSPropertiesList.hpp"
+#include "OBSToggleSwitch.hpp"
+
+class OBSGroupBox : public QFrame, public OBSIdianUtils {
+	Q_OBJECT
+
+public:
+	OBSGroupBox(QWidget *parent = nullptr);
+
+	OBSPropertiesList *properties() const { return propertyList; }
+
+	void addRow(OBSActionRow *actionRow) const;
+
+	void setTitle(QString name);
+	void setDescription(QString desc);
+
+	void showTitle(bool visible);
+	void showDescription(bool visible);
+
+	void setCheckable(bool check);
+	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;
+
+	QWidget *contentsContainer = nullptr;
+	QVBoxLayout *contentsLayout = nullptr;
+
+	QLabel *nameLabel = nullptr;
+	QLabel *descriptionLabel = nullptr;
+
+	OBSPropertiesList *propertyList = nullptr;
+
+	OBSToggleSwitch *toggleSwitch = nullptr;
+	bool checkable = false;
+};

+ 152 - 0
frontend/components/idian/OBSIdianWidget.hpp

@@ -0,0 +1,152 @@
+/******************************************************************************
+    Copyright (C) 2024 by Taylor Giampaolo <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QWidget>
+#include <QStyle>
+#include <QFocusEvent>
+#include <QRegularExpression>
+
+/*
+ * Helpers for OBS Idian widgets
+ */
+
+static const QRegularExpression classRegex = QRegularExpression("^[a-zA-Z][a-zA-Z0-9_-]*$");
+
+class OBSIdianUtils {
+
+	static bool classNameIsValid(const QString &name)
+	{
+		QRegularExpressionMatch match = classRegex.match(name);
+		return match.hasMatch();
+	}
+
+public:
+	QWidget *parent = nullptr;
+
+	OBSIdianUtils(QWidget *w) { parent = w; }
+
+	/*
+	 * Set a custom property whenever the widget has
+	 * keyboard focus specifically
+	 */
+	void showKeyFocused(QFocusEvent *e)
+	{
+		if (e->reason() != Qt::MouseFocusReason && e->reason() != Qt::PopupFocusReason) {
+			addClass("keyFocus");
+		} else {
+			removeClass("keyFocus");
+		}
+	}
+
+	void hideKeyFocused(QFocusEvent *e)
+	{
+		if (e->reason() != Qt::PopupFocusReason) {
+			removeClass("keyFocus");
+		}
+	}
+
+	/*
+	 * Force all children widgets to repaint
+	 */
+	void polishChildren() { polishChildren(parent); }
+
+	static void polishChildren(QWidget *widget)
+	{
+		for (QWidget *child : widget->findChildren<QWidget *>()) {
+			repolish(child);
+		}
+	}
+
+	void repolish() { repolish(parent); }
+
+	static void repolish(QWidget *widget)
+	{
+		widget->style()->unpolish(widget);
+		widget->style()->polish(widget);
+	}
+
+	/*
+	 * Adds a style class to the widget
+	 */
+	void addClass(const QString &classname) { addClass(parent, classname); }
+
+	static void addClass(QWidget *widget, const QString &classname)
+	{
+		if (!classNameIsValid(classname)) {
+			return;
+		}
+
+		QVariant current = widget->property("class");
+
+		QStringList classList = current.toString().split(" ");
+		if (classList.contains(classname)) {
+			return;
+		}
+
+		classList.removeDuplicates();
+		classList.append(classname);
+
+		widget->setProperty("class", classList.join(" "));
+
+		repolish(widget);
+	}
+
+	/*
+	 * Removes a style class from a widget
+	 */
+	void removeClass(const QString &classname) { removeClass(parent, classname); }
+
+	static void removeClass(QWidget *widget, const QString &classname)
+	{
+		if (!classNameIsValid(classname)) {
+			return;
+		}
+
+		QVariant current = widget->property("class");
+		if (current.isNull()) {
+			return;
+		}
+
+		QStringList classList = current.toString().split(" ");
+		if (!classList.contains(classname, Qt::CaseSensitive)) {
+			return;
+		}
+
+		classList.removeDuplicates();
+		classList.removeAll(classname);
+
+		widget->setProperty("class", classList.join(" "));
+
+		repolish(widget);
+	}
+
+	/*
+	 * Forces the addition or removal of a style class from a widget
+	 */
+	void toggleClass(const QString &classname, bool toggle) { toggleClass(parent, classname, toggle); }
+
+	static void toggleClass(QWidget *widget, const QString &classname, bool toggle)
+	{
+		if (toggle) {
+			addClass(widget, classname);
+		} else {
+			removeClass(widget, classname);
+		}
+	}
+};

+ 80 - 0
frontend/components/idian/OBSPropertiesList.cpp

@@ -0,0 +1,80 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include <QStyle>
+
+#include "OBSPropertiesList.hpp"
+#include "OBSActionRow.hpp"
+
+OBSPropertiesList::OBSPropertiesList(QWidget *parent) : QFrame(parent)
+{
+	layout = new QVBoxLayout();
+	layout->setSpacing(0);
+	layout->setContentsMargins(0, 0, 0, 0);
+	setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Minimum);
+
+	rowsList = QList<OBSActionRow *>();
+
+	setLayout(layout);
+}
+
+/* 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 OBSPropertiesList::addRow(OBSActionRow *actionRow)
+{
+	// Add custom spacer once more than one element exists
+	if (layout->count() > 0)
+		layout->addWidget(new OBSPropertiesListSpacer(this));
+
+	// Custom properties to work around :first and :last not existing.
+	if (!first) {
+		OBSIdianUtils::addClass(actionRow, "first");
+		first = actionRow;
+	}
+
+	// Remove last property from existing last item
+	if (last)
+		OBSIdianUtils::removeClass(last, "last");
+
+	// Most recently added item is also always last
+	OBSIdianUtils::addClass(actionRow, "last");
+	last = actionRow;
+
+	actionRow->setParent(this);
+	rowsList.append(actionRow);
+	layout->addWidget(actionRow);
+	adjustSize();
+}
+
+void OBSPropertiesList::clear()
+{
+	rowsList.clear();
+	first = nullptr;
+	last = nullptr;
+	QLayoutItem *item = layout->takeAt(0);
+
+	while (item) {
+		if (item->widget())
+			item->widget()->deleteLater();
+		delete item;
+
+		item = layout->takeAt(0);
+	}
+
+	adjustSize();
+}

+ 57 - 0
frontend/components/idian/OBSPropertiesList.hpp

@@ -0,0 +1,57 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QFrame>
+#include <QWidget>
+#include <QLayout>
+
+#include "OBSIdianWidget.hpp"
+
+class OBSActionRow;
+
+class OBSPropertiesList : public QFrame {
+	Q_OBJECT
+
+public:
+	OBSPropertiesList(QWidget *parent = nullptr);
+
+	void addRow(OBSActionRow *actionRow);
+	void clear();
+
+	QList<OBSActionRow *> rows() const { return rowsList; }
+
+private:
+	OBSActionRow *first = nullptr;
+	OBSActionRow *last = nullptr;
+
+	QVBoxLayout *layout;
+	QList<OBSActionRow *> rowsList;
+};
+
+/**
+* Spacer with only cosmetic functionality
+*/
+class OBSPropertiesListSpacer : public QFrame {
+	Q_OBJECT
+public:
+	OBSPropertiesListSpacer(QWidget *parent = nullptr) : QFrame(parent)
+	{
+		setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+	}
+};

+ 42 - 0
frontend/components/idian/OBSSpinBox.cpp

@@ -0,0 +1,42 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "OBSSpinBox.hpp"
+
+OBSSpinBox::OBSSpinBox(QWidget *parent) : QFrame(parent)
+{
+	layout = new QHBoxLayout();
+	setLayout(layout);
+
+	layout->setContentsMargins(0, 0, 0, 0);
+
+	decr = new QPushButton("-");
+	decr->setObjectName("obsSpinBoxButton");
+	layout->addWidget(decr);
+
+	sbox = new QSpinBox();
+	sbox->setObjectName("obsSpinBox");
+	sbox->setButtonSymbols(QAbstractSpinBox::NoButtons);
+	layout->addWidget(sbox);
+
+	incr = new QPushButton("+");
+	incr->setObjectName("obsSpinBoxButton");
+	layout->addWidget(incr);
+
+	connect(decr, &QPushButton::pressed, sbox, &QSpinBox::stepDown);
+	connect(incr, &QPushButton::pressed, sbox, &QSpinBox::stepUp);
+}

+ 38 - 0
frontend/components/idian/OBSSpinBox.hpp

@@ -0,0 +1,38 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QFrame>
+#include <QLayout>
+#include <QSpinBox>
+#include <QPushButton>
+
+class OBSSpinBox : public QFrame {
+	Q_OBJECT;
+
+public:
+	OBSSpinBox(QWidget *parent = nullptr);
+
+	QSpinBox *spinBox() const { return sbox; }
+
+private:
+	QHBoxLayout *layout;
+	QPushButton *decr;
+	QPushButton *incr;
+	QSpinBox *sbox;
+};

+ 247 - 0
frontend/components/idian/OBSToggleSwitch.cpp

@@ -0,0 +1,247 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "OBSToggleSwitch.hpp"
+#include <util/base.h>
+
+#define UNUSED_PARAMETER(param) (void)param
+
+static QColor blendColors(const QColor &color1, const QColor &color2, float ratio)
+{
+	int r = color1.red() * (1 - ratio) + color2.red() * ratio;
+	int g = color1.green() * (1 - ratio) + color2.green() * ratio;
+	int b = color1.blue() * (1 - ratio) + color2.blue() * ratio;
+
+	return QColor(r, g, b, 255);
+}
+
+OBSToggleSwitch::OBSToggleSwitch(QWidget *parent)
+	: QAbstractButton(parent),
+	  animHandle(new QPropertyAnimation(this, "xpos", this)),
+	  animBgColor(new QPropertyAnimation(this, "blend", this)),
+	  OBSIdianUtils(this)
+{
+	offPos = rect().width() / 2 - 18;
+	onPos = rect().width() / 2 + 18;
+	xPos = offPos;
+	margin = 3;
+
+	setCheckable(true);
+	setAccessibleName("ToggleSwitch");
+
+	installEventFilter(this);
+
+	connect(this, &OBSToggleSwitch::clicked, this, &OBSToggleSwitch::onClicked);
+
+	connect(animHandle, &QVariantAnimation::valueChanged, this, &OBSToggleSwitch::updateBackgroundColor);
+	connect(animBgColor, &QVariantAnimation::valueChanged, this, &OBSToggleSwitch::updateBackgroundColor);
+}
+
+OBSToggleSwitch::OBSToggleSwitch(bool defaultState, QWidget *parent) : OBSToggleSwitch(parent)
+{
+	setChecked(defaultState);
+	if (defaultState) {
+		xPos = onPos;
+	}
+}
+
+void OBSToggleSwitch::animateHandlePosition()
+{
+	animHandle->setStartValue(xPos);
+
+	int endPos = onPos;
+
+	if ((!isDelayed() && !isChecked()) || (isDelayed() && !pendingStatus))
+		endPos = offPos;
+
+	animHandle->setEndValue(endPos);
+
+	animHandle->setDuration(120);
+	animHandle->start();
+}
+
+void OBSToggleSwitch::updateBackgroundColor()
+{
+	QColor offColor = underMouse() ? backgroundInactiveHover : backgroundInactive;
+	QColor onColor = underMouse() ? backgroundActiveHover : backgroundActive;
+
+	if (!isDelayed()) {
+		int offset = isChecked() ? 0 : offPos;
+		blend = (float)(xPos - offset) / (float)(onPos);
+	}
+
+	QColor bg = blendColors(offColor, onColor, blend);
+
+	if (!isEnabled())
+		bg = backgroundInactive;
+
+	setStyleSheet("background: " + bg.name());
+}
+
+void OBSToggleSwitch::changeEvent(QEvent *event)
+{
+	if (event->type() == QEvent::EnabledChange) {
+		OBSIdianUtils::toggleClass("disabled", !isEnabled());
+		updateBackgroundColor();
+	}
+}
+
+void OBSToggleSwitch::paintEvent(QPaintEvent *e)
+{
+	UNUSED_PARAMETER(e);
+
+	QStyleOptionButton opt;
+	opt.initFrom(this);
+	QPainter p(this);
+
+	bool showChecked = isChecked();
+	if (isDelayed()) {
+		showChecked = pendingStatus;
+	}
+
+	opt.state.setFlag(QStyle::State_On, showChecked);
+	opt.state.setFlag(QStyle::State_Off, !showChecked);
+
+	opt.state.setFlag(QStyle::State_Sunken, true);
+
+	style()->drawPrimitive(QStyle::PE_PanelButtonCommand, &opt, &p, this);
+
+	p.setRenderHint(QPainter::Antialiasing, true);
+
+	p.setBrush(handleColor);
+	p.drawEllipse(QRectF(xPos, margin, handleSize, handleSize));
+}
+
+void OBSToggleSwitch::showEvent(QShowEvent *e)
+{
+	margin = (rect().height() - handleSize) / 2;
+
+	offPos = margin;
+	onPos = rect().width() - handleSize - margin;
+
+	xPos = isChecked() ? onPos : offPos;
+
+	updateBackgroundColor();
+	style()->polish(this);
+
+	QAbstractButton::showEvent(e);
+}
+
+void OBSToggleSwitch::click()
+{
+	if (!isDelayed())
+		QAbstractButton::click();
+
+	if (isChecked() == pendingStatus)
+		setPending(!isChecked());
+}
+
+void OBSToggleSwitch::onClicked(bool checked)
+{
+	if (delayed)
+		return;
+
+	setPending(checked);
+}
+
+void OBSToggleSwitch::setStatus(bool status)
+{
+	if (status == isChecked() && status == pendingStatus)
+		return;
+
+	pendingStatus = status;
+	setChecked(status);
+
+	if (isChecked()) {
+		animBgColor->setStartValue(0.0f);
+		animBgColor->setEndValue(1.0f);
+	} else {
+		animBgColor->setStartValue(1.0f);
+		animBgColor->setEndValue(0.0f);
+	}
+
+	animBgColor->setEasingCurve(QEasingCurve::InOutCubic);
+	animBgColor->setDuration(240);
+	animBgColor->start();
+}
+
+void OBSToggleSwitch::setPending(bool pending)
+{
+	pendingStatus = pending;
+	animateHandlePosition();
+
+	if (!isDelayed())
+		return;
+
+	if (pending) {
+		emit pendingChecked();
+	} else {
+		emit pendingUnchecked();
+	}
+}
+
+void OBSToggleSwitch::setDelayed(bool state)
+{
+	delayed = state;
+	pendingStatus = isChecked();
+}
+
+void OBSToggleSwitch::enterEvent(QEnterEvent *e)
+{
+	setCursor(Qt::PointingHandCursor);
+	updateBackgroundColor();
+	QAbstractButton::enterEvent(e);
+}
+
+void OBSToggleSwitch::leaveEvent(QEvent *e)
+{
+	updateBackgroundColor();
+	QAbstractButton::leaveEvent(e);
+}
+
+void OBSToggleSwitch::keyReleaseEvent(QKeyEvent *e)
+{
+	if (!isDelayed()) {
+		QAbstractButton::keyReleaseEvent(e);
+		return;
+	}
+
+	if (e->key() != Qt::Key_Space) {
+		return;
+	}
+
+	click();
+}
+
+void OBSToggleSwitch::mouseReleaseEvent(QMouseEvent *e)
+{
+	if (!isDelayed()) {
+		QAbstractButton::mouseReleaseEvent(e);
+		return;
+	}
+
+	if (e->button() != Qt::LeftButton) {
+		return;
+	}
+
+	click();
+}
+
+QSize OBSToggleSwitch::sizeHint() const
+{
+	return QSize(2 * handleSize, handleSize);
+}

+ 121 - 0
frontend/components/idian/OBSToggleSwitch.hpp

@@ -0,0 +1,121 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QWidget>
+#include <QEvent>
+#include <QBrush>
+#include <QPropertyAnimation>
+#include <QPainter>
+#include <QMouseEvent>
+#include <QAbstractButton>
+#include <QStyleOptionButton>
+#include <QAccessibleWidget>
+
+#include "OBSIdianWidget.hpp"
+
+class OBSToggleSwitch : public QAbstractButton, public OBSIdianUtils {
+	Q_OBJECT
+	Q_PROPERTY(int xpos MEMBER xPos WRITE setPos)
+	Q_PROPERTY(QColor background MEMBER backgroundInactive DESIGNABLE true)
+	Q_PROPERTY(QColor background_hover MEMBER backgroundInactiveHover DESIGNABLE true)
+	Q_PROPERTY(QColor background_checked MEMBER backgroundActive DESIGNABLE true)
+	Q_PROPERTY(QColor background_checked_hover MEMBER backgroundActiveHover DESIGNABLE true)
+	Q_PROPERTY(QColor handleColor MEMBER handleColor DESIGNABLE true)
+	Q_PROPERTY(int handleSize MEMBER handleSize DESIGNABLE true)
+	Q_PROPERTY(float blend MEMBER blend WRITE setBlend DESIGNABLE false)
+
+public:
+	OBSToggleSwitch(QWidget *parent = nullptr);
+	OBSToggleSwitch(bool defaultState, QWidget *parent = nullptr);
+
+	QSize sizeHint() const override;
+
+	void setPos(int x)
+	{
+		xPos = x;
+		update();
+	}
+
+	void setBlend(float newBlend)
+	{
+		blend = newBlend;
+		update();
+	}
+
+	void setDelayed(bool state);
+	bool isDelayed() { return delayed; }
+
+	void setStatus(bool status);
+	void setPending(bool pending);
+
+public slots:
+	void click();
+
+signals:
+	void pendingChecked();
+	void pendingUnchecked();
+
+protected:
+	void changeEvent(QEvent *event) override;
+	void paintEvent(QPaintEvent *) override;
+	void showEvent(QShowEvent *) override;
+	void enterEvent(QEnterEvent *) override;
+	void leaveEvent(QEvent *) override;
+	void keyReleaseEvent(QKeyEvent *) override;
+	void mouseReleaseEvent(QMouseEvent *) override;
+
+	void focusInEvent(QFocusEvent *e) override
+	{
+		OBSIdianUtils::showKeyFocused(e);
+		QAbstractButton::focusInEvent(e);
+	}
+
+	void focusOutEvent(QFocusEvent *e) override
+	{
+		OBSIdianUtils::hideKeyFocused(e);
+		QAbstractButton::focusOutEvent(e);
+	}
+
+private slots:
+	void onClicked(bool checked);
+
+private:
+	int xPos;
+	int onPos;
+	int offPos;
+	int margin;
+
+	float blend = 0.0f;
+
+	bool delayed = false;
+	bool pendingStatus = false;
+
+	void animateHandlePosition();
+
+	void updateBackgroundColor();
+	QColor backgroundInactive;
+	QColor backgroundInactiveHover;
+	QColor backgroundActive;
+	QColor backgroundActiveHover;
+	QColor handleColor;
+	int handleSize = 18;
+
+	QPropertyAnimation *animHandle = nullptr;
+	QPropertyAnimation *animBgColor = nullptr;
+};

+ 37 - 0
frontend/components/idian/obs-widgets.hpp

@@ -0,0 +1,37 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+// Idian - A family of custom widgets for OBS implementing the "Yami" UI design.
+//
+// (OBS Idian, get it?)
+
+#include "OBSActionRow.hpp"
+#include "OBSCheckBox.hpp"
+#include "OBSComboBox.hpp"
+#include "OBSDoubleSpinBox.hpp"
+#include "OBSGroupBox.hpp"
+#include "OBSPropertiesList.hpp"
+#include "OBSSpinBox.hpp"
+#include "OBSToggleSwitch.hpp"
+
+/// Note: This file serves as an all-in-one include for custom OBS widgets.
+///       It is not intended to define any widgets by itself.
+
+/// Note 2: These widgets are still heavily work in progress. They should not
+///         yet be used outside of the demo and scene collection dialogues.

+ 105 - 0
frontend/data/themes/System.obt

@@ -379,3 +379,108 @@ QCalendarWidget #qt_calendar_nextmonth {
 StatusBarWidget > QFrame {
     padding: 0px 12px 8px;
 }
+
+OBSToggleSwitch {
+    qproperty-handle: rgb(255, 255, 255);
+    qproperty-backgroundActive: #284cb8;
+    qproperty-backgroundInactive: #3c404b;
+}
+
+OBSGroupBox {
+    border-radius: 4px;
+    font-weight: bold;
+}
+
+OBSGroupBox > QLabel.title {
+    font-weight: bold;
+}
+
+OBSGroupBox > QLabel.subtitle {
+    color: palette(button-text);
+}
+
+OBSPropertiesList {
+    background: palette(base);
+    border-radius: 4px;
+    border-width: 0px;
+    padding: 0px;
+    margin: 0px;
+}
+
+OBSActionRow {
+    margin: 0;
+    padding: 0;
+    border-left: 3px solid transparent;
+    min-height: 48px;
+    background: transparent;
+}
+
+OBSActionRow:hover {
+    background: palette(highlight);
+    border-left: 0;
+}
+
+OBSActionRow[last="true"]:hover {
+    border-bottom-left-radius: 4px;
+    border-bottom-right-radius: 4px;
+}
+
+OBSActionRow[first="true"]:hover {
+    border-top-left-radius: 4px;
+    border-top-right-radius: 4px;
+}
+
+OBSActionRow > QLabel {
+    font-weight: 500;
+}
+
+OBSActionRow > QLabel.subtitle {
+    font-size: 9pt;
+    color: palette(button-text);
+}
+
+OBSActionRow QComboBox,
+OBSActionRow QPushButton {
+    margin: 0;
+}
+
+OBSActionRow QComboBox,
+OBSActionRow QComboBox:hover,
+OBSActionRow QComboBox:selected,
+OBSActionRow QComboBox::on {
+    background: transparent;
+}
+
+OBSActionRow QComboBox::drop-down {
+    border: none;
+}
+
+OBSActionRow QComboBox::down-arrow {
+    image: url(./Light/collapse.svg);
+}
+
+OBSPropertiesListSpacer {
+    max-height: 1px;
+    min-height: 1px;
+    background-color: palette(midlight);
+}
+
+OBSCollapsibleActionRow {
+    margin: 0;
+    padding: 0;
+    border: none;
+}
+
+OBSCollapsibleActionRow OBSPropertiesList {
+    border-radius: 0;
+    background: palette(mid);
+}
+
+IdianPlayground QVBoxLayout {
+    background: palette(midlight);
+    border-radius: 4px;
+    padding-top: 32px;
+    padding-bottom: 8px;
+    font-weight: bold;
+    margin-bottom: 6px;
+}

+ 315 - 11
frontend/data/themes/Yami.obt

@@ -97,6 +97,9 @@
     --padding_base_value: var(--obsPadding);
     --spacing_base_value: calc(2 + calc(var(--obsPadding) / 2));
 
+    --highlight_width: 1px;
+    --highlight_color: var(--primary_lighter); 
+    
     /* TODO: Better Accessibility focus state */
     /* TODO: Move Accessibilty Colors to Theme config system */
     --border_highlight: "transparent";
@@ -172,6 +175,7 @@
     --list_item_bg_hover: var(--primary_light);
 
     --input_border: var(--grey1);
+    --input_border_width: 1px;
     --input_border_hover: var(--grey1);
     --input_border_focus: var(--primary);
 
@@ -182,6 +186,7 @@
     --button_bg_down: var(--grey7);
     --button_bg_disabled: var(--grey6);
 
+    --button_border_width: var(--input_border_width);
     --button_border: var(--button_bg);
     --button_border_hover: var(--grey1);
     --button_border_focus: var(--grey1);
@@ -193,7 +198,7 @@
 
     --tab_border: var(--border_color);
     --tab_border_hover: var(--button_border_hover);
-    --tab_border_focus: var(--button_border_focus);
+    --tab_border_focus: var(--primary_lighter);
     --tab_border_selected: var(--primary);
 
     --tab_padding_base: calc(5px + var(--padding_base));
@@ -203,8 +208,22 @@
 
     --separator_hover: var(--white1);
 
-    --highlight: rgb(42, 130, 218);
-    --highlight_inactive: rgb(25, 28, 34);
+    --action_row_base: calc(var(--input_height_base) * 0.75);
+    --action_row_height: calc(var(--action_row_base) + calc(var(--action_row_padding) * 2));
+    --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);
+
+    --toggle_border: 1;
+    --toggle_margin: 3;
+    --toggle_width: calc(var(--action_row_base) * 1.8);
+    --toggle_height: calc(var(--action_row_base) * 0.9);
+    --toggle_handle: calc(calc(calc(var(--toggle_height) * 0.9) - calc(var(--toggle_border) * 2)) - var(--toggle_margin));
+    --toggle_radius: calc(var(--toggle_height) / 2);
 
     /* Qt Palette variables can be set with the "palette_" prefix */
     --palette_window: var(--bg_window);
@@ -538,6 +557,7 @@ QListView,
 QListWidget,
 QMenu {
     padding: var(--spacing_base);
+    outline: none;
 }
 
 QMenu {
@@ -588,6 +608,7 @@ QMenu::item:selected,
 QListView::item:selected,
 QListWidget::item:selected {
     background-color: var(--primary);
+    border-color: var(--primary);
 }
 
 QMenu::item:hover,
@@ -597,7 +618,7 @@ QMenu::item:selected:hover,
 QListView::item:selected:hover,
 QListWidget::item:selected:hover {
     background-color: var(--primary_light);
-    color: var(--text);
+    border-color: var(--highlight_color);
 }
 
 QMenu::item:focus,
@@ -622,7 +643,7 @@ QListWidget QLineEdit {
     padding: 0;
     padding-bottom: 1px;
     margin: 0;
-    border: 1px solid var(--white1);
+    border: var(--input_border_width) solid var(--white1);
     border-radius: var(--border_radius);
 }
 
@@ -937,6 +958,10 @@ QTabWidget::pane {
     border-top: 4px solid var(--tab_bg);
 }
 
+QTabBar {
+    outline: none;
+}
+
 QTabWidget::tab-bar {
     alignment: left;
 }
@@ -1124,7 +1149,7 @@ QTextEdit:!editable:focus {
 QSpinBox,
 QDoubleSpinBox {
     background-color: var(--input_bg);
-    border: 1px solid var(--input_bg);
+    border: var(--input_border_width) solid var(--input_bg);
     border-radius: var(--border_radius);
     padding: var(--input_padding) var(--input_text_padding);
     height: var(--input_height);
@@ -1446,7 +1471,7 @@ QSlider::handle:disabled {
     background-color: var(--button_bg);
     padding: var(--padding_base_border) var(--padding_base_border);
     margin: 0px;
-    border: 1px solid var(--button_border);
+    border: var(--highlight_width) solid var(--button_border);
     border-radius: var(--border_radius);
     icon-size: var(--icon_base);
 }
@@ -1702,8 +1727,8 @@ QTableView::indicator:unchecked:disabled {
     max-height: var(--icon_base);
     padding: var(--padding_base);
     margin-right: var(--spacing_large);
-    border: 1px solid transparent;
-    border-radius: 4px;
+    border: var(--highlight_width) solid transparent;
+    border-radius: var(--border_radius);
 }
 
 .checkbox-icon::indicator {
@@ -1753,7 +1778,7 @@ QTableView::indicator:unchecked:disabled {
     background-color: var(--button_bg);
     padding: var(--padding_base_border) var(--padding_base_border);
     margin: 0px;
-    border: 1px solid var(--button_border);
+    border: var(--highlight_width) solid var(--button_border);
     border-radius: var(--border_radius);
     icon-size: var(--icon_base);
 }
@@ -1763,7 +1788,7 @@ QTableView::indicator:unchecked:disabled {
     background-color: var(--button_bg_hover);
     padding: var(--padding_base_border) var(--padding_base_border);
     margin: 0px;
-    border: 1px solid var(--button_border_hover);
+    border: var(--highlight_width) solid var(--button_border_hover);
     icon-size: var(--icon_base);
 }
 
@@ -2153,3 +2178,282 @@ OBSBasicAdvAudio #scrollAreaWidgetContents {
 #previewZoomOutButton:focus {
     border: 1px solid var(--input_border_hover);
 }
+
+/* Idian Widgets */
+OBSGroupBox {
+    border-radius: var(--border_radius);
+    font-weight: bold;
+    margin: 0 0 var(--spacing_base);
+    min-width: 300px;
+    max-width: 600px;
+}
+
+OBSGroupBox .header .title {
+    font-weight: bold;
+    padding: var(--padding_large) 0;
+}
+
+OBSGroupBox .header .description {
+    color: var(--text_muted);
+    padding: var(--spacing_small) 0;
+}
+
+OBSPropertiesList {
+    border-width: 0;
+    padding: 0;
+    margin: var(--spacing_base) 0;
+}
+
+OBSActionRowWidget {
+    background: var(--grey5);
+    margin: 0;
+    padding: var(--action_row_padding) var(--action_row_padding_x);
+}
+
+OBSActionRowWidget.keyFocus {
+    background: var(--grey4);
+    border: var(--highlight_width) solid var(--grey4);
+}
+
+OBSActionRowWidget.cursor-pointer.hover {
+    background: var(--grey4);
+    border: var(--highlight_width) solid var(--grey1);
+}
+
+OBSActionRowWidget.first {
+    border-top-left-radius: var(--border_radius);
+    border-top-right-radius: var(--border_radius);
+}
+
+OBSActionRowWidget.last {
+    border-bottom-left-radius: var(--border_radius);
+    border-bottom-right-radius: var(--border_radius);
+}
+
+OBSActionRowWidget > QWidget QLabel {
+    background: red;
+    margin: 4px;
+    font-weight: 500;
+    max-height: var(--input_height);
+}
+
+OBSActionRowWidget > QLabel.description {
+    font-size: var(--font_small);
+    color: var(--text_muted);
+}
+
+OBSToggleSwitch {
+    qproperty-background: var(--grey6);
+    qproperty-background_hover: var(--grey7);
+    qproperty-background_checked: var(--primary);
+    qproperty-background_checked_hover: var(--primary_light);
+
+    min-width: var(--toggle_width);
+    min-height: var(--toggle_height);
+
+    border-radius: var(--toggle_radius);
+
+    qproperty-handleColor: var(--white1);
+    qproperty-handleSize: var(--toggle_handle);
+
+    border: var(--highlight_width) solid transparent;
+}
+
+OBSToggleSwitch:hover {
+    border-color: var(--grey4);
+}
+
+OBSToggleSwitch:checked:hover {
+    border-color: var(--white1);
+}
+
+OBSToggleSwitch.keyFocus {
+    border-color: var(--highlight_color);
+}
+
+OBSActionRowWidget OBSToggleSwitch:hover,
+OBSActionRowWidget.hover > OBSToggleSwitch.row-buddy {
+    border-color: var(--grey1);
+}
+
+OBSActionRowWidget OBSToggleSwitch:checked:hover,
+OBSActionRowWidget.hover OBSToggleSwitch.row-buddy:checked {
+    border-color: var(--white1);
+}
+
+OBSActionRowWidget QComboBox {
+    background-color: transparent;
+    min-height: var(--action_row_base);
+    max-height: var(--action_row_base);
+    min-width: var(--action_row_input_width);
+    border: var(--highlight_width) solid transparent;
+    padding: 0;
+    padding-left: var(--padding_xlarge);
+    margin: 0;
+}
+
+OBSActionRowWidget QComboBox:focus {
+    border-color: transparent;
+}
+
+OBSActionRowWidget QComboBox:hover {
+    border-color: var(--grey1);
+}
+
+OBSActionRowWidget QComboBox.keyFocus {
+    border-color: var(--highlight_color);
+}
+
+OBSActionRowWidget QComboBox::drop-down {
+    border: none;
+}
+
+OBSActionRowWidget QComboBox::down-arrow {
+    image: url(theme:Dark/collapse.svg);
+}
+
+OBSActionRowWidget QComboBox QAbstractItemView {
+    outline: none;
+}
+
+OBSActionRowWidget QComboBox QAbstractItemView::item {
+    background-color: var(--bg_base);
+    padding: var(--padding_base) var(--padding_large);
+}
+
+OBSActionRowWidget QComboBox QAbstractItemView::item:hover,
+OBSActionRowWidget QComboBox QAbstractItemView::item:selected {
+    background-color: var(--list_item_bg_selected);
+    padding: var(--padding_base) var(--padding_large);
+}
+
+OBSActionRowWidget QPushButton,
+OBSActionRowWidget QSpinBox,
+OBSActionRowWidget QDoubleSpinBox {
+    margin: 0;
+    padding: var(--padding_base) var(--action_row_padding_x);
+}
+
+OBSPropertiesListSpacer {
+    max-height: var(--spacing_small);
+    min-height: var(--spacing_small);
+    background-color: var(--bg_window);
+}
+
+OBSActionRowWidget OBSCheckBox {
+    outline: none;
+}
+
+OBSActionRowWidget OBSCheckBox::indicator,
+OBSActionRowWidget OBSCheckBox::indicator:unchecked:hover {
+    border: var(--highlight_width) solid transparent;
+    border-radius: var(--border_radius);
+}
+
+OBSActionRowWidget.hover > OBSCheckBox.row-buddy::indicator,
+OBSActionRowWidget > OBSCheckBox::indicator:unchecked:hover,
+OBSActionRowWidget > OBSCheckBox::indicator:hover {
+    border-color: var(--grey1);
+}
+
+OBSActionRowWidget.hover > OBSCheckBox.row-buddy::indicator:unchecked,
+OBSCheckBox.keyFocus::indicator:unchecked {
+    image: url(theme:Yami/checkbox_unchecked_focus.svg);
+}
+
+OBSActionRowWidget OBSCheckBox.keyFocus::indicator,
+OBSActionRowWidget.hover > OBSCheckBox::indicator {
+    image: url(theme:Yami/checkbox_checked_focus.svg);
+}
+
+OBSActionRowWidget OBSCheckBox.keyFocus::indicator,
+OBSActionRowWidget OBSCheckBox.keyFocus::indicator:unchecked,
+OBSActionRowWidget OBSCheckBox.keyFocus::indicator:hover,
+OBSActionRowWidget OBSCheckBox.keyFocus::indicator:unchecked:hover {
+    border-color: var(--highlight_color);
+}
+
+OBSCollapsibleRowWidget {
+    margin: 0;
+    padding: 0;
+    border: none;
+}
+
+OBSCollapsibleRowWidget.keyFocus {
+    border: var(--highlight_width) solid var(--highlight_color);
+}
+
+OBSCollapsibleRowWidget OBSPropertiesList {
+    border-radius: 0;
+    border-left: 1px solid var(--grey5);
+    border-right: 1px solid var(--grey5);
+    border-bottom: 1px solid var(--grey5);
+    margin: var(--spacing_small) 0px 0px;
+}
+
+OBSCollapsibleRowWidget OBSPropertiesList OBSActionRowWidget {
+    background-color: var(--grey6);
+    padding-left: var(--action_row_padding_nested);
+}
+
+OBSCollapsibleRowWidget OBSActionRowWidget.first,
+OBSCollapsibleRowWidget OBSActionRowWidget.last {
+    border-radius: 0;
+}
+
+OBSCollapsibleRowWidget OBSPropertiesList OBSToggleSwitch {
+    qproperty-background: var(--grey7);
+    qproperty-background_hover: var(--grey6);
+}
+
+OBSActionRowExpandButton {
+    background: transparent;
+    min-width: var(--action_row_collapse);
+    max-width: var(--action_row_collapse);
+    min-height: var(--action_row_collapse);
+    max-height: var(--action_row_collapse);
+    border: none;
+}
+
+OBSActionRowExpandButton::indicator {
+    background: var(--grey5);
+    border-radius: var(--action_row_collapse_radius);
+    padding: var(--padding_large);
+    image: url(theme:Dark/down.svg);
+    border: var(--highlight_width) solid var(--grey5);
+}
+
+OBSActionRowExpandButton::indicator:checked {
+    image: url(theme:Dark/up.svg);
+}
+
+OBSActionRowExpandButton.keyFocus,
+OBSActionRowExpandButton.keyFocus::indicator {
+    border-color: var(--highlight_color);
+}
+
+OBSCollapsibleRowFrame .btn-frame {
+    background: var(--grey5);
+    padding: var(--action_row_padding) var(--action_row_padding_x);
+}
+
+OBSCollapsibleRowFrame.hover .btn-frame {
+    background: var(--grey4);
+}
+
+OBSCollapsibleRowFrame.hover OBSActionRowWidget,
+OBSCollapsibleRowFrame.hover OBSActionRowWidget.hover {
+    background: var(--grey4);
+    border: 2px solid var(--grey1);
+    border-right: none;
+}
+
+OBSCollapsibleRowFrame.hover .row-buddy {
+    background: var(--grey4);
+    border: 2px solid var(--grey1);
+    border-left: none;
+}
+
+OBSCollapsibleRowFrame.hover OBSActionRowExpandButton::indicator {
+    border-color: var(--grey1);
+}

+ 132 - 0
frontend/dialogs/OBSIdianPlayground.cpp

@@ -0,0 +1,132 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "OBSIdianPlayground.hpp"
+
+#include "components/idian/obs-widgets.hpp"
+
+#include <QTimer>
+
+OBSIdianPlayground::OBSIdianPlayground(QWidget *parent) : QDialog(parent), ui(new Ui_OBSIdianPlayground)
+{
+	ui->setupUi(this);
+
+	setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Minimum);
+
+	OBSGroupBox *test;
+	OBSActionRowWidget *tmp;
+
+	OBSComboBox *cbox = new OBSComboBox;
+	cbox->addItem("Test 1");
+	cbox->addItem("Test 2");
+
+	// Group box 1
+	test = new OBSGroupBox(this);
+
+	tmp = new OBSActionRowWidget();
+	tmp->setTitle("Row with a dropdown");
+	tmp->setSuffix(cbox);
+	test->properties()->addRow(tmp);
+
+	cbox = new OBSComboBox;
+	cbox->addItem("Test 3");
+	cbox->addItem("Test 4");
+	tmp = new OBSActionRowWidget();
+	tmp->setTitle("Row with a dropdown");
+	tmp->setDescription("And a subtitle!");
+	tmp->setSuffix(cbox);
+	test->properties()->addRow(tmp);
+
+	tmp = new OBSActionRowWidget();
+	tmp->setTitle("Toggle Switch");
+	tmp->setSuffix(new OBSToggleSwitch());
+	test->properties()->addRow(tmp);
+	ui->scrollAreaWidgetContents->layout()->addWidget(test);
+
+	tmp = new OBSActionRowWidget();
+	tmp->setTitle("Delayed toggle switch");
+	tmp->setDescription("The state can be set separately");
+	auto tswitch = new OBSToggleSwitch;
+	tswitch->setDelayed(true);
+	connect(tswitch, &OBSToggleSwitch::pendingChecked, this, [=]() {
+		// Do async enable stuff, then set toggle status when complete
+		QTimer::singleShot(1000, [=]() { tswitch->setStatus(true); });
+	});
+	connect(tswitch, &OBSToggleSwitch::pendingUnchecked, this, [=]() {
+		// Do async disable stuff, then set toggle status when complete
+		QTimer::singleShot(1000, [=]() { tswitch->setStatus(false); });
+	});
+	tmp->setSuffix(tswitch);
+	test->properties()->addRow(tmp);
+
+	// Group box 2
+	test = new OBSGroupBox();
+	test->setTitle("Just a few checkboxes");
+
+	tmp = new OBSActionRowWidget();
+	tmp->setTitle("Box 1");
+	tmp->setPrefix(new OBSCheckBox);
+	test->properties()->addRow(tmp);
+
+	tmp = new OBSActionRowWidget();
+	tmp->setTitle("Box 2");
+	tmp->setPrefix(new OBSCheckBox);
+	test->properties()->addRow(tmp);
+
+	ui->scrollAreaWidgetContents->layout()->addWidget(test);
+
+	// Group box 2
+	test = new OBSGroupBox();
+	test->setTitle("Another Group");
+	test->setDescription("With a subtitle");
+
+	tmp = new OBSActionRowWidget();
+	tmp->setTitle("Placeholder");
+	tmp->setSuffix(new OBSToggleSwitch);
+	test->properties()->addRow(tmp);
+
+	OBSCollapsibleRowWidget *tmp2 = new OBSCollapsibleRowWidget("A Collapsible row!", this);
+	tmp2->setCheckable(true);
+	test->addRow(tmp2);
+
+	tmp = new OBSActionRowWidget();
+	tmp->setTitle("Spin box demo");
+	tmp->setSuffix(new OBSDoubleSpinBox());
+	tmp2->addRow(tmp);
+
+	tmp = new OBSActionRowWidget();
+	tmp->setTitle("Just another placeholder");
+	tmp->setSuffix(new OBSToggleSwitch(true));
+	tmp2->addRow(tmp);
+
+	tmp = new OBSActionRowWidget();
+	tmp->setTitle("Placeholder 2");
+	tmp->setSuffix(new OBSToggleSwitch);
+	test->properties()->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()->setAlignment(Qt::AlignTop | Qt::AlignHCenter);
+
+	// Test Checkable Group
+	OBSGroupBox *test2 = new OBSGroupBox();
+	test2->setTitle("Checkable Group");
+	test2->setDescription("Description goes here");
+	test2->setCheckable(true);
+	ui->scrollAreaWidgetContents->layout()->addWidget(test2);
+}

+ 36 - 0
frontend/dialogs/OBSIdianPlayground.hpp

@@ -0,0 +1,36 @@
+/******************************************************************************
+    Copyright (C) 2023 by Dennis Sädtler <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include "components/idian/obs-widgets.hpp"
+
+#include <QDialog>
+
+#include <memory>
+
+#include <ui_OBSIdianPlayground.h>
+
+// QDialog including a bunch of custom widgets for demoing
+class OBSIdianPlayground : public QDialog {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_OBSIdianPlayground> ui;
+
+public:
+	OBSIdianPlayground(QWidget *parent);
+};

+ 6 - 0
frontend/forms/OBSBasic.ui

@@ -937,6 +937,7 @@
      <string>Basic.MainMenu.Tools</string>
     </property>
     <addaction name="autoConfigure"/>
+    <addaction name="widgetPlayground"/>
     <addaction name="separator"/>
    </widget>
    <widget class="QMenu" name="menuDocks">
@@ -2165,6 +2166,11 @@
     <string>PasteDuplicate</string>
    </property>
   </action>
+  <action name="widgetPlayground">
+   <property name="text">
+    <string>Widget Playground</string>
+   </property>
+  </action>
   <action name="autoConfigure2">
    <property name="text">
     <string>Basic.AutoConfig</string>

+ 55 - 0
frontend/forms/OBSIdianPlayground.ui

@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>OBSIdianPlayground</class>
+ <widget class="QDialog" name="OBSIdianPlayground">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>700</width>
+    <height>700</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Idian Playground</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QScrollArea" name="scrollArea">
+     <property name="frameShape">
+      <enum>QFrame::NoFrame</enum>
+     </property>
+     <property name="widgetResizable">
+      <bool>true</bool>
+     </property>
+     <widget class="QWidget" name="scrollAreaWidgetContents">
+      <property name="geometry">
+       <rect>
+        <x>0</x>
+        <y>0</y>
+        <width>786</width>
+        <height>919</height>
+       </rect>
+      </property>
+      <layout class="QVBoxLayout" name="scrollContentsLayout">
+       <property name="leftMargin">
+        <number>0</number>
+       </property>
+       <property name="topMargin">
+        <number>0</number>
+       </property>
+       <property name="rightMargin">
+        <number>0</number>
+       </property>
+       <property name="bottomMargin">
+        <number>9</number>
+       </property>
+      </layout>
+     </widget>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>

+ 5 - 0
frontend/forms/images/hide.svg

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="100%" height="100%" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
+    <path d="M3,5L3,6C3,6.277 3.113,6.527 3.293,6.707L8,11.414L12.707,6.707C12.887,6.527 13,6.277 13,6L13,5L12,5C11.723,5 11.473,5.113 11.293,5.293L8,8.586L4.707,5.293C4.527,5.113 4.277,5 4,5L3,5Z" style="fill:rgb(162,162,162);fill-rule:nonzero;"/>
+</svg>

+ 1 - 0
frontend/forms/obs.qrc

@@ -30,6 +30,7 @@
     <file>images/visible.svg</file>
     <file>images/help.svg</file>
     <file>images/help_light.svg</file>
+    <file>images/hide.svg</file>
     <file>images/revert.svg</file>
     <file>images/alert.svg</file>
     <file>images/warning.svg</file>

+ 4 - 0
frontend/widgets/OBSBasic.cpp

@@ -422,6 +422,10 @@ OBSBasic::OBSBasic(QWidget *parent) : OBSMainWindow(parent), undo_s(ui), ui(new
 	ui->actionE_xit->setShortcut(Qt::CTRL | Qt::Key_Q);
 #endif
 
+#ifndef ENABLE_WIDGET_PLAYGROUND
+	ui->widgetPlayground->setVisible(false);
+#endif
+
 	auto addNudge = [this](const QKeySequence &seq, MoveDir direction, int distance) {
 		QAction *nudge = new QAction(ui->preview);
 		nudge->setShortcut(seq);

+ 1 - 0
frontend/widgets/OBSBasic.hpp

@@ -630,6 +630,7 @@ private slots:
 
 	void on_autoConfigure_triggered();
 	void on_stats_triggered();
+	void on_widgetPlayground_triggered();
 
 	void on_resetUI_triggered();
 

+ 14 - 0
frontend/widgets/OBSBasic_MainControls.cpp

@@ -42,6 +42,10 @@
 #endif
 #include <wizards/AutoConfig.hpp>
 
+#ifdef ENABLE_WIDGET_PLAYGROUND
+#include "dialogs/OBSIdianPlayground.hpp"
+#endif
+
 #include <qt-wrappers.hpp>
 
 #include <QDesktopServices>
@@ -635,6 +639,16 @@ void OBSBasic::on_stats_triggered()
 	stats = statsDlg;
 }
 
+void OBSBasic::on_widgetPlayground_triggered()
+{
+#ifdef ENABLE_WIDGET_PLAYGROUND
+	OBSIdianPlayground playground(this);
+	playground.setModal(true);
+	playground.show();
+	playground.exec();
+#endif
+}
+
 void OBSBasic::on_actionShowAbout_triggered()
 {
 	if (about)