Browse Source

UI: Add grouping

jp9000 7 years ago
parent
commit
88b6c63964

+ 3 - 0
UI/CMakeLists.txt

@@ -152,6 +152,7 @@ set(obs_SOURCES
 	window-log-reply.cpp
 	window-projector.cpp
 	window-remux.cpp
+	source-tree.cpp
 	properties-view.cpp
 	focus-list.cpp
 	menu-button.cpp
@@ -198,6 +199,7 @@ set(obs_HEADERS
 	window-log-reply.hpp
 	window-projector.hpp
 	window-remux.hpp
+	source-tree.hpp
 	properties-view.hpp
 	properties-view.moc.hpp
 	display-helpers.hpp
@@ -211,6 +213,7 @@ set(obs_HEADERS
 	visibility-checkbox.hpp
 	locked-checkbox.hpp
 	horizontal-scroll-area.hpp
+	expand-checkbox.hpp
 	vertical-scroll-area.hpp
 	visibility-item-widget.hpp
 	slider-absoluteset-style.hpp

+ 4 - 0
UI/data/locale/en-US.ini

@@ -84,6 +84,7 @@ StudioMode.Preview="Preview"
 StudioMode.Program="Program"
 ShowInMultiview="Show in Multiview"
 VerticalLayout="Vertical Layout"
+Group="Group"
 
 # warning if program already open
 AlreadyRunning.Title="OBS is already running"
@@ -458,6 +459,9 @@ Basic.Main.StoppingReplayBuffer="Stopping Replay Buffer..."
 Basic.Main.StopStreaming="Stop Streaming"
 Basic.Main.StoppingStreaming="Stopping Stream..."
 Basic.Main.ForceStopStreaming="Stop Streaming (discard delay)"
+Basic.Main.Group="Group %1"
+Basic.Main.GroupItems="Group Selected Items"
+Basic.Main.Ungroup="Ungroup"
 
 # basic mode file menu
 Basic.MainMenu.File="&File"

+ 47 - 12
UI/data/themes/Acri.qss

@@ -94,7 +94,8 @@ QMenuBar::item:selected {
 }
 
 /* Listbox item */
-QListWidget::item {
+QListWidget::item,
+SourceTree::item {
 	padding: 4px 2px;
 	margin-bottom: 2px;
 	margin-top: 0px;
@@ -110,11 +111,6 @@ QListWidget QLineEdit {
 	 border-radius: none;
 }
 
-SourceListWidget::item {
-	margin-bottom: 1px;
-	padding: -4px 2px;
-}
-
 /* Dock stuff */
 QDockWidget {
 	background: transparent;
@@ -157,6 +153,23 @@ SourceListWidget {
 	border-bottom: 2px solid #2f2f2f;
 }
 
+SourceTree {
+	border: none;
+	border-bottom: 1px solid #2f2f2f;
+}
+
+SourceTree QLabel {
+	padding: 2px 0px;
+	margin: -2px 4px -2px;
+}
+
+SourceTree QLineEdit {
+	background-color: #0c101e;
+	padding: 2px;
+	margin: -2px 6px -2px 3px;
+	font-size: 12px;
+}
+
 #scenesFrame,
 #sourcesFrame {
 	margin-left: -7px;
@@ -179,13 +192,15 @@ SourceListWidget {
 }
 
 /* Listbox item selected, unfocused */
-QListWidget::item:hover {
+QListWidget::item:hover,
+SourceTree::item:hover {
 	background-color: #212121;
 	border: 1px solid #333336;
 }
 
 /* Listbox item selected */
-QListWidget::item:selected {
+QListWidget::item:selected,
+SourceTree::item:selected {
 	background-color: #131a30;
 	border: 1px solid #252a45;
 }
@@ -727,6 +742,30 @@ OBSHotkeyLabel[hotkeyPairHover=true] {
 }
 
 
+/* Group Collapse Checkbox */
+
+SourceTreeSubItemCheckBox {
+    background: transparent;
+    outline: none;
+    padding: 0px;
+}
+
+SourceTreeSubItemCheckBox::indicator {
+    width: 12px;
+    height: 12px;
+}
+
+SourceTreeSubItemCheckBox::indicator:checked,
+SourceTreeSubItemCheckBox::indicator:checked:hover {
+    image: url(./Dark/expand.png);
+}
+
+SourceTreeSubItemCheckBox::indicator:unchecked,
+SourceTreeSubItemCheckBox::indicator:unchecked:hover {
+    image: url(./Dark/collapse.png);
+}
+
+
 /* Label warning/error */
 
 QLabel#warningLabel {
@@ -753,10 +792,6 @@ OBSBasicProperties,
 	background: #101010;
 }
 
-#OBSBasicSourceSelect #sourceList {
-	border-bottom: 2px solid #333336;
-}
-
 FocusList::item {
 	padding: 0px 2px;
 }

+ 21 - 0
UI/data/themes/Dark.qss

@@ -551,6 +551,27 @@ OBSHotkeyLabel[hotkeyPairHover=true] {
 }
 
 
+/* Group Collapse Checkbox */
+
+SourceTreeSubItemCheckBox {
+    background: transparent;
+    outline: none;
+}
+
+SourceTreeSubItemCheckBox::indicator {
+    width: 10px;
+    height: 10px;
+}
+
+SourceTreeSubItemCheckBox::indicator:checked {
+    image: url(./Dark/expand.png);
+}
+
+SourceTreeSubItemCheckBox::indicator:unchecked {
+    image: url(./Dark/collapse.png);
+}
+
+
 /* Label warning/error */
 
 QLabel#warningLabel {

BIN
UI/data/themes/Dark/collapse.png


BIN
UI/data/themes/Dark/expand.png


+ 18 - 0
UI/data/themes/Default.qss

@@ -51,6 +51,24 @@ MuteCheckBox::indicator:unchecked {
     image: url(:/res/images/unmute.png);
 }
 
+SourceTreeSubItemCheckBox {
+    background: transparent;
+    outline: none;
+}
+
+SourceTreeSubItemCheckBox::indicator {
+    width: 10px;
+    height: 10px;
+}
+
+SourceTreeSubItemCheckBox::indicator:checked {
+    image: url(:/res/images/expand.png);
+}
+
+SourceTreeSubItemCheckBox::indicator:unchecked {
+    image: url(:/res/images/collapse.png);
+}
+
 OBSHotkeyLabel[hotkeyPairHover=true] {
     color: red;
 }

+ 24 - 0
UI/data/themes/Rachni.qss

@@ -701,6 +701,30 @@ MuteCheckBox::indicator:unchecked:disabled {
 	image: url(./Dark/unmute.png);
 }
 
+/****************************/
+/* --- Group Checkboxes --- */
+/****************************/
+
+SourceTreeSubItemCheckBox {
+    background: transparent;
+    outline: none;
+}
+
+SourceTreeSubItemCheckBox::indicator {
+    width: 10px;
+    height: 10px;
+}
+
+SourceTreeSubItemCheckBox::indicator:checked,
+SourceTreeSubItemCheckBox::indicator:checked:hover {
+    image: url(./Dark/expand.png);
+}
+
+SourceTreeSubItemCheckBox::indicator:unchecked,
+SourceTreeSubItemCheckBox::indicator:unchecked:hover {
+    image: url(./Dark/collapse.png);
+}
+
 /*************************/
 /* --- Progress bars --- */
 /*************************/

+ 5 - 0
UI/expand-checkbox.hpp

@@ -0,0 +1,5 @@
+#include <QCheckBox>
+
+class ExpandCheckBox : public QCheckBox {
+	Q_OBJECT
+};

+ 4 - 4
UI/forms/OBSBasic.ui

@@ -518,7 +518,7 @@
          <number>0</number>
         </property>
         <item>
-         <widget class="SourceListWidget" name="sources">
+         <widget class="SourceTree" name="sources">
           <property name="sizePolicy">
            <sizepolicy hsizetype="Preferred" vsizetype="Expanding">
             <horstretch>0</horstretch>
@@ -1682,9 +1682,9 @@
    <container>1</container>
   </customwidget>
   <customwidget>
-   <class>SourceListWidget</class>
-   <extends>QListWidget</extends>
-   <header>source-list-widget.hpp</header>
+   <class>SourceTree</class>
+   <extends>QListView</extends>
+   <header>source-tree.hpp</header>
   </customwidget>
  </customwidgets>
  <resources>

BIN
UI/forms/images/collapse.png


BIN
UI/forms/images/expand.png


+ 2 - 0
UI/forms/obs.qrc

@@ -17,6 +17,8 @@
     <file>images/tray_active.png</file>
     <file>images/locked_mask.png</file>
     <file>images/unlocked_mask.png</file>
+    <file>images/collapse.png</file>
+    <file>images/expand.png</file>
   </qresource>
   <qresource prefix="/settings">
     <file>images/settings/advanced.png</file>

+ 1286 - 0
UI/source-tree.cpp

@@ -0,0 +1,1286 @@
+#include "window-basic-main.hpp"
+#include "obs-app.hpp"
+#include "source-tree.hpp"
+#include "qt-wrappers.hpp"
+#include "visibility-checkbox.hpp"
+#include "locked-checkbox.hpp"
+#include "expand-checkbox.hpp"
+
+#include <obs-frontend-api.h>
+#include <obs.h>
+
+#include <string>
+
+#include <QLabel>
+#include <QLineEdit>
+#include <QSpacerItem>
+#include <QPushButton>
+#include <QVBoxLayout>
+#include <QHBoxLayout>
+#include <QMouseEvent>
+
+#include <QStylePainter>
+#include <QStyleOptionFocusRect>
+
+static inline OBSScene GetCurrentScene()
+{
+	OBSBasic *main = reinterpret_cast<OBSBasic*>(App()->GetMainWindow());
+	return main->GetCurrentScene();
+}
+
+/* ========================================================================= */
+
+SourceTreeItem::SourceTreeItem(SourceTree *tree_, OBSSceneItem sceneitem_)
+	: tree         (tree_),
+	  sceneitem    (sceneitem_)
+{
+	setAttribute(Qt::WA_TranslucentBackground);
+
+	obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+	const char *name = obs_source_get_name(source);
+
+	vis = new VisibilityCheckBox();
+	vis->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
+	vis->setMaximumSize(16, 16);
+	vis->setChecked(obs_sceneitem_visible(sceneitem));
+
+	lock = new LockedCheckBox();
+	lock->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
+	lock->setMaximumSize(16, 16);
+	lock->setChecked(obs_sceneitem_locked(sceneitem));
+
+	label = new QLabel(QT_UTF8(name));
+	label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+	label->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
+	label->setAttribute(Qt::WA_TranslucentBackground);
+
+	boxLayout = new QHBoxLayout();
+	boxLayout->setContentsMargins(1, 1, 2, 1);
+	boxLayout->setSpacing(1);
+	boxLayout->addWidget(label);
+	boxLayout->addWidget(vis);
+	boxLayout->addWidget(lock);
+
+	Update(false);
+
+	setLayout(boxLayout);
+
+	/* --------------------------------------------------------- */
+
+	auto setItemVisible = [this] (bool checked)
+	{
+		SignalBlocker sourcesSignalBlocker(this);
+		obs_sceneitem_set_visible(sceneitem, checked);
+	};
+
+	auto setItemLocked = [this] (bool checked)
+	{
+		SignalBlocker sourcesSignalBlocker(this);
+		obs_sceneitem_set_locked(sceneitem, checked);
+	};
+
+	connect(vis, &QAbstractButton::clicked, setItemVisible);
+	connect(lock, &QAbstractButton::clicked, setItemLocked);
+}
+
+void SourceTreeItem::DisconnectSignals()
+{
+	sceneRemoveSignal.Disconnect();
+	itemRemoveSignal.Disconnect();
+	visibleSignal.Disconnect();
+	renameSignal.Disconnect();
+	removeSignal.Disconnect();
+}
+
+void SourceTreeItem::ReconnectSignals()
+{
+	if (!sceneitem)
+		return;
+
+	DisconnectSignals();
+
+	/* --------------------------------------------------------- */
+
+	auto removeItem = [] (void *data, calldata_t *cd)
+	{
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem*>(data);
+		obs_sceneitem_t *curItem =
+			(obs_sceneitem_t*)calldata_ptr(cd, "item");
+
+		if (!curItem || curItem == this_->sceneitem) {
+			this_->DisconnectSignals();
+			this_->sceneitem = nullptr;
+		}
+	};
+
+	auto itemVisible = [] (void *data, calldata_t *cd)
+	{
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem*>(data);
+		obs_sceneitem_t *curItem =
+			(obs_sceneitem_t*)calldata_ptr(cd, "item");
+		bool visible = calldata_bool(cd, "visible");
+
+		if (curItem == this_->sceneitem)
+			QMetaObject::invokeMethod(this_, "VisibilityChanged",
+					Q_ARG(bool, visible));
+	};
+
+	obs_scene_t *scene = obs_sceneitem_get_scene(sceneitem);
+	obs_source_t *sceneSource = obs_scene_get_source(scene);
+	signal_handler_t *signal = obs_source_get_signal_handler(sceneSource);
+
+	sceneRemoveSignal.Connect(signal, "remove", removeItem, this);
+	itemRemoveSignal.Connect(signal, "item_remove", removeItem, this);
+	visibleSignal.Connect(signal, "item_visible", itemVisible, this);
+
+	/* --------------------------------------------------------- */
+
+	auto renamed = [] (void *data, calldata_t *cd)
+	{
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem*>(data);
+		const char *name = calldata_string(cd, "new_name");
+
+		QMetaObject::invokeMethod(this_, "Renamed",
+				Q_ARG(QString, QT_UTF8(name)));
+	};
+
+	auto removeSource = [] (void *data, calldata_t *)
+	{
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem*>(data);
+		this_->DisconnectSignals();
+		this_->sceneitem = nullptr;
+	};
+
+	obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+	signal = obs_source_get_signal_handler(source);
+	renameSignal.Connect(signal, "rename", renamed, this);
+	removeSignal.Connect(signal, "remove", removeSource, this);
+}
+
+void SourceTreeItem::mouseDoubleClickEvent(QMouseEvent *event)
+{
+	QWidget::mouseDoubleClickEvent(event);
+
+	if (expand) {
+		expand->setChecked(!expand->isChecked());
+	} else {
+		obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+		OBSBasic *main =
+			reinterpret_cast<OBSBasic*>(App()->GetMainWindow());
+		if (source) {
+			main->CreatePropertiesWindow(source);
+		}
+	}
+}
+
+void SourceTreeItem::EnterEditMode()
+{
+	setFocusPolicy(Qt::StrongFocus);
+	boxLayout->removeWidget(label);
+	editor = new QLineEdit(label->text());
+	editor->installEventFilter(this);
+	boxLayout->insertWidget(1, editor);
+	setFocusProxy(editor);
+}
+
+void SourceTreeItem::ExitEditMode(bool save)
+{
+	if (!editor)
+		return;
+
+	OBSBasic *main = reinterpret_cast<OBSBasic*>(App()->GetMainWindow());
+	OBSScene scene = main->GetCurrentScene();
+
+	std::string newName = QT_TO_UTF8(editor->text());
+
+	setFocusProxy(nullptr);
+	boxLayout->removeWidget(editor);
+	delete editor;
+	editor = nullptr;
+	setFocusPolicy(Qt::NoFocus);
+	boxLayout->insertWidget(1, label);
+
+	/* ----------------------------------------- */
+	/* check for empty string                    */
+
+	if (!save)
+		return;
+
+	if (newName.empty()) {
+		OBSMessageBox::information(main,
+			QTStr("NoNameEntered.Title"),
+			QTStr("NoNameEntered.Text"));
+		return;
+	}
+
+	/* ----------------------------------------- */
+	/* Check for same name                       */
+
+	obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+	if (newName == obs_source_get_name(source))
+		return;
+
+	/* ----------------------------------------- */
+	/* check for existing source                 */
+
+	bool exists = false;
+
+	if (obs_sceneitem_is_group(sceneitem)) {
+		exists = !!obs_scene_get_group(scene, newName.c_str());
+	} else {
+		obs_source_t *existingSource =
+			obs_get_source_by_name(newName.c_str());
+		obs_source_release(existingSource);
+		exists = !!existingSource;
+	}
+
+	if (exists) {
+		OBSMessageBox::information(main,
+			QTStr("NameExists.Title"),
+			QTStr("NameExists.Text"));
+		return;
+	}
+
+	/* ----------------------------------------- */
+	/* rename                                    */
+
+	SignalBlocker sourcesSignalBlocker(this);
+	obs_source_set_name(source, newName.c_str());
+	label->setText(QT_UTF8(newName.c_str()));
+}
+
+bool SourceTreeItem::eventFilter(QObject *object, QEvent *event)
+{
+	if (editor != object)
+		return false;
+
+	if (event->type() == QEvent::KeyPress) {
+		QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
+
+		switch (keyEvent->key()) {
+		case Qt::Key_Escape:
+			QMetaObject::invokeMethod(this, "ExitEditMode",
+					Qt::QueuedConnection,
+					Q_ARG(bool, false));
+			return true;
+		case Qt::Key_Tab:
+		case Qt::Key_Backtab:
+		case Qt::Key_Enter:
+		case Qt::Key_Return:
+			QMetaObject::invokeMethod(this, "ExitEditMode",
+					Qt::QueuedConnection,
+					Q_ARG(bool, true));
+			return true;
+		}
+	} else if (event->type() == QEvent::FocusOut) {
+		QMetaObject::invokeMethod(this, "ExitEditMode",
+				Qt::QueuedConnection,
+				Q_ARG(bool, false));
+		return true;
+	}
+
+	return false;
+}
+
+void SourceTreeItem::VisibilityChanged(bool visible)
+{
+	vis->setChecked(visible);
+}
+
+void SourceTreeItem::Renamed(const QString &name)
+{
+	label->setText(name);
+}
+
+void SourceTreeItem::Update(bool force)
+{
+	OBSScene scene = GetCurrentScene();
+	obs_scene_t *itemScene = obs_sceneitem_get_scene(sceneitem);
+
+	Type newType;
+
+	/* ------------------------------------------------- */
+	/* if it's a group item, insert group checkbox       */
+
+	if (obs_sceneitem_is_group(sceneitem)) {
+		newType = Type::Group;
+
+	/* ------------------------------------------------- */
+	/* if it's a group sub-item                          */
+
+	} else if (itemScene != scene) {
+		newType = Type::SubItem;
+
+	/* ------------------------------------------------- */
+	/* if it's a regular item                            */
+
+	} else {
+		newType = Type::Item;
+	}
+
+	/* ------------------------------------------------- */
+
+	if (!force && newType == type) {
+		return;
+	}
+
+	/* ------------------------------------------------- */
+
+	ReconnectSignals();
+
+	if (spacer) {
+		boxLayout->removeItem(spacer);
+		delete spacer;
+		spacer = nullptr;
+	}
+
+	if (type == Type::Group) {
+		boxLayout->removeWidget(expand);
+		expand->deleteLater();
+		expand = nullptr;
+	}
+
+	type = newType;
+
+	if (type == Type::SubItem) {
+		spacer = new QSpacerItem(16, 1);
+		boxLayout->insertItem(0, spacer);
+
+	} else if (type == Type::Group) {
+		expand = new SourceTreeSubItemCheckBox();
+		expand->setSizePolicy(
+				QSizePolicy::Maximum,
+				QSizePolicy::Maximum);
+		expand->setMaximumSize(10, 16);
+		expand->setMinimumSize(10, 0);
+		boxLayout->insertWidget(0, expand);
+
+		obs_data_t *data = obs_sceneitem_get_private_settings(sceneitem);
+		expand->blockSignals(true);
+		expand->setChecked(obs_data_get_bool(data, "collapsed"));
+		expand->blockSignals(false);
+		obs_data_release(data);
+
+		connect(expand, &QPushButton::toggled,
+				this, &SourceTreeItem::ExpandClicked);
+
+	} else {
+		spacer = new QSpacerItem(3, 1);
+		boxLayout->insertItem(0, spacer);
+	}
+}
+
+void SourceTreeItem::ExpandClicked(bool checked)
+{
+	OBSData data = obs_sceneitem_get_private_settings(sceneitem);
+	obs_data_release(data);
+
+	obs_data_set_bool(data, "collapsed", checked);
+
+	if (!checked)
+		tree->GetStm()->ExpandGroup(sceneitem);
+	else
+		tree->GetStm()->CollapseGroup(sceneitem);
+}
+
+/* ========================================================================= */
+
+void SourceTreeModel::OBSFrontendEvent(enum obs_frontend_event event, void *ptr)
+{
+	SourceTreeModel *stm = reinterpret_cast<SourceTreeModel *>(ptr);
+
+	switch ((int)event) {
+	case OBS_FRONTEND_EVENT_PREVIEW_SCENE_CHANGED:
+		stm->SceneChanged();
+		break;
+	case OBS_FRONTEND_EVENT_EXIT:
+	case OBS_FRONTEND_EVENT_SCENE_COLLECTION_CLEANUP:
+		stm->Clear();
+		break;
+	}
+}
+
+void SourceTreeModel::Clear()
+{
+	beginResetModel();
+	items.clear();
+	endResetModel();
+
+	hasGroups = false;
+}
+
+static bool enumItem(obs_scene_t*, obs_sceneitem_t *item, void *ptr)
+{
+	QVector<OBSSceneItem> &items =
+		*reinterpret_cast<QVector<OBSSceneItem>*>(ptr);
+
+	if (obs_sceneitem_is_group(item)) {
+		obs_data_t *data = obs_sceneitem_get_private_settings(item);
+
+		bool collapse = obs_data_get_bool(data, "collapsed");
+		if (!collapse) {
+			obs_scene_t *scene =
+				obs_sceneitem_group_get_scene(item);
+
+			obs_scene_enum_items(scene, enumItem, &items);
+		}
+
+		obs_data_release(data);
+	}
+
+	items.insert(0, item);
+	return true;
+}
+
+void SourceTreeModel::SceneChanged()
+{
+	OBSScene scene = GetCurrentScene();
+
+	beginResetModel();
+	items.clear();
+	obs_scene_enum_items(scene, enumItem, &items);
+	endResetModel();
+
+	UpdateGroupState(false);
+	st->ResetWidgets();
+
+	for (int i = 0; i < items.count(); i++) {
+		bool select = obs_sceneitem_selected(items[i]);
+		QModelIndex index = createIndex(i, 0);
+
+		st->selectionModel()->select(index, select
+				? QItemSelectionModel::Select
+				: QItemSelectionModel::Deselect);
+	}
+}
+
+/* moves a scene item index (blame linux distros for using older Qt builds) */
+static inline void MoveItem(QVector<OBSSceneItem> &items, int oldIdx, int newIdx)
+{
+	OBSSceneItem item = items[oldIdx];
+	items.remove(oldIdx);
+	items.insert(newIdx, item);
+}
+
+/* reorders list optimally with model reorder funcs */
+void SourceTreeModel::ReorderItems()
+{
+	OBSScene scene = GetCurrentScene();
+
+	QVector<OBSSceneItem> newitems;
+	obs_scene_enum_items(scene, enumItem, &newitems);
+
+	/* if item list has changed size, do full reset */
+	if (newitems.count() != items.count()) {
+		SceneChanged();
+		return;
+	}
+
+	for (;;) {
+		int idx1Old = 0;
+		int idx1New = 0;
+		int count;
+		int i;
+
+		/* find first starting changed item index */
+		for (i = 0; i < newitems.count(); i++) {
+			obs_sceneitem_t *oldItem = items[i];
+			obs_sceneitem_t *newItem = newitems[i];
+			if (oldItem != newItem) {
+				idx1Old = i;
+				break;
+			}
+		}
+
+		/* if everything is the same, break */
+		if (i == newitems.count()) {
+			break;
+		}
+
+		/* find new starting index */
+		for (i = idx1Old + 1; i < newitems.count(); i++) {
+			obs_sceneitem_t *oldItem = items[idx1Old];
+			obs_sceneitem_t *newItem = newitems[i];
+
+			if (oldItem == newItem) {
+				idx1New = i;
+				break;
+			}
+		}
+
+		/* if item could not be found, do full reset */
+		if (i == newitems.count()) {
+			SceneChanged();
+			return;
+		}
+
+		/* get move count */
+		for (count = 1; (idx1New + count) < newitems.count(); count++) {
+			int oldIdx = idx1Old + count;
+			int newIdx = idx1New + count;
+
+			obs_sceneitem_t *oldItem = items[oldIdx];
+			obs_sceneitem_t *newItem = newitems[newIdx];
+
+			if (oldItem != newItem) {
+				break;
+			}
+		}
+
+		/* move items */
+		beginMoveRows(QModelIndex(), idx1Old, idx1Old + count - 1,
+		              QModelIndex(), idx1New + count);
+		for (i = 0; i < count; i++) {
+			int to = idx1New + count;
+			if (to > idx1Old)
+				to--;
+			MoveItem(items, idx1Old, to);
+		}
+		endMoveRows();
+	}
+}
+
+void SourceTreeModel::Add(obs_sceneitem_t *item)
+{
+	beginInsertRows(QModelIndex(), 0, 0);
+	items.insert(0, item);
+	endInsertRows();
+
+	st->UpdateWidget(createIndex(0, 0, nullptr), item);
+}
+
+void SourceTreeModel::Remove(obs_sceneitem_t *item)
+{
+	int idx = -1;
+	for (int i = 0; i < items.count(); i++) {
+		if (items[i] == item) {
+			idx = i;
+			break;
+		}
+	}
+
+	if (idx == -1)
+		return;
+
+	int startIdx = idx;
+	int endIdx = idx;
+
+	bool is_group = obs_sceneitem_is_group(item);
+	if (is_group) {
+		obs_scene_t *scene = obs_sceneitem_group_get_scene(item);
+
+		for (int i = endIdx + 1; i < items.count(); i++) {
+			obs_sceneitem_t *subitem = items[i];
+			obs_scene_t *subscene =
+				obs_sceneitem_get_scene(subitem);
+
+			if (subscene == scene)
+				endIdx = i;
+			else
+				break;
+		}
+	}
+
+	beginRemoveRows(QModelIndex(), startIdx, endIdx);
+	items.remove(idx, endIdx - startIdx + 1);
+	endRemoveRows();
+
+	if (is_group)
+		UpdateGroupState(true);
+}
+
+OBSSceneItem SourceTreeModel::Get(int idx)
+{
+	if (idx == -1 || idx >= items.count())
+		return OBSSceneItem();
+	return items[idx];
+}
+
+SourceTreeModel::SourceTreeModel(SourceTree *st_)
+	: QAbstractListModel (st_),
+	  st                 (st_)
+{
+	obs_frontend_add_event_callback(OBSFrontendEvent, this);
+}
+
+SourceTreeModel::~SourceTreeModel()
+{
+	obs_frontend_remove_event_callback(OBSFrontendEvent, this);
+}
+
+int SourceTreeModel::rowCount(const QModelIndex &parent) const
+{
+	return parent.isValid() ? 0 : items.count();
+}
+
+QVariant SourceTreeModel::data(const QModelIndex &, int) const
+{
+	return QVariant();
+}
+
+Qt::ItemFlags SourceTreeModel::flags(const QModelIndex &index) const
+{
+	if (!index.isValid())
+		return QAbstractListModel::flags(index) | Qt::ItemIsDropEnabled;
+
+	obs_sceneitem_t *item = items[index.row()];
+	bool is_group = obs_sceneitem_is_group(item);
+
+	return QAbstractListModel::flags(index) |
+	       Qt::ItemIsEditable |
+	       Qt::ItemIsDragEnabled |
+	       (is_group ? Qt::ItemIsDropEnabled : Qt::NoItemFlags);
+}
+
+Qt::DropActions SourceTreeModel::supportedDropActions() const
+{
+	return QAbstractItemModel::supportedDropActions() | Qt::MoveAction;
+}
+
+QString SourceTreeModel::GetNewGroupName()
+{
+	OBSScene scene = GetCurrentScene();
+	QString name;
+
+	int i = 1;
+	for (;;) {
+		name = QTStr("Basic.Main.Group").arg(QString::number(i++));
+		obs_sceneitem_t *group = obs_scene_get_group(scene,
+				QT_TO_UTF8(name));
+		if (!group)
+			break;
+	}
+
+	return name;
+}
+
+void SourceTreeModel::AddGroup()
+{
+	QString name = GetNewGroupName();
+	obs_sceneitem_t *group = obs_scene_add_group(GetCurrentScene(),
+			QT_TO_UTF8(name));
+	if (!group)
+		return;
+
+	beginInsertRows(QModelIndex(), 0, 0);
+	items.insert(0, group);
+	endInsertRows();
+
+	st->UpdateWidget(createIndex(0, 0, nullptr), group);
+	UpdateGroupState(true);
+
+	QMetaObject::invokeMethod(st, "Edit", Qt::QueuedConnection,
+			Q_ARG(int, 0));
+}
+
+void SourceTreeModel::GroupSelectedItems(QModelIndexList &indices)
+{
+	if (indices.count() == 0)
+		return;
+
+	OBSScene scene = GetCurrentScene();
+	QString name = GetNewGroupName();
+
+	QVector<obs_sceneitem_t *> item_order;
+
+	for (int i = indices.count() - 1; i >= 0; i--) {
+		obs_sceneitem_t *item = items[indices[i].row()];
+		item_order << item;
+	}
+
+	obs_sceneitem_t *item = obs_scene_insert_group(
+			scene, QT_TO_UTF8(name),
+			item_order.data(), item_order.size());
+	if (!item) {
+		return;
+	}
+
+	for (obs_sceneitem_t *item : item_order)
+		obs_sceneitem_select(item, false);
+
+	int newIdx = indices[0].row();
+
+	beginInsertRows(QModelIndex(), newIdx, newIdx);
+	items.insert(newIdx, item);
+	endInsertRows();
+
+	for (int i = 0; i < indices.size(); i++) {
+		int fromIdx = indices[i].row() + 1;
+		int toIdx = newIdx + i + 1;
+		if (fromIdx != toIdx) {
+			beginMoveRows(QModelIndex(), fromIdx, fromIdx,
+			              QModelIndex(), toIdx);
+			MoveItem(items, fromIdx, toIdx);
+			endMoveRows();
+		}
+	}
+
+	hasGroups = true;
+	st->UpdateWidgets(true);
+
+	obs_sceneitem_select(item, true);
+
+	QMetaObject::invokeMethod(st, "Edit", Qt::QueuedConnection,
+			Q_ARG(int, newIdx));
+}
+
+void SourceTreeModel::UngroupSelectedGroups(QModelIndexList &indices)
+{
+	if (indices.count() == 0)
+		return;
+
+	for (int i = indices.count() - 1; i >= 0; i--) {
+		obs_sceneitem_t *item = items[indices[i].row()];
+		obs_sceneitem_group_ungroup(item);
+	}
+
+	SceneChanged();
+}
+
+void SourceTreeModel::ExpandGroup(obs_sceneitem_t *item)
+{
+	int itemIdx = items.indexOf(item);
+	if (itemIdx == -1)
+		return;
+
+	itemIdx++;
+
+	obs_scene_t *scene = obs_sceneitem_group_get_scene(item);
+
+	QVector<OBSSceneItem> subItems;
+	obs_scene_enum_items(scene, enumItem, &subItems);
+
+	if (!subItems.size())
+		return;
+
+	beginInsertRows(QModelIndex(), itemIdx, itemIdx + subItems.size() - 1);
+	for (int i = 0; i < subItems.size(); i++)
+		items.insert(i + itemIdx, subItems[i]);
+	endInsertRows();
+
+	st->UpdateWidgets();
+}
+
+void SourceTreeModel::CollapseGroup(obs_sceneitem_t *item)
+{
+	int startIdx = -1;
+	int endIdx = -1;
+
+	obs_scene_t *scene = obs_sceneitem_group_get_scene(item);
+
+	for (int i = 0; i < items.size(); i++) {
+		obs_scene_t *itemScene = obs_sceneitem_get_scene(items[i]);
+
+		if (itemScene == scene) {
+			if (startIdx == -1)
+				startIdx = i;
+			endIdx = i;
+		}
+	}
+
+	if (startIdx == -1)
+		return;
+
+	beginRemoveRows(QModelIndex(), startIdx, endIdx);
+	items.remove(startIdx, endIdx - startIdx + 1);
+	endRemoveRows();
+}
+
+void SourceTreeModel::UpdateGroupState(bool update)
+{
+	bool nowHasGroups = false;
+	for (auto &item : items) {
+		if (obs_sceneitem_is_group(item)) {
+			nowHasGroups = true;
+			break;
+		}
+	}
+
+	if (nowHasGroups != hasGroups) {
+		hasGroups = nowHasGroups;
+		if (update) {
+			st->UpdateWidgets(true);
+		}
+	}
+}
+
+/* ========================================================================= */
+
+SourceTree::SourceTree(QWidget *parent_) : QListView(parent_)
+{
+	SourceTreeModel *stm_ = new SourceTreeModel(this);
+	setModel(stm_);
+}
+
+void SourceTree::ResetWidgets()
+{
+	OBSScene scene = GetCurrentScene();
+
+	SourceTreeModel *stm = GetStm();
+	stm->UpdateGroupState(false);
+
+	for (int i = 0; i < stm->items.count(); i++) {
+		QModelIndex index = stm->createIndex(i, 0, nullptr);
+		setIndexWidget(index, new SourceTreeItem(this, stm->items[i]));
+	}
+}
+
+void SourceTree::UpdateWidget(const QModelIndex &idx, obs_sceneitem_t *item)
+{
+	setIndexWidget(idx, new SourceTreeItem(this, item));
+}
+
+void SourceTree::UpdateWidgets(bool force)
+{
+	SourceTreeModel *stm = GetStm();
+
+	for (int i = 0; i < stm->items.size(); i++) {
+		obs_sceneitem_t *item = stm->items[i];
+		SourceTreeItem *widget = GetItemWidget(i);
+
+		if (!widget) {
+			UpdateWidget(stm->createIndex(i, 0), item);
+		} else {
+			widget->Update(force);
+		}
+	}
+}
+
+void SourceTree::SelectItem(obs_sceneitem_t *sceneitem, bool select)
+{
+	SourceTreeModel *stm = GetStm();
+	int i = 0;
+
+	for (; i < stm->items.count(); i++) {
+		if (stm->items[i] == sceneitem)
+			break;
+	}
+
+	if (i == stm->items.count())
+		return;
+
+	QModelIndex index = stm->createIndex(i, 0);
+	if (index.isValid())
+		selectionModel()->select(index, select
+				? QItemSelectionModel::Select
+				: QItemSelectionModel::Deselect);
+}
+
+Q_DECLARE_METATYPE(OBSSceneItem);
+
+void SourceTree::mouseDoubleClickEvent(QMouseEvent *event)
+{
+	if (event->button() == Qt::LeftButton)
+		QListView::mouseDoubleClickEvent(event);
+}
+
+void SourceTree::dropEvent(QDropEvent *event)
+{
+	if (event->source() != this) {
+		QListView::dropEvent(event);
+		return;
+	}
+
+	OBSScene scene = GetCurrentScene();
+	SourceTreeModel *stm = GetStm();
+	auto &items = stm->items;
+	QModelIndexList indices = selectedIndexes();
+
+	DropIndicatorPosition indicator = dropIndicatorPosition();
+	int row = indexAt(event->pos()).row();
+	bool emptyDrop = row == -1;
+
+	if (emptyDrop) {
+		if (!items.size()) {
+			QListView::dropEvent(event);
+			return;
+		}
+
+		row = items.size() - 1;
+		indicator = QAbstractItemView::BelowItem;
+	}
+
+	/* --------------------------------------- */
+	/* store destination group if moving to a  */
+	/* group                                   */
+
+	obs_sceneitem_t *dropItem = items[row]; /* item being dropped on */
+	bool itemIsGroup = obs_sceneitem_is_group(dropItem);
+
+	obs_sceneitem_t *dropGroup = itemIsGroup
+		? dropItem
+		: obs_sceneitem_get_group(dropItem);
+
+	/* not a group if moving above the group */
+	if (indicator == QAbstractItemView::AboveItem && itemIsGroup)
+		dropGroup = nullptr;
+	if (emptyDrop)
+		dropGroup = nullptr;
+
+	/* --------------------------------------- */
+	/* remember to remove list items if        */
+	/* dropping on collapsed group             */
+
+	bool dropOnCollapsed = false;
+	if (dropGroup) {
+		obs_data_t *data = obs_sceneitem_get_private_settings(dropGroup);
+		dropOnCollapsed = obs_data_get_bool(data, "collapsed");
+		obs_data_release(data);
+	}
+
+	if (indicator == QAbstractItemView::BelowItem ||
+	    indicator == QAbstractItemView::OnItem)
+		row++;
+
+	if (row < 0 || row > stm->items.count()) {
+		QListView::dropEvent(event);
+		return;
+	}
+
+	/* --------------------------------------- */
+	/* determine if any base group is selected */
+
+	bool hasGroups = false;
+	for (int i = 0; i < indices.size(); i++) {
+		obs_sceneitem_t *item = items[indices[i].row()];
+		if (obs_sceneitem_is_group(item)) {
+			hasGroups = true;
+			break;
+		}
+	}
+
+	/* --------------------------------------- */
+	/* if dropping a group, detect if it's     */
+	/* below another group                     */
+
+	obs_sceneitem_t *itemBelow = row == stm->items.count()
+		? nullptr
+		: stm->items[row];
+	if (hasGroups) {
+		if (!itemBelow ||
+		    obs_sceneitem_get_group(itemBelow) != dropGroup) {
+			indicator = QAbstractItemView::BelowItem;
+			dropGroup = nullptr;
+			dropOnCollapsed = false;
+		}
+	}
+
+	/* --------------------------------------- */
+	/* if dropping groups on other groups,     */
+	/* disregard as invalid drag/drop          */
+
+	if (dropGroup && hasGroups) {
+		QListView::dropEvent(event);
+		return;
+	}
+
+	/* --------------------------------------- */
+	/* if selection includes base group items, */
+	/* include all group sub-items and treat   */
+	/* them all as one                         */
+
+	if (hasGroups) {
+		/* remove sub-items if selected */
+		for (int i = indices.size() - 1; i >= 0; i--) {
+			obs_sceneitem_t *item = items[indices[i].row()];
+			obs_scene_t *itemScene = obs_sceneitem_get_scene(item);
+
+			if (itemScene != scene) {
+				indices.removeAt(i);
+			}
+		}
+
+		/* add all sub-items of selected groups */
+		for (int i = indices.size() - 1; i >= 0; i--) {
+			obs_sceneitem_t *item = items[indices[i].row()];
+
+			if (obs_sceneitem_is_group(item)) {
+				for (int j = items.size() - 1; j >= 0; j--) {
+					obs_sceneitem_t *subitem = items[j];
+					obs_sceneitem_t *subitemGroup =
+						obs_sceneitem_get_group(subitem);
+
+					if (subitemGroup == item) {
+						QModelIndex idx =
+							stm->createIndex(j, 0);
+						indices.insert(i + 1, idx);
+					}
+				}
+			}
+		}
+	}
+
+	/* --------------------------------------- */
+	/* build persistent indices                */
+
+	QList<QPersistentModelIndex> persistentIndices;
+	persistentIndices.reserve(indices.count());
+	for (QModelIndex &index : indices)
+		persistentIndices.append(index);
+	std::sort(persistentIndices.begin(), persistentIndices.end());
+
+	/* --------------------------------------- */
+	/* move all items to destination index     */
+
+	int r = row;
+	for (auto &persistentIdx : persistentIndices) {
+		int from = persistentIdx.row();
+		int to = r;
+		int itemTo = to;
+
+		if (itemTo > from)
+			itemTo--;
+
+		if (itemTo != from) {
+			stm->beginMoveRows(QModelIndex(), from, from,
+			                   QModelIndex(), to);
+			MoveItem(items, from, itemTo);
+			stm->endMoveRows();
+		}
+
+		r = persistentIdx.row() + 1;
+	}
+
+	std::sort(persistentIndices.begin(), persistentIndices.end());
+	int firstIdx = persistentIndices.front().row();
+	int lastIdx = persistentIndices.back().row();
+
+	/* --------------------------------------- */
+	/* reorder scene items in back-end         */
+
+	QVector<struct obs_sceneitem_order_info> orderList;
+	obs_sceneitem_t *lastGroup = nullptr;
+	int insertCollapsedIdx = 0;
+
+	auto insertCollapsed = [&] (obs_sceneitem_t *item)
+	{
+		struct obs_sceneitem_order_info info;
+		info.group = lastGroup;
+		info.item = item;
+
+		orderList.insert(insertCollapsedIdx++, info);
+	};
+
+	using insertCollapsed_t = decltype(insertCollapsed);
+
+	auto preInsertCollapsed = [] (obs_scene_t *, obs_sceneitem_t *item,
+			void *param)
+	{
+		(*reinterpret_cast<insertCollapsed_t *>(param))(item);
+		return true;
+	};
+
+	auto insertLastGroup = [&] ()
+	{
+		obs_data_t *data = obs_sceneitem_get_private_settings(lastGroup);
+		bool collapsed = obs_data_get_bool(data, "collapsed");
+		obs_data_release(data);
+
+		if (collapsed) {
+			insertCollapsedIdx = 0;
+			obs_sceneitem_group_enum_items(
+					lastGroup,
+					preInsertCollapsed,
+					&insertCollapsed);
+		}
+
+		struct obs_sceneitem_order_info info;
+		info.group = nullptr;
+		info.item = lastGroup;
+		orderList.insert(0, info);
+	};
+
+	auto updateScene = [&] ()
+	{
+		struct obs_sceneitem_order_info info;
+
+		for (int i = 0; i < items.size(); i++) {
+			obs_sceneitem_t *item = items[i];
+			obs_sceneitem_t *group;
+
+			if (obs_sceneitem_is_group(item)) {
+				if (lastGroup) {
+					insertLastGroup();
+				}
+				lastGroup = item;
+				continue;
+			}
+
+			if (!hasGroups && i >= firstIdx && i <= lastIdx)
+				group = dropGroup;
+			else
+				group = obs_sceneitem_get_group(item);
+
+			if (lastGroup && lastGroup != group) {
+				insertLastGroup();
+			}
+
+			lastGroup = group;
+
+			info.group = group;
+			info.item = item;
+			orderList.insert(0, info);
+		}
+
+		if (lastGroup) {
+			insertLastGroup();
+		}
+
+		obs_scene_reorder_items2(scene,
+				orderList.data(), orderList.size());
+	};
+
+	using updateScene_t = decltype(updateScene);
+
+	auto preUpdateScene = [] (void *data, obs_scene_t *)
+	{
+		(*reinterpret_cast<updateScene_t *>(data))();
+	};
+
+	ignoreReorder = true;
+	obs_scene_atomic_update(scene, preUpdateScene, &updateScene);
+	ignoreReorder = false;
+
+	/* --------------------------------------- */
+	/* remove items if dropped in to collapsed */
+	/* group                                   */
+
+	if (dropOnCollapsed) {
+		stm->beginRemoveRows(QModelIndex(), firstIdx, lastIdx);
+		items.remove(firstIdx, lastIdx - firstIdx + 1);
+		stm->endRemoveRows();
+	}
+
+	/* --------------------------------------- */
+	/* update widgets and accept event         */
+
+	UpdateWidgets(true);
+
+	event->accept();
+	event->setDropAction(Qt::CopyAction);
+
+	QListView::dropEvent(event);
+}
+
+void SourceTree::selectionChanged(
+		const QItemSelection &selected,
+		const QItemSelection &deselected)
+{
+	{
+		SignalBlocker sourcesSignalBlocker(this);
+		SourceTreeModel *stm = GetStm();
+
+		QModelIndexList selectedIdxs = selected.indexes();
+		QModelIndexList deselectedIdxs = deselected.indexes();
+
+		for (int i = 0; i < selectedIdxs.count(); i++) {
+			int idx = selectedIdxs[i].row();
+			obs_sceneitem_select(stm->items[idx], true);
+		}
+
+		for (int i = 0; i < deselectedIdxs.count(); i++) {
+			int idx = deselectedIdxs[i].row();
+			obs_sceneitem_select(stm->items[idx], false);
+		}
+	}
+	QListView::selectionChanged(selected, deselected);
+}
+
+void SourceTree::Edit(int row)
+{
+	SourceTreeModel *stm = GetStm();
+	if (row < 0 || row >= stm->items.count())
+		return;
+
+	QWidget *widget = indexWidget(stm->createIndex(row, 0));
+	SourceTreeItem *itemWidget = reinterpret_cast<SourceTreeItem *>(widget);
+	itemWidget->EnterEditMode();
+	edit(stm->createIndex(row, 0));
+}
+
+bool SourceTree::MultipleBaseSelected() const
+{
+	SourceTreeModel *stm = GetStm();
+	QModelIndexList selectedIndices = selectedIndexes();
+
+	OBSScene scene = GetCurrentScene();
+
+	if (selectedIndices.size() < 1) {
+		return false;
+	}
+
+	for (auto &idx : selectedIndices) {
+		obs_sceneitem_t *item = stm->items[idx.row()];
+		if (obs_sceneitem_is_group(item)) {
+			return false;
+		}
+
+		obs_scene *itemScene = obs_sceneitem_get_scene(item);
+		if (itemScene != scene) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+bool SourceTree::GroupsSelected() const
+{
+	SourceTreeModel *stm = GetStm();
+	QModelIndexList selectedIndices = selectedIndexes();
+
+	OBSScene scene = GetCurrentScene();
+
+	if (selectedIndices.size() < 1) {
+		return false;
+	}
+
+	for (auto &idx : selectedIndices) {
+		obs_sceneitem_t *item = stm->items[idx.row()];
+		if (!obs_sceneitem_is_group(item)) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+bool SourceTree::GroupedItemsSelected() const
+{
+	SourceTreeModel *stm = GetStm();
+	QModelIndexList selectedIndices = selectedIndexes();
+	OBSScene scene = GetCurrentScene();
+
+	if (!selectedIndices.size()) {
+		return false;
+	}
+
+	for (auto &idx : selectedIndices) {
+		obs_sceneitem_t *item = stm->items[idx.row()];
+		obs_scene *itemScene = obs_sceneitem_get_scene(item);
+
+		if (itemScene != scene) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+void SourceTree::GroupSelectedItems()
+{
+	QModelIndexList indices = selectedIndexes();
+	std::sort(indices.begin(), indices.end());
+	GetStm()->GroupSelectedItems(indices);
+}
+
+void SourceTree::UngroupSelectedGroups()
+{
+	QModelIndexList indices = selectedIndexes();
+	GetStm()->UngroupSelectedGroups(indices);
+}
+
+void SourceTree::AddGroup()
+{
+	GetStm()->AddGroup();
+}

+ 171 - 0
UI/source-tree.hpp

@@ -0,0 +1,171 @@
+#pragma once
+
+#include <QList>
+#include <QVector>
+#include <QPointer>
+#include <QListView>
+#include <QCheckBox>
+#include <QAbstractListModel>
+
+class QLabel;
+class QCheckBox;
+class QLineEdit;
+class SourceTree;
+class QSpacerItem;
+class QHBoxLayout;
+class LockedCheckBox;
+class VisibilityCheckBox;
+class VisibilityItemWidget;
+
+class SourceTreeSubItemCheckBox : public QCheckBox {
+	Q_OBJECT
+};
+
+class SourceTreeItem : public QWidget {
+	Q_OBJECT
+
+	friend class SourceTree;
+	friend class SourceTreeModel;
+
+	void mouseDoubleClickEvent(QMouseEvent *event) override;
+
+	virtual bool eventFilter(QObject *object, QEvent *event) override;
+
+	void Update(bool force);
+
+	enum class Type {
+		Unknown,
+		Item,
+		Group,
+		SubItem,
+	};
+
+	void DisconnectSignals();
+	void ReconnectSignals();
+
+	Type type = Type::Unknown;
+
+public:
+	explicit SourceTreeItem(SourceTree *tree, OBSSceneItem sceneitem);
+
+private:
+	QSpacerItem *spacer = nullptr;
+	QCheckBox *expand = nullptr;
+	VisibilityCheckBox *vis = nullptr;
+	LockedCheckBox *lock = nullptr;
+	QHBoxLayout *boxLayout = nullptr;
+	QLabel *label = nullptr;
+
+	QLineEdit *editor = nullptr;
+
+	SourceTree *tree;
+	OBSSceneItem sceneitem;
+	OBSSignal sceneRemoveSignal;
+	OBSSignal itemRemoveSignal;
+	OBSSignal visibleSignal;
+	OBSSignal renameSignal;
+	OBSSignal removeSignal;
+
+private slots:
+	void EnterEditMode();
+	void ExitEditMode(bool save);
+
+	void VisibilityChanged(bool visible);
+	void Renamed(const QString &name);
+
+	void ExpandClicked(bool checked);
+};
+
+class SourceTreeModel : public QAbstractListModel {
+	Q_OBJECT
+
+	friend class SourceTree;
+	friend class SourceTreeItem;
+
+	SourceTree *st;
+	QVector<OBSSceneItem> items;
+	bool hasGroups = false;
+
+	static void OBSFrontendEvent(enum obs_frontend_event event, void *ptr);
+	void Clear();
+	void SceneChanged();
+	void ReorderItems();
+
+	void Add(obs_sceneitem_t *item);
+	void Remove(obs_sceneitem_t *item);
+	OBSSceneItem Get(int idx);
+	QString GetNewGroupName();
+	void AddGroup();
+
+	void GroupSelectedItems(QModelIndexList &indices);
+	void UngroupSelectedGroups(QModelIndexList &indices);
+
+	void ExpandGroup(obs_sceneitem_t *item);
+	void CollapseGroup(obs_sceneitem_t *item);
+
+	void UpdateGroupState(bool update);
+
+public:
+	explicit SourceTreeModel(SourceTree *st);
+	~SourceTreeModel();
+
+	virtual int rowCount(const QModelIndex &parent) const override;
+	virtual QVariant data(const QModelIndex &index, int role) const override;
+
+	virtual Qt::ItemFlags flags(const QModelIndex &index) const override;
+	virtual Qt::DropActions supportedDropActions() const override;
+};
+
+class SourceTree : public QListView {
+	Q_OBJECT
+
+	bool ignoreReorder = false;
+
+	friend class SourceTreeModel;
+	friend class SourceTreeItem;
+
+	void ResetWidgets();
+	void UpdateWidget(const QModelIndex &idx, obs_sceneitem_t *item);
+	void UpdateWidgets(bool force = false);
+
+	inline SourceTreeModel *GetStm() const
+	{
+		return reinterpret_cast<SourceTreeModel *>(model());
+	}
+
+	inline SourceTreeItem *GetItemWidget(int idx)
+	{
+		QWidget *widget = indexWidget(GetStm()->createIndex(idx, 0));
+		return reinterpret_cast<SourceTreeItem *>(widget);
+	}
+
+public:
+	explicit SourceTree(QWidget *parent = nullptr);
+
+	inline bool IgnoreReorder() const {return ignoreReorder;}
+	inline void ReorderItems() {GetStm()->ReorderItems();}
+	inline void Clear() {GetStm()->Clear();}
+
+	inline void Add(obs_sceneitem_t *item) {GetStm()->Add(item);}
+	inline void Remove(obs_sceneitem_t *item) {GetStm()->Remove(item);}
+	inline OBSSceneItem Get(int idx) {return GetStm()->Get(idx);}
+	inline QString GetNewGroupName() {return GetStm()->GetNewGroupName();}
+
+	void SelectItem(obs_sceneitem_t *sceneitem, bool select);
+
+	bool MultipleBaseSelected() const;
+	bool GroupsSelected() const;
+	bool GroupedItemsSelected() const;
+
+public slots:
+	void GroupSelectedItems();
+	void UngroupSelectedGroups();
+	void AddGroup();
+	void Edit(int idx);
+
+protected:
+	virtual void mouseDoubleClickEvent(QMouseEvent *event) override;
+	virtual void dropEvent(QDropEvent *event) override;
+
+	virtual void selectionChanged(const QItemSelection &selected, const QItemSelection &deselected) override;
+};

+ 75 - 206
UI/window-basic-main.cpp

@@ -54,6 +54,7 @@
 #include "display-helpers.hpp"
 #include "volume-control.hpp"
 #include "remote-text.hpp"
+#include "source-tree.hpp"
 
 #ifdef _WIN32
 #include "win-update/win-update.hpp"
@@ -148,8 +149,6 @@ OBSBasic::OBSBasic(QWidget *parent)
 
 	copyActionsDynamicProperties();
 
-	ui->sources->setItemDelegate(new VisibilityItemDelegate(ui->sources));
-
 	char styleSheetPath[512];
 	int ret = GetProfilePath(styleSheetPath, sizeof(styleSheetPath),
 			"stylesheet.qss");
@@ -201,13 +200,6 @@ OBSBasic::OBSBasic(QWidget *parent)
 			SLOT(SceneNameEdited(QWidget*,
 					QAbstractItemDelegate::EndEditHint)));
 
-	connect(ui->sources->itemDelegate(),
-			SIGNAL(closeEditor(QWidget*,
-					QAbstractItemDelegate::EndEditHint)),
-			this,
-			SLOT(SceneItemNameEdited(QWidget*,
-					QAbstractItemDelegate::EndEditHint)));
-
 	cpuUsageInfo = os_cpu_usage_info_start();
 	cpuUsageTimer = new QTimer(this);
 	connect(cpuUsageTimer, SIGNAL(timeout()),
@@ -2024,7 +2016,7 @@ OBSSceneItem OBSBasic::GetSceneItem(QListWidgetItem *item)
 
 OBSSceneItem OBSBasic::GetCurrentSceneItem()
 {
-	return GetSceneItem(GetTopSelectedSourceItem());
+	return ui->sources->Get(GetTopSelectedSourceItem());
 }
 
 void OBSBasic::UpdatePreviewScalingMenu()
@@ -2047,32 +2039,6 @@ void OBSBasic::UpdatePreviewScalingMenu()
 			scalingAmount == float(ovi.output_width) / float(ovi.base_width));
 }
 
-void OBSBasic::UpdateSources(OBSScene scene)
-{
-	ClearListItems(ui->sources);
-
-	obs_scene_enum_items(scene,
-			[] (obs_scene_t *scene, obs_sceneitem_t *item, void *p)
-			{
-				OBSBasic *window = static_cast<OBSBasic*>(p);
-				window->InsertSceneItem(item);
-
-				UNUSED_PARAMETER(scene);
-				return true;
-			}, this);
-}
-
-void OBSBasic::InsertSceneItem(obs_sceneitem_t *item)
-{
-	QListWidgetItem *listItem = new QListWidgetItem();
-	SetOBSRef(listItem, OBSSceneItem(item));
-
-	ui->sources->insertItem(0, listItem);
-	ui->sources->setCurrentRow(0, QItemSelectionModel::ClearAndSelect);
-
-	SetupVisibilityItem(ui->sources, listItem, item);
-}
-
 void OBSBasic::CreateInteractionWindow(obs_source_t *source)
 {
 	if (interaction)
@@ -2199,7 +2165,7 @@ void OBSBasic::RemoveScene(OBSSource source)
 
 	if (sel != nullptr) {
 		if (sel == ui->scenes->currentItem())
-			ClearListItems(ui->sources);
+			ui->sources->Clear();
 		delete sel;
 	}
 
@@ -2221,7 +2187,7 @@ void OBSBasic::AddSceneItem(OBSSceneItem item)
 	obs_scene_t  *scene  = obs_sceneitem_get_scene(item);
 
 	if (GetCurrentScene() == scene)
-		InsertSceneItem(item);
+		ui->sources->Add(item);
 
 	SaveProject();
 
@@ -2237,14 +2203,7 @@ void OBSBasic::AddSceneItem(OBSSceneItem item)
 
 void OBSBasic::RemoveSceneItem(OBSSceneItem item)
 {
-	for (int i = 0; i < ui->sources->count(); i++) {
-		QListWidgetItem *listItem = ui->sources->item(i);
-
-		if (GetOBSRef<OBSSceneItem>(listItem) == item) {
-			DeleteListItem(ui->sources, listItem);
-			break;
-		}
-	}
+	ui->sources->Remove(item);
 
 	SaveProject();
 
@@ -2276,8 +2235,6 @@ void OBSBasic::UpdateSceneSelection(OBSSource source)
 			ui->scenes->setCurrentItem(items.first());
 			sceneChanging = false;
 
-			UpdateSources(scene);
-
 			OBSScene curScene =
 				GetOBSRef<OBSScene>(ui->scenes->currentItem());
 			if (api && scene != curScene)
@@ -2322,19 +2279,7 @@ void OBSBasic::SelectSceneItem(OBSScene scene, OBSSceneItem item, bool select)
 	if (scene != GetCurrentScene() || ignoreSelectionUpdate)
 		return;
 
-	for (int i = 0; i < ui->sources->count(); i++) {
-		QListWidgetItem *witem = ui->sources->item(i);
-		QVariant data =
-			witem->data(static_cast<int>(QtDataRole::OBSRef));
-		if (!data.canConvert<OBSSceneItem>())
-			continue;
-
-		if (item != data.value<OBSSceneItem>())
-			continue;
-
-		witem->setSelected(select);
-		break;
-	}
+	ui->sources->SelectItem(item, select);
 }
 
 static inline bool SourceMixerHidden(obs_source_t *source)
@@ -2664,7 +2609,8 @@ void OBSBasic::DeactivateAudioSource(OBSSource source)
 
 bool OBSBasic::QueryRemoveSource(obs_source_t *source)
 {
-	if (obs_source_get_type(source) == OBS_SOURCE_TYPE_SCENE) {
+	if (obs_source_get_type(source) == OBS_SOURCE_TYPE_SCENE &&
+	    !obs_sceneitem_group_from_source(source)) {
 		int count = ui->scenes->count();
 
 		if (count == 1) {
@@ -2830,62 +2776,12 @@ void OBSBasic::RemoveSelectedSceneItem()
 	}
 }
 
-struct ReorderInfo {
-	int idx = 0;
-	OBSBasic *window;
-
-	inline ReorderInfo(OBSBasic *window_) : window(window_) {}
-};
-
-void OBSBasic::ReorderSceneItem(obs_sceneitem_t *item, size_t idx)
-{
-	int count = ui->sources->count();
-	int idx_inv = count - (int)idx - 1;
-
-	for (int i = 0; i < count; i++) {
-		QListWidgetItem *listItem = ui->sources->item(i);
-		OBSSceneItem sceneItem = GetOBSRef<OBSSceneItem>(listItem);
-
-		if (sceneItem == item) {
-			if ((int)idx_inv != i) {
-				bool sel = (ui->sources->currentRow() == i);
-
-				listItem = TakeListItem(ui->sources, i);
-				if (listItem)  {
-					ui->sources->insertItem(idx_inv,
-							listItem);
-					SetupVisibilityItem(ui->sources,
-							listItem, item);
-
-					if (sel)
-						ui->sources->setCurrentRow(
-								idx_inv);
-				}
-			}
-
-			break;
-		}
-	}
-}
-
 void OBSBasic::ReorderSources(OBSScene scene)
 {
-	ReorderInfo info(this);
-
 	if (scene != GetCurrentScene() || ui->sources->IgnoreReorder())
 		return;
 
-	obs_scene_enum_items(scene,
-			[] (obs_scene_t*, obs_sceneitem_t *item, void *p)
-			{
-				ReorderInfo *info =
-					reinterpret_cast<ReorderInfo*>(p);
-
-				info->window->ReorderSceneItem(item,
-					info->idx++);
-				return true;
-			}, &info);
-
+	ui->sources->ReorderItems();
 	SaveProject();
 }
 
@@ -3410,7 +3306,7 @@ void OBSBasic::ClearSceneData()
 
 	ClearVolumeControls();
 	ClearListItems(ui->scenes);
-	ClearListItems(ui->sources);
+	ui->sources->Clear();
 	ClearQuickTransitions();
 	ui->transitions->clear();
 
@@ -3800,44 +3696,10 @@ void OBSBasic::MoveSceneToBottom()
 			ui->scenes->count() - 1);
 }
 
-void OBSBasic::on_sources_itemSelectionChanged()
-{
-	SignalBlocker sourcesSignalBlocker(ui->sources);
-
-	auto updateItemSelection = [&]()
-	{
-		ignoreSelectionUpdate = true;
-		for (int i = 0; i < ui->sources->count(); i++)
-		{
-			QListWidgetItem *wItem = ui->sources->item(i);
-			OBSSceneItem item = GetOBSRef<OBSSceneItem>(wItem);
-
-			obs_sceneitem_select(item, wItem->isSelected());
-		}
-		ignoreSelectionUpdate = false;
-	};
-	using updateItemSelection_t = decltype(updateItemSelection);
-
-	obs_scene_atomic_update(GetCurrentScene(),
-			[](void *data, obs_scene_t *)
-	{
-		(*static_cast<updateItemSelection_t*>(data))();
-	}, static_cast<void*>(&updateItemSelection));
-}
-
 void OBSBasic::EditSceneItemName()
 {
-	QListWidgetItem *item = GetTopSelectedSourceItem();
-	Qt::ItemFlags flags   = item->flags();
-	OBSSceneItem sceneItem= GetOBSRef<OBSSceneItem>(item);
-	obs_source_t *source  = obs_sceneitem_get_source(sceneItem);
-	const char *name      = obs_source_get_name(source);
-
-	item->setText(QT_UTF8(name));
-	item->setFlags(flags | Qt::ItemIsEditable);
-	ui->sources->removeItemWidget(item);
-	ui->sources->editItem(item);
-	item->setFlags(flags);
+	int idx = GetTopSelectedSourceItem();
+	ui->sources->Edit(idx);
 }
 
 void OBSBasic::SetDeinterlacingMode()
@@ -3937,7 +3799,7 @@ QMenu *OBSBasic::AddScaleFilteringMenu(obs_sceneitem_t *item)
 	return menu;
 }
 
-void OBSBasic::CreateSourcePopupMenu(QListWidgetItem *item, bool preview)
+void OBSBasic::CreateSourcePopupMenu(int idx, bool preview)
 {
 	QMenu popup(this);
 	QPointer<QMenu> previewProjector;
@@ -3978,6 +3840,17 @@ void OBSBasic::CreateSourcePopupMenu(QListWidgetItem *item, bool preview)
 	ui->actionCopyFilters->setEnabled(false);
 	ui->actionCopySource->setEnabled(false);
 
+	if (ui->sources->MultipleBaseSelected()) {
+		popup.addSeparator();
+		popup.addAction(QTStr("Basic.Main.GroupItems"),
+				ui->sources, SLOT(GroupSelectedItems()));
+
+	} else if (ui->sources->GroupsSelected()) {
+		popup.addSeparator();
+		popup.addAction(QTStr("Basic.Main.Ungroup"),
+				ui->sources, SLOT(UngroupSelectedGroups()));
+	}
+
 	popup.addSeparator();
 	popup.addAction(ui->actionCopySource);
 	popup.addAction(ui->actionPasteRef);
@@ -3989,11 +3862,11 @@ void OBSBasic::CreateSourcePopupMenu(QListWidgetItem *item, bool preview)
 	popup.addAction(ui->actionPasteFilters);
 	popup.addSeparator();
 
-	if (item) {
+	if (idx != -1) {
 		if (addSourceMenu)
 			popup.addSeparator();
 
-		OBSSceneItem sceneItem = GetSceneItem(item);
+		OBSSceneItem sceneItem = ui->sources->Get(idx);
 		obs_source_t *source = obs_sceneitem_get_source(sceneItem);
 		uint32_t flags = obs_source_get_output_flags(source);
 		bool isAsyncVideo = (flags & OBS_SOURCE_ASYNC_VIDEO) ==
@@ -4064,20 +3937,10 @@ void OBSBasic::CreateSourcePopupMenu(QListWidgetItem *item, bool preview)
 
 void OBSBasic::on_sources_customContextMenuRequested(const QPoint &pos)
 {
-	if (ui->scenes->count())
-		CreateSourcePopupMenu(ui->sources->itemAt(pos), false);
-}
-
-void OBSBasic::on_sources_itemDoubleClicked(QListWidgetItem *witem)
-{
-	if (!witem)
-		return;
-
-	OBSSceneItem item = GetSceneItem(witem);
-	OBSSource source = obs_sceneitem_get_source(item);
-
-	if (source)
-		CreatePropertiesWindow(source);
+	if (ui->scenes->count()) {
+		QModelIndex idx = ui->sources->indexAt(pos);
+		CreateSourcePopupMenu(idx.row(), false);
+	}
 }
 
 void OBSBasic::on_scenes_itemDoubleClicked(QListWidgetItem *witem)
@@ -4161,6 +4024,12 @@ QMenu *OBSBasic::CreateAddSourcePopupMenu()
 
 	addSource(popup, "scene", Str("Basic.Scene"));
 
+	popup->addSeparator();
+	QAction *addGroup = new QAction(QTStr("Group"), this);
+	connect(addGroup, SIGNAL(triggered(bool)),
+			ui->sources, SLOT(AddGroup()));
+	popup->addAction(addGroup);
+
 	if (!foundDeprecated) {
 		delete deprecated;
 		deprecated = nullptr;
@@ -4171,6 +4040,7 @@ QMenu *OBSBasic::CreateAddSourcePopupMenu()
 		popup = nullptr;
 
 	} else if (foundDeprecated) {
+		popup->addSeparator();
 		popup->addMenu(deprecated);
 	}
 
@@ -4206,20 +4076,24 @@ void OBSBasic::on_actionAddSource_triggered()
 	AddSourcePopupMenu(QCursor::pos());
 }
 
+static bool remove_items(obs_scene_t *, obs_sceneitem_t *item, void *param)
+{
+	vector<OBSSceneItem> &items =
+		*reinterpret_cast<vector<OBSSceneItem>*>(param);
+
+	if (obs_sceneitem_selected(item)) {
+		items.emplace_back(item);
+	} else if (obs_sceneitem_is_group(item)) {
+		obs_sceneitem_group_enum_items(item, remove_items, &items);
+	}
+	return true;
+};
+
 void OBSBasic::on_actionRemoveSource_triggered()
 {
 	vector<OBSSceneItem> items;
 
-	auto func = [] (obs_scene_t *, obs_sceneitem_t *item, void *param)
-	{
-		vector<OBSSceneItem> &items =
-			*reinterpret_cast<vector<OBSSceneItem>*>(param);
-		if (obs_sceneitem_selected(item))
-			items.emplace_back(item);
-		return true;
-	};
-
-	obs_scene_enum_items(GetCurrentScene(), func, &items);
+	obs_scene_enum_items(GetCurrentScene(), remove_items, &items);
 
 	if (!items.size())
 		return;
@@ -4484,26 +4358,6 @@ void OBSBasic::SceneNameEdited(QWidget *editor,
 	UNUSED_PARAMETER(endHint);
 }
 
-void OBSBasic::SceneItemNameEdited(QWidget *editor,
-		QAbstractItemDelegate::EndEditHint endHint)
-{
-	OBSSceneItem item  = GetCurrentSceneItem();
-	QLineEdit    *edit = qobject_cast<QLineEdit*>(editor);
-	string       text  = QT_TO_UTF8(edit->text().trimmed());
-
-	if (!item)
-		return;
-
-	obs_source_t *source = obs_sceneitem_get_source(item);
-	RenameListItem(this, ui->sources, source, text);
-
-	QListWidgetItem *listItem = ui->sources->currentItem();
-	listItem->setText(QString());
-	SetupVisibilityItem(ui->sources, listItem, item);
-
-	UNUSED_PARAMETER(endHint);
-}
-
 void OBSBasic::OpenFilters()
 {
 	OBSSceneItem item = GetCurrentSceneItem();
@@ -5150,13 +5004,11 @@ void OBSBasic::on_actionShowProfileFolder_triggered()
 	QDesktopServices::openUrl(QUrl::fromLocalFile(path));
 }
 
-QListWidgetItem *OBSBasic::GetTopSelectedSourceItem()
+int OBSBasic::GetTopSelectedSourceItem()
 {
-	QList<QListWidgetItem*> selectedItems = ui->sources->selectedItems();
-	QListWidgetItem *topItem = nullptr;
-	if (selectedItems.size() != 0)
-		topItem = selectedItems[0];
-	return topItem;
+	QModelIndexList selectedItems =
+		ui->sources->selectionModel()->selectedIndexes();
+	return selectedItems.count() ? selectedItems[0].row() : -1;
 }
 
 void OBSBasic::on_preview_customContextMenuRequested(const QPoint &pos)
@@ -5613,6 +5465,22 @@ static bool nudge_callback(obs_scene_t*, obs_sceneitem_t *item, void *param)
 	struct vec2 pos;
 
 	if (!obs_sceneitem_selected(item)) {
+		if (obs_sceneitem_is_group(item)) {
+			struct vec3 offset3;
+			vec3_set(&offset3, offset.x, offset.y, 0.0f);
+
+			struct matrix4 matrix;
+			obs_sceneitem_get_draw_transform(item, &matrix);
+			vec4_set(&matrix.t, 0.0f, 0.0f, 0.0f, 1.0f);
+			matrix4_inv(&matrix, &matrix);
+			vec3_transform(&offset3, &offset3, &matrix);
+
+			struct vec2 new_offset;
+			vec2_set(&new_offset, offset3.x, offset3.y);
+			obs_sceneitem_group_enum_items(item, nudge_callback,
+					&new_offset);
+		}
+
 		return true;
 	}
 
@@ -6188,12 +6056,13 @@ bool OBSBasic::sysTrayMinimizeToTray()
 
 void OBSBasic::on_actionCopySource_triggered()
 {
-	on_actionCopyTransform_triggered();
-
 	OBSSceneItem item = GetCurrentSceneItem();
-
 	if (!item)
 		return;
+	if (!!obs_sceneitem_is_group(item))
+		return;
+
+	on_actionCopyTransform_triggered();
 
 	OBSSource source = obs_sceneitem_get_source(item);
 

+ 6 - 15
UI/window-basic-main.hpp

@@ -232,9 +232,6 @@ private:
 
 	void UpdatePreviewScalingMenu();
 
-	void UpdateSources(OBSScene scene);
-	void InsertSceneItem(obs_sceneitem_t *item);
-
 	void LoadSceneListOrder(obs_data_array_t *array);
 	obs_data_array_t *SaveSceneListOrder();
 	void ChangeSceneIndex(bool relative, int idx, int invalidIdx);
@@ -243,10 +240,6 @@ private:
 	void TempStreamOutput(const char *url, const char *key,
 			int vBitrate, int aBitrate);
 
-	void CreateInteractionWindow(obs_source_t *source);
-	void CreatePropertiesWindow(obs_source_t *source);
-	void CreateFiltersWindow(obs_source_t *source);
-
 	void CloseDialogs();
 	void ClearSceneData();
 
@@ -275,7 +268,7 @@ private:
 
 	void SaveProjectNow();
 
-	QListWidgetItem *GetTopSelectedSourceItem();
+	int GetTopSelectedSourceItem();
 
 	obs_hotkey_pair_id streamingHotkeys, recordingHotkeys,
 	                   replayBufHotkeys;
@@ -550,11 +543,9 @@ public:
 		}
 	}
 
-	void ReorderSceneItem(obs_sceneitem_t *item, size_t idx);
-
 	QMenu *AddDeinterlacingMenu(obs_source_t *source);
 	QMenu *AddScaleFilteringMenu(obs_sceneitem_t *item);
-	void CreateSourcePopupMenu(QListWidgetItem *item, bool preview);
+	void CreateSourcePopupMenu(int idx, bool preview);
 
 	void UpdateTitleBar();
 	void UpdateSceneSelection(OBSSource source);
@@ -564,6 +555,10 @@ public:
 
 	void OpenSavedProjectors();
 
+	void CreateInteractionWindow(obs_source_t *source);
+	void CreatePropertiesWindow(obs_source_t *source);
+	void CreateFiltersWindow(obs_source_t *source);
+
 protected:
 	virtual void closeEvent(QCloseEvent *event) override;
 	virtual void changeEvent(QEvent *event) override;
@@ -605,9 +600,7 @@ private slots:
 	void on_actionRemoveScene_triggered();
 	void on_actionSceneUp_triggered();
 	void on_actionSceneDown_triggered();
-	void on_sources_itemSelectionChanged();
 	void on_sources_customContextMenuRequested(const QPoint &pos);
-	void on_sources_itemDoubleClicked(QListWidgetItem *item);
 	void on_scenes_itemDoubleClicked(QListWidgetItem *item);
 	void on_actionAddSource_triggered();
 	void on_actionRemoveSource_triggered();
@@ -689,8 +682,6 @@ private slots:
 
 	void SceneNameEdited(QWidget *editor,
 			QAbstractItemDelegate::EndEditHint endHint);
-	void SceneItemNameEdited(QWidget *editor,
-			QAbstractItemDelegate::EndEditHint endHint);
 
 	void OpenSceneFilters();
 	void OpenFilters();

+ 100 - 7
UI/window-basic-preview.cpp

@@ -39,6 +39,8 @@ struct SceneFindData {
 	OBSSceneItem item;
 	bool         selectBelow;
 
+	obs_sceneitem_t *group = nullptr;
+
 	SceneFindData(const SceneFindData &) = delete;
 	SceneFindData(SceneFindData &&) = delete;
 	SceneFindData& operator=(const SceneFindData &) = delete;
@@ -214,11 +216,26 @@ static bool CheckItemSelected(obs_scene_t *scene, obs_sceneitem_t *item,
 
 	if (!SceneItemHasVideo(item))
 		return true;
+	if (obs_sceneitem_is_group(item)) {
+		data->group = item;
+		obs_sceneitem_group_enum_items(item, CheckItemSelected, param);
+		data->group = nullptr;
+
+		if (data->item) {
+			return false;
+		}
+	}
 
 	vec3_set(&pos3, data->pos.x, data->pos.y, 0.0f);
 
 	obs_sceneitem_get_box_transform(item, &transform);
 
+	if (data->group) {
+		matrix4 parent_transform;
+		obs_sceneitem_get_draw_transform(data->group, &parent_transform);
+		matrix4_mul(&transform, &transform, &parent_transform);
+	}
+
 	matrix4_inv(&transform, &transform);
 	vec3_transform(&transformedPos, &pos3, &transform);
 
@@ -268,10 +285,35 @@ struct HandleFindData {
 static bool FindHandleAtPos(obs_scene_t *scene, obs_sceneitem_t *item,
 		void *param)
 {
-	if (!obs_sceneitem_selected(item))
+	HandleFindData *data = reinterpret_cast<HandleFindData*>(param);
+
+	if (!obs_sceneitem_selected(item)) {
+		if (obs_sceneitem_is_group(item)) {
+			matrix4 transform;
+			vec3 new_pos3;
+			vec3_set(&new_pos3, data->pos.x, data->pos.y, 0.0f);
+			vec3_divf(&new_pos3, &new_pos3, data->scale);
+
+			obs_sceneitem_get_draw_transform(item, &transform);
+			matrix4_inv(&transform, &transform);
+			vec3_transform(&new_pos3, &new_pos3, &transform);
+
+			vec2 new_pos;
+			vec2_set(&new_pos, new_pos3.x, new_pos3.y);
+			HandleFindData findData(new_pos, 1.0f);
+			findData.item = data->item;
+			findData.handle = data->handle;
+
+			obs_sceneitem_group_enum_items(item, FindHandleAtPos,
+					&findData);
+
+			data->item = findData.item;
+			data->handle = findData.handle;
+		}
+
 		return true;
+	}
 
-	HandleFindData *data = reinterpret_cast<HandleFindData*>(param);
 	matrix4        transform;
 	vec3           pos3;
 	float          closestHandle = HANDLE_SEL_RADIUS;
@@ -377,6 +419,15 @@ void OBSBasicPreview::GetStretchHandleData(const vec2 &pos)
 				startCrop.left - startCrop.right);
 		cropSize.y = float(obs_source_get_height(source) -
 				startCrop.top - startCrop.bottom);
+
+		stretchGroup = obs_sceneitem_get_group(stretchItem);
+		if (stretchGroup) {
+			obs_sceneitem_get_draw_transform(stretchGroup,
+					&invGroupTransform);
+			matrix4_inv(&invGroupTransform,
+					&invGroupTransform);
+			obs_sceneitem_defer_group_resize_begin(stretchGroup);
+		}
 	}
 }
 
@@ -482,6 +533,9 @@ static bool select_one(obs_scene_t *scene, obs_sceneitem_t *item, void *param)
 {
 	obs_sceneitem_t *selectedItem =
 		reinterpret_cast<obs_sceneitem_t*>(param);
+	if (obs_sceneitem_is_group(item))
+		obs_sceneitem_group_enum_items(item, select_one, param);
+
 	obs_sceneitem_select(item, (selectedItem == item));
 
 	UNUSED_PARAMETER(scene);
@@ -534,10 +588,15 @@ void OBSBasicPreview::mouseReleaseEvent(QMouseEvent *event)
 		if (!mouseMoved)
 			ProcessClick(pos);
 
-		stretchItem = nullptr;
-		mouseDown   = false;
-		mouseMoved  = false;
-		cropping    = false;
+		if (stretchGroup) {
+			obs_sceneitem_defer_group_resize_end(stretchGroup);
+		}
+
+		stretchItem  = nullptr;
+		stretchGroup = nullptr;
+		mouseDown    = false;
+		mouseMoved   = false;
+		cropping     = false;
 	}
 }
 
@@ -692,9 +751,22 @@ static bool move_items(obs_scene_t *scene, obs_sceneitem_t *item, void *param)
 	if (obs_sceneitem_locked(item))
 		return true;
 
+	bool selected = obs_sceneitem_selected(item);
 	vec2 *offset = reinterpret_cast<vec2*>(param);
 
-	if (obs_sceneitem_selected(item)) {
+	if (obs_sceneitem_is_group(item) && !selected) {
+		matrix4 transform;
+		vec3 new_offset;
+		vec3_set(&new_offset, offset->x, offset->y, 0.0f);
+
+		obs_sceneitem_get_draw_transform(item, &transform);
+		vec4_set(&transform.t, 0.0f, 0.0f, 0.0f, 1.0f);
+		matrix4_inv(&transform, &transform);
+		vec3_transform(&new_offset, &new_offset, &transform);
+		obs_sceneitem_group_enum_items(item, move_items, &new_offset);
+	}
+
+	if (selected) {
 		vec2 pos;
 		obs_sceneitem_get_pos(item, &pos);
 		vec2_add(&pos, &pos, offset);
@@ -1063,6 +1135,17 @@ void OBSBasicPreview::mouseMoveEvent(QMouseEvent *event)
 		pos.y = std::round(pos.y);
 
 		if (stretchHandle != ItemHandle::None) {
+			obs_sceneitem_t *group = obs_sceneitem_get_group(
+					stretchItem);
+			if (group) {
+				vec3 group_pos;
+				vec3_set(&group_pos, pos.x, pos.y, 0.0f);
+				vec3_transform(&group_pos, &group_pos,
+						&invGroupTransform);
+				pos.x = group_pos.x;
+				pos.y = group_pos.y;
+			}
+
 			if (cropping)
 				CropItem(pos);
 			else
@@ -1110,6 +1193,16 @@ bool OBSBasicPreview::DrawSelectedItem(obs_scene_t *scene,
 	if (!SceneItemHasVideo(item))
 		return true;
 
+	if (obs_sceneitem_is_group(item)) {
+		matrix4 mat;
+		obs_sceneitem_get_draw_transform(item, &mat);
+
+		gs_matrix_push();
+		gs_matrix_mul(&mat);
+		obs_sceneitem_group_enum_items(item, DrawSelectedItem, param);
+		gs_matrix_pop();
+	}
+
 	if (!obs_sceneitem_selected(item))
 		return true;
 

+ 2 - 0
UI/window-basic-preview.hpp

@@ -35,11 +35,13 @@ private:
 	obs_sceneitem_crop startCrop;
 	vec2         startItemPos;
 	vec2         cropSize;
+	OBSSceneItem stretchGroup;
 	OBSSceneItem stretchItem;
 	ItemHandle   stretchHandle = ItemHandle::None;
 	vec2         stretchItemSize;
 	matrix4      screenToItem;
 	matrix4      itemToScreen;
+	matrix4      invGroupTransform;
 
 	vec2         startPos;
 	vec2         lastMoveOffset;