浏览代码

frontend: Prepare Qt UI components for splits

PatTheMav 11 月之前
父节点
当前提交
819850c0f6
共有 40 个文件被更改,包括 15575 次插入0 次删除
  1. 0 0
      frontend/components/ApplicationAudioCaptureToolbar.cpp
  2. 0 0
      frontend/components/ApplicationAudioCaptureToolbar.hpp
  3. 707 0
      frontend/components/AudioCaptureToolbar.cpp
  4. 178 0
      frontend/components/AudioCaptureToolbar.hpp
  5. 707 0
      frontend/components/BrowserToolbar.cpp
  6. 178 0
      frontend/components/BrowserToolbar.hpp
  7. 707 0
      frontend/components/ColorSourceToolbar.cpp
  8. 178 0
      frontend/components/ColorSourceToolbar.hpp
  9. 707 0
      frontend/components/ComboSelectToolbar.cpp
  10. 178 0
      frontend/components/ComboSelectToolbar.hpp
  11. 707 0
      frontend/components/DeviceCaptureToolbar.cpp
  12. 178 0
      frontend/components/DeviceCaptureToolbar.hpp
  13. 707 0
      frontend/components/DisplayCaptureToolbar.cpp
  14. 178 0
      frontend/components/DisplayCaptureToolbar.hpp
  15. 707 0
      frontend/components/GameCaptureToolbar.cpp
  16. 178 0
      frontend/components/GameCaptureToolbar.hpp
  17. 707 0
      frontend/components/ImageSourceToolbar.cpp
  18. 178 0
      frontend/components/ImageSourceToolbar.hpp
  19. 0 0
      frontend/components/OBSPreviewScalingComboBox.cpp
  20. 0 0
      frontend/components/OBSPreviewScalingComboBox.hpp
  21. 133 0
      frontend/components/OBSPreviewScalingLabel.cpp
  22. 79 0
      frontend/components/OBSPreviewScalingLabel.hpp
  23. 707 0
      frontend/components/SourceToolbar.cpp
  24. 178 0
      frontend/components/SourceToolbar.hpp
  25. 0 0
      frontend/components/SourceTree.cpp
  26. 0 0
      frontend/components/SourceTree.hpp
  27. 1613 0
      frontend/components/SourceTreeDelegate.cpp
  28. 202 0
      frontend/components/SourceTreeDelegate.hpp
  29. 1613 0
      frontend/components/SourceTreeItem.cpp
  30. 202 0
      frontend/components/SourceTreeItem.hpp
  31. 1613 0
      frontend/components/SourceTreeModel.cpp
  32. 202 0
      frontend/components/SourceTreeModel.hpp
  33. 707 0
      frontend/components/TextSourceToolbar.cpp
  34. 178 0
      frontend/components/TextSourceToolbar.hpp
  35. 0 0
      frontend/components/VisibilityItemDelegate.cpp
  36. 0 0
      frontend/components/VisibilityItemDelegate.hpp
  37. 133 0
      frontend/components/VisibilityItemWidget.cpp
  38. 50 0
      frontend/components/VisibilityItemWidget.hpp
  39. 707 0
      frontend/components/WindowCaptureToolbar.cpp
  40. 178 0
      frontend/components/WindowCaptureToolbar.hpp

+ 0 - 0
UI/context-bar-controls.cpp → frontend/components/ApplicationAudioCaptureToolbar.cpp


+ 0 - 0
UI/context-bar-controls.hpp → frontend/components/ApplicationAudioCaptureToolbar.hpp


+ 707 - 0
frontend/components/AudioCaptureToolbar.cpp

@@ -0,0 +1,707 @@
+#include "window-basic-main.hpp"
+#include "moc_context-bar-controls.cpp"
+#include "obs-app.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QStandardItemModel>
+#include <QColorDialog>
+#include <QFontDialog>
+
+#include "ui_browser-source-toolbar.h"
+#include "ui_device-select-toolbar.h"
+#include "ui_game-capture-toolbar.h"
+#include "ui_image-source-toolbar.h"
+#include "ui_color-source-toolbar.h"
+#include "ui_text-source-toolbar.h"
+
+#ifdef _WIN32
+#define get_os_module(win, mac, linux) obs_get_module(win)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, win)
+#elif __APPLE__
+#define get_os_module(win, mac, linux) obs_get_module(mac)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, mac)
+#else
+#define get_os_module(win, mac, linux) obs_get_module(linux)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, linux)
+#endif
+
+/* ========================================================================= */
+
+SourceToolbar::SourceToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  props(obs_source_properties(source), obs_properties_destroy)
+{
+}
+
+void SourceToolbar::SaveOldProperties(obs_source_t *source)
+{
+	oldData = obs_data_create();
+
+	OBSDataAutoRelease oldSettings = obs_source_get_settings(source);
+	obs_data_apply(oldData, oldSettings);
+	obs_data_set_string(oldData, "undo_suuid", obs_source_get_uuid(source));
+}
+
+void SourceToolbar::SetUndoProperties(obs_source_t *source, bool repeatable)
+{
+	if (!oldData) {
+		blog(LOG_ERROR, "%s: somehow oldData was null.", __FUNCTION__);
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+	OBSSource currentSceneSource = main->GetCurrentSceneSource();
+	if (!currentSceneSource)
+		return;
+	std::string scene_uuid = obs_source_get_uuid(currentSceneSource);
+	auto undo_redo = [scene_uuid = std::move(scene_uuid), main](const std::string &data) {
+		OBSDataAutoRelease settings = obs_data_create_from_json(data.c_str());
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(obs_data_get_string(settings, "undo_suuid"));
+		obs_source_reset_settings(source, settings);
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+
+		main->UpdateContextBar();
+	};
+
+	OBSDataAutoRelease new_settings = obs_data_create();
+	OBSDataAutoRelease curr_settings = obs_source_get_settings(source);
+	obs_data_apply(new_settings, curr_settings);
+	obs_data_set_string(new_settings, "undo_suuid", obs_source_get_uuid(source));
+
+	std::string undo_data(obs_data_get_json(oldData));
+	std::string redo_data(obs_data_get_json(new_settings));
+
+	if (undo_data.compare(redo_data) != 0)
+		main->undo_s.add_action(QTStr("Undo.Properties").arg(obs_source_get_name(source)), undo_redo, undo_redo,
+					undo_data, redo_data, repeatable);
+
+	oldData = nullptr;
+}
+
+/* ========================================================================= */
+
+BrowserToolbar::BrowserToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_BrowserSourceToolbar)
+{
+	ui->setupUi(this);
+}
+
+BrowserToolbar::~BrowserToolbar() {}
+
+void BrowserToolbar::on_refresh_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "refreshnocache");
+	obs_property_button_clicked(p, source.Get());
+}
+
+/* ========================================================================= */
+
+ComboSelectToolbar::ComboSelectToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+}
+
+ComboSelectToolbar::~ComboSelectToolbar() {}
+
+static int FillPropertyCombo(QComboBox *c, obs_property_t *p, const std::string &cur_id, bool is_int = false)
+{
+	size_t count = obs_property_list_item_count(p);
+	int cur_idx = -1;
+
+	for (size_t i = 0; i < count; i++) {
+		const char *name = obs_property_list_item_name(p, i);
+		std::string id;
+
+		if (is_int) {
+			id = std::to_string(obs_property_list_item_int(p, i));
+		} else {
+			const char *val = obs_property_list_item_string(p, i);
+			id = val ? val : "";
+		}
+
+		if (cur_id == id)
+			cur_idx = (int)i;
+
+		c->addItem(name, id.c_str());
+	}
+
+	return cur_idx;
+}
+
+void UpdateSourceComboToolbarProperties(QComboBox *combo, OBSSource source, obs_properties_t *props,
+					const char *prop_name, bool is_int)
+{
+	std::string cur_id;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (is_int) {
+		cur_id = std::to_string(obs_data_get_int(settings, prop_name));
+	} else {
+		cur_id = obs_data_get_string(settings, prop_name);
+	}
+
+	combo->blockSignals(true);
+
+	obs_property_t *p = obs_properties_get(props, prop_name);
+	int cur_idx = FillPropertyCombo(combo, p, cur_id, is_int);
+
+	if (cur_idx == -1 || obs_property_list_item_disabled(p, cur_idx)) {
+		if (cur_idx == -1) {
+			combo->insertItem(0, QTStr("Basic.Settings.Audio.UnknownAudioDevice"));
+			cur_idx = 0;
+		}
+
+		SetComboItemEnabled(combo, cur_idx, false);
+	}
+
+	combo->setCurrentIndex(cur_idx);
+	combo->blockSignals(false);
+}
+
+void ComboSelectToolbar::Init()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	UpdateSourceComboToolbarProperties(ui->device, source, props.get(), prop_name, is_int);
+}
+
+void UpdateSourceComboToolbarValue(QComboBox *combo, OBSSource source, int idx, const char *prop_name, bool is_int)
+{
+	QString id = combo->itemData(idx).toString();
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (is_int) {
+		obs_data_set_int(settings, prop_name, id.toInt());
+	} else {
+		obs_data_set_string(settings, prop_name, QT_TO_UTF8(id));
+	}
+	obs_source_update(source, settings);
+}
+
+void ComboSelectToolbar::on_device_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	SaveOldProperties(source);
+	UpdateSourceComboToolbarValue(ui->device, source, idx, prop_name, is_int);
+	SetUndoProperties(source);
+}
+
+AudioCaptureToolbar::AudioCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void AudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-wasapi", "mac-capture", "linux-pulseaudio");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Device", "CoreAudio.Device", "Device");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "device_id";
+
+	ComboSelectToolbar::Init();
+}
+
+WindowCaptureToolbar::WindowCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void WindowCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "WindowCapture.Window", "WindowUtils.Window", "Window");
+	ui->deviceLabel->setText(device_str);
+
+#if !defined(_WIN32) && !defined(__APPLE__) //linux
+	prop_name = "capture_window";
+#else
+	prop_name = "window";
+#endif
+
+#ifdef __APPLE__
+	is_int = true;
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+ApplicationAudioCaptureToolbar::ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source)
+	: ComboSelectToolbar(parent, source)
+{
+}
+
+void ApplicationAudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = obs_get_module("win-wasapi");
+	const char *device_str = obs_module_get_locale_text(mod, "Window");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "window";
+
+	ComboSelectToolbar::Init();
+}
+
+DisplayCaptureToolbar::DisplayCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void DisplayCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Monitor", "DisplayCapture.Display", "Screen");
+	ui->deviceLabel->setText(device_str);
+
+#ifdef _WIN32
+	prop_name = "monitor_id";
+#elif __APPLE__
+	prop_name = "display_uuid";
+#else
+	is_int = true;
+	prop_name = "screen";
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+/* ========================================================================= */
+
+DeviceCaptureToolbar::DeviceCaptureToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+
+	delete ui->deviceLabel;
+	delete ui->device;
+	ui->deviceLabel = nullptr;
+	ui->device = nullptr;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	active = obs_data_get_bool(settings, "active");
+
+	obs_module_t *mod = obs_get_module("win-dshow");
+	if (!mod)
+		return;
+
+	activateText = obs_module_get_locale_text(mod, "Activate");
+	deactivateText = obs_module_get_locale_text(mod, "Deactivate");
+
+	ui->activateButton->setText(active ? deactivateText : activateText);
+}
+
+DeviceCaptureToolbar::~DeviceCaptureToolbar() {}
+
+void DeviceCaptureToolbar::on_activateButton_clicked()
+{
+	OBSSource source = OBSGetStrongRef(weakSource);
+	if (!source) {
+		return;
+	}
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	bool now_active = obs_data_get_bool(settings, "active");
+
+	bool desyncedSetting = now_active != active;
+
+	active = !active;
+
+	const char *text = active ? deactivateText : activateText;
+	ui->activateButton->setText(text);
+
+	if (desyncedSetting) {
+		return;
+	}
+
+	calldata_t cd = {};
+	calldata_set_bool(&cd, "active", active);
+	proc_handler_t *ph = obs_source_get_proc_handler(source);
+	proc_handler_call(ph, "activate", &cd);
+	calldata_free(&cd);
+}
+
+/* ========================================================================= */
+
+GameCaptureToolbar::GameCaptureToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_GameCaptureToolbar)
+{
+	obs_property_t *p;
+	int cur_idx;
+
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("win-capture");
+	if (!mod)
+		return;
+
+	ui->modeLabel->setText(obs_module_get_locale_text(mod, "Mode"));
+	ui->windowLabel->setText(obs_module_get_locale_text(mod, "WindowCapture.Window"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string cur_mode = obs_data_get_string(settings, "capture_mode");
+	std::string cur_window = obs_data_get_string(settings, "window");
+
+	ui->mode->blockSignals(true);
+	p = obs_properties_get(props.get(), "capture_mode");
+	cur_idx = FillPropertyCombo(ui->mode, p, cur_mode);
+	ui->mode->setCurrentIndex(cur_idx);
+	ui->mode->blockSignals(false);
+
+	ui->window->blockSignals(true);
+	p = obs_properties_get(props.get(), "window");
+	cur_idx = FillPropertyCombo(ui->window, p, cur_window);
+	ui->window->setCurrentIndex(cur_idx);
+	ui->window->blockSignals(false);
+
+	if (cur_idx != -1 && obs_property_list_item_disabled(p, cur_idx)) {
+		SetComboItemEnabled(ui->window, cur_idx, false);
+	}
+
+	UpdateWindowVisibility();
+}
+
+GameCaptureToolbar::~GameCaptureToolbar() {}
+
+void GameCaptureToolbar::UpdateWindowVisibility()
+{
+	QString mode = ui->mode->currentData().toString();
+	bool is_window = (mode == "window");
+	ui->windowLabel->setVisible(is_window);
+	ui->window->setVisible(is_window);
+}
+
+void GameCaptureToolbar::on_mode_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->mode->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "capture_mode", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+
+	UpdateWindowVisibility();
+}
+
+void GameCaptureToolbar::on_window_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->window->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "window", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+ImageSourceToolbar::ImageSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ImageSourceToolbar)
+{
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("image-source");
+	ui->pathLabel->setText(obs_module_get_locale_text(mod, "File"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string file = obs_data_get_string(settings, "file");
+
+	ui->path->setText(file.c_str());
+}
+
+ImageSourceToolbar::~ImageSourceToolbar() {}
+
+void ImageSourceToolbar::on_browse_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "file");
+	const char *desc = obs_property_description(p);
+	const char *filter = obs_property_path_filter(p);
+	const char *default_path = obs_property_path_default_path(p);
+
+	QString startDir = ui->path->text();
+	if (startDir.isEmpty())
+		startDir = default_path;
+
+	QString path = OpenFile(this, desc, startDir, filter);
+	if (path.isEmpty()) {
+		return;
+	}
+
+	ui->path->setText(path);
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "file", QT_TO_UTF8(path));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+static inline QColor color_from_int(long long val)
+{
+	return QColor(val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, (val >> 24) & 0xff);
+}
+
+static inline long long color_to_int(QColor color)
+{
+	auto shift = [&](unsigned val, int shift) {
+		return ((val & 0xff) << shift);
+	};
+
+	return shift(color.red(), 0) | shift(color.green(), 8) | shift(color.blue(), 16) | shift(color.alpha(), 24);
+}
+
+ColorSourceToolbar::ColorSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ColorSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	unsigned int val = (unsigned int)obs_data_get_int(settings, "color");
+
+	color = color_from_int(val);
+	UpdateColor();
+}
+
+ColorSourceToolbar::~ColorSourceToolbar() {}
+
+void ColorSourceToolbar::UpdateColor()
+{
+	QPalette palette = QPalette(color);
+	ui->color->setFrameStyle(QFrame::Sunken | QFrame::Panel);
+	ui->color->setText(color.name(QColor::HexRgb));
+	ui->color->setPalette(palette);
+	ui->color->setStyleSheet(QString("background-color :%1; color: %2;")
+					 .arg(palette.color(QPalette::Window).name(QColor::HexRgb))
+					 .arg(palette.color(QPalette::WindowText).name(QColor::HexRgb)));
+	ui->color->setAutoFillBackground(true);
+	ui->color->setAlignment(Qt::AlignCenter);
+}
+
+void ColorSourceToolbar::on_choose_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "color");
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+	UpdateColor();
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_int(settings, "color", color_to_int(color));
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+extern void MakeQFont(obs_data_t *font_obj, QFont &font, bool limit = false);
+
+TextSourceToolbar::TextSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_TextSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+
+	const char *id = obs_source_get_unversioned_id(source);
+	bool ft2 = strcmp(id, "text_ft2_source") == 0;
+	bool read_from_file = obs_data_get_bool(settings, ft2 ? "from_file" : "read_from_file");
+
+	OBSDataAutoRelease font_obj = obs_data_get_obj(settings, "font");
+	MakeQFont(font_obj, font);
+
+	// Use "color1" if it's a freetype source and "color" elsewise
+	unsigned int val = (unsigned int)obs_data_get_int(
+		settings, (strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0) ? "color1" : "color");
+
+	color = color_from_int(val);
+
+	const char *text = obs_data_get_string(settings, "text");
+
+	bool single_line = !read_from_file && (!text || (strchr(text, '\n') == nullptr));
+	ui->emptySpace->setVisible(!single_line);
+	ui->text->setVisible(single_line);
+	if (single_line)
+		ui->text->setText(text);
+}
+
+TextSourceToolbar::~TextSourceToolbar() {}
+
+void TextSourceToolbar::on_selectFont_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	QFontDialog::FontDialogOptions options;
+	uint32_t flags;
+	bool success;
+
+#ifndef _WIN32
+	options = QFontDialog::DontUseNativeDialog;
+#endif
+
+	font = QFontDialog::getFont(&success, font, this, QTStr("Basic.PropertiesWindow.SelectFont.WindowTitle"),
+				    options);
+	if (!success) {
+		return;
+	}
+
+	OBSDataAutoRelease font_obj = obs_data_create();
+
+	obs_data_set_string(font_obj, "face", QT_TO_UTF8(font.family()));
+	obs_data_set_string(font_obj, "style", QT_TO_UTF8(font.styleName()));
+	obs_data_set_int(font_obj, "size", font.pointSize());
+	flags = font.bold() ? OBS_FONT_BOLD : 0;
+	flags |= font.italic() ? OBS_FONT_ITALIC : 0;
+	flags |= font.underline() ? OBS_FONT_UNDERLINE : 0;
+	flags |= font.strikeOut() ? OBS_FONT_STRIKEOUT : 0;
+	obs_data_set_int(font_obj, "flags", flags);
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+
+	obs_data_set_obj(settings, "font", font_obj);
+
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_selectColor_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	bool freetype = strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0;
+
+	obs_property_t *p = obs_properties_get(props.get(), freetype ? "color1" : "color");
+
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (freetype) {
+		obs_data_set_int(settings, "color1", color_to_int(color));
+		obs_data_set_int(settings, "color2", color_to_int(color));
+	} else {
+		obs_data_set_int(settings, "color", color_to_int(color));
+	}
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_text_textChanged()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+	std::string newText = QT_TO_UTF8(ui->text->text());
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (newText == obs_data_get_string(settings, "text")) {
+		return;
+	}
+	SaveOldProperties(source);
+
+	obs_data_set_string(settings, "text", newText.c_str());
+	obs_source_update(source, nullptr);
+
+	SetUndoProperties(source, true);
+}

+ 178 - 0
frontend/components/AudioCaptureToolbar.hpp

@@ -0,0 +1,178 @@
+#pragma once
+
+#include <memory>
+#include <obs.hpp>
+#include <QWidget>
+
+class Ui_BrowserSourceToolbar;
+class Ui_DeviceSelectToolbar;
+class Ui_GameCaptureToolbar;
+class Ui_ImageSourceToolbar;
+class Ui_ColorSourceToolbar;
+class Ui_TextSourceToolbar;
+
+class SourceToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+protected:
+	using properties_delete_t = decltype(&obs_properties_destroy);
+	using properties_t = std::unique_ptr<obs_properties_t, properties_delete_t>;
+
+	properties_t props;
+	OBSDataAutoRelease oldData;
+
+	void SaveOldProperties(obs_source_t *source);
+	void SetUndoProperties(obs_source_t *source, bool repeatable = false);
+
+public:
+	SourceToolbar(QWidget *parent, OBSSource source);
+
+	OBSSource GetSource() { return OBSGetStrongRef(weakSource); }
+
+public slots:
+	virtual void Update() {}
+};
+
+class BrowserToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_BrowserSourceToolbar> ui;
+
+public:
+	BrowserToolbar(QWidget *parent, OBSSource source);
+	~BrowserToolbar();
+
+public slots:
+	void on_refresh_clicked();
+};
+
+class ComboSelectToolbar : public SourceToolbar {
+	Q_OBJECT
+
+protected:
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *prop_name;
+	bool is_int = false;
+
+public:
+	ComboSelectToolbar(QWidget *parent, OBSSource source);
+	~ComboSelectToolbar();
+	virtual void Init();
+
+public slots:
+	void on_device_currentIndexChanged(int idx);
+};
+
+class AudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	AudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class WindowCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	WindowCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class ApplicationAudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DisplayCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	DisplayCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DeviceCaptureToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *activateText;
+	const char *deactivateText;
+	bool active;
+
+public:
+	DeviceCaptureToolbar(QWidget *parent, OBSSource source);
+	~DeviceCaptureToolbar();
+
+public slots:
+	void on_activateButton_clicked();
+};
+
+class GameCaptureToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_GameCaptureToolbar> ui;
+
+	void UpdateWindowVisibility();
+
+public:
+	GameCaptureToolbar(QWidget *parent, OBSSource source);
+	~GameCaptureToolbar();
+
+public slots:
+	void on_mode_currentIndexChanged(int idx);
+	void on_window_currentIndexChanged(int idx);
+};
+
+class ImageSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ImageSourceToolbar> ui;
+
+public:
+	ImageSourceToolbar(QWidget *parent, OBSSource source);
+	~ImageSourceToolbar();
+
+public slots:
+	void on_browse_clicked();
+};
+
+class ColorSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ColorSourceToolbar> ui;
+	QColor color;
+
+	void UpdateColor();
+
+public:
+	ColorSourceToolbar(QWidget *parent, OBSSource source);
+	~ColorSourceToolbar();
+
+public slots:
+	void on_choose_clicked();
+};
+
+class TextSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_TextSourceToolbar> ui;
+	QFont font;
+	QColor color;
+
+public:
+	TextSourceToolbar(QWidget *parent, OBSSource source);
+	~TextSourceToolbar();
+
+public slots:
+	void on_selectFont_clicked();
+	void on_selectColor_clicked();
+	void on_text_textChanged();
+};

+ 707 - 0
frontend/components/BrowserToolbar.cpp

@@ -0,0 +1,707 @@
+#include "window-basic-main.hpp"
+#include "moc_context-bar-controls.cpp"
+#include "obs-app.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QStandardItemModel>
+#include <QColorDialog>
+#include <QFontDialog>
+
+#include "ui_browser-source-toolbar.h"
+#include "ui_device-select-toolbar.h"
+#include "ui_game-capture-toolbar.h"
+#include "ui_image-source-toolbar.h"
+#include "ui_color-source-toolbar.h"
+#include "ui_text-source-toolbar.h"
+
+#ifdef _WIN32
+#define get_os_module(win, mac, linux) obs_get_module(win)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, win)
+#elif __APPLE__
+#define get_os_module(win, mac, linux) obs_get_module(mac)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, mac)
+#else
+#define get_os_module(win, mac, linux) obs_get_module(linux)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, linux)
+#endif
+
+/* ========================================================================= */
+
+SourceToolbar::SourceToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  props(obs_source_properties(source), obs_properties_destroy)
+{
+}
+
+void SourceToolbar::SaveOldProperties(obs_source_t *source)
+{
+	oldData = obs_data_create();
+
+	OBSDataAutoRelease oldSettings = obs_source_get_settings(source);
+	obs_data_apply(oldData, oldSettings);
+	obs_data_set_string(oldData, "undo_suuid", obs_source_get_uuid(source));
+}
+
+void SourceToolbar::SetUndoProperties(obs_source_t *source, bool repeatable)
+{
+	if (!oldData) {
+		blog(LOG_ERROR, "%s: somehow oldData was null.", __FUNCTION__);
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+	OBSSource currentSceneSource = main->GetCurrentSceneSource();
+	if (!currentSceneSource)
+		return;
+	std::string scene_uuid = obs_source_get_uuid(currentSceneSource);
+	auto undo_redo = [scene_uuid = std::move(scene_uuid), main](const std::string &data) {
+		OBSDataAutoRelease settings = obs_data_create_from_json(data.c_str());
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(obs_data_get_string(settings, "undo_suuid"));
+		obs_source_reset_settings(source, settings);
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+
+		main->UpdateContextBar();
+	};
+
+	OBSDataAutoRelease new_settings = obs_data_create();
+	OBSDataAutoRelease curr_settings = obs_source_get_settings(source);
+	obs_data_apply(new_settings, curr_settings);
+	obs_data_set_string(new_settings, "undo_suuid", obs_source_get_uuid(source));
+
+	std::string undo_data(obs_data_get_json(oldData));
+	std::string redo_data(obs_data_get_json(new_settings));
+
+	if (undo_data.compare(redo_data) != 0)
+		main->undo_s.add_action(QTStr("Undo.Properties").arg(obs_source_get_name(source)), undo_redo, undo_redo,
+					undo_data, redo_data, repeatable);
+
+	oldData = nullptr;
+}
+
+/* ========================================================================= */
+
+BrowserToolbar::BrowserToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_BrowserSourceToolbar)
+{
+	ui->setupUi(this);
+}
+
+BrowserToolbar::~BrowserToolbar() {}
+
+void BrowserToolbar::on_refresh_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "refreshnocache");
+	obs_property_button_clicked(p, source.Get());
+}
+
+/* ========================================================================= */
+
+ComboSelectToolbar::ComboSelectToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+}
+
+ComboSelectToolbar::~ComboSelectToolbar() {}
+
+static int FillPropertyCombo(QComboBox *c, obs_property_t *p, const std::string &cur_id, bool is_int = false)
+{
+	size_t count = obs_property_list_item_count(p);
+	int cur_idx = -1;
+
+	for (size_t i = 0; i < count; i++) {
+		const char *name = obs_property_list_item_name(p, i);
+		std::string id;
+
+		if (is_int) {
+			id = std::to_string(obs_property_list_item_int(p, i));
+		} else {
+			const char *val = obs_property_list_item_string(p, i);
+			id = val ? val : "";
+		}
+
+		if (cur_id == id)
+			cur_idx = (int)i;
+
+		c->addItem(name, id.c_str());
+	}
+
+	return cur_idx;
+}
+
+void UpdateSourceComboToolbarProperties(QComboBox *combo, OBSSource source, obs_properties_t *props,
+					const char *prop_name, bool is_int)
+{
+	std::string cur_id;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (is_int) {
+		cur_id = std::to_string(obs_data_get_int(settings, prop_name));
+	} else {
+		cur_id = obs_data_get_string(settings, prop_name);
+	}
+
+	combo->blockSignals(true);
+
+	obs_property_t *p = obs_properties_get(props, prop_name);
+	int cur_idx = FillPropertyCombo(combo, p, cur_id, is_int);
+
+	if (cur_idx == -1 || obs_property_list_item_disabled(p, cur_idx)) {
+		if (cur_idx == -1) {
+			combo->insertItem(0, QTStr("Basic.Settings.Audio.UnknownAudioDevice"));
+			cur_idx = 0;
+		}
+
+		SetComboItemEnabled(combo, cur_idx, false);
+	}
+
+	combo->setCurrentIndex(cur_idx);
+	combo->blockSignals(false);
+}
+
+void ComboSelectToolbar::Init()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	UpdateSourceComboToolbarProperties(ui->device, source, props.get(), prop_name, is_int);
+}
+
+void UpdateSourceComboToolbarValue(QComboBox *combo, OBSSource source, int idx, const char *prop_name, bool is_int)
+{
+	QString id = combo->itemData(idx).toString();
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (is_int) {
+		obs_data_set_int(settings, prop_name, id.toInt());
+	} else {
+		obs_data_set_string(settings, prop_name, QT_TO_UTF8(id));
+	}
+	obs_source_update(source, settings);
+}
+
+void ComboSelectToolbar::on_device_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	SaveOldProperties(source);
+	UpdateSourceComboToolbarValue(ui->device, source, idx, prop_name, is_int);
+	SetUndoProperties(source);
+}
+
+AudioCaptureToolbar::AudioCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void AudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-wasapi", "mac-capture", "linux-pulseaudio");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Device", "CoreAudio.Device", "Device");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "device_id";
+
+	ComboSelectToolbar::Init();
+}
+
+WindowCaptureToolbar::WindowCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void WindowCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "WindowCapture.Window", "WindowUtils.Window", "Window");
+	ui->deviceLabel->setText(device_str);
+
+#if !defined(_WIN32) && !defined(__APPLE__) //linux
+	prop_name = "capture_window";
+#else
+	prop_name = "window";
+#endif
+
+#ifdef __APPLE__
+	is_int = true;
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+ApplicationAudioCaptureToolbar::ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source)
+	: ComboSelectToolbar(parent, source)
+{
+}
+
+void ApplicationAudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = obs_get_module("win-wasapi");
+	const char *device_str = obs_module_get_locale_text(mod, "Window");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "window";
+
+	ComboSelectToolbar::Init();
+}
+
+DisplayCaptureToolbar::DisplayCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void DisplayCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Monitor", "DisplayCapture.Display", "Screen");
+	ui->deviceLabel->setText(device_str);
+
+#ifdef _WIN32
+	prop_name = "monitor_id";
+#elif __APPLE__
+	prop_name = "display_uuid";
+#else
+	is_int = true;
+	prop_name = "screen";
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+/* ========================================================================= */
+
+DeviceCaptureToolbar::DeviceCaptureToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+
+	delete ui->deviceLabel;
+	delete ui->device;
+	ui->deviceLabel = nullptr;
+	ui->device = nullptr;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	active = obs_data_get_bool(settings, "active");
+
+	obs_module_t *mod = obs_get_module("win-dshow");
+	if (!mod)
+		return;
+
+	activateText = obs_module_get_locale_text(mod, "Activate");
+	deactivateText = obs_module_get_locale_text(mod, "Deactivate");
+
+	ui->activateButton->setText(active ? deactivateText : activateText);
+}
+
+DeviceCaptureToolbar::~DeviceCaptureToolbar() {}
+
+void DeviceCaptureToolbar::on_activateButton_clicked()
+{
+	OBSSource source = OBSGetStrongRef(weakSource);
+	if (!source) {
+		return;
+	}
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	bool now_active = obs_data_get_bool(settings, "active");
+
+	bool desyncedSetting = now_active != active;
+
+	active = !active;
+
+	const char *text = active ? deactivateText : activateText;
+	ui->activateButton->setText(text);
+
+	if (desyncedSetting) {
+		return;
+	}
+
+	calldata_t cd = {};
+	calldata_set_bool(&cd, "active", active);
+	proc_handler_t *ph = obs_source_get_proc_handler(source);
+	proc_handler_call(ph, "activate", &cd);
+	calldata_free(&cd);
+}
+
+/* ========================================================================= */
+
+GameCaptureToolbar::GameCaptureToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_GameCaptureToolbar)
+{
+	obs_property_t *p;
+	int cur_idx;
+
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("win-capture");
+	if (!mod)
+		return;
+
+	ui->modeLabel->setText(obs_module_get_locale_text(mod, "Mode"));
+	ui->windowLabel->setText(obs_module_get_locale_text(mod, "WindowCapture.Window"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string cur_mode = obs_data_get_string(settings, "capture_mode");
+	std::string cur_window = obs_data_get_string(settings, "window");
+
+	ui->mode->blockSignals(true);
+	p = obs_properties_get(props.get(), "capture_mode");
+	cur_idx = FillPropertyCombo(ui->mode, p, cur_mode);
+	ui->mode->setCurrentIndex(cur_idx);
+	ui->mode->blockSignals(false);
+
+	ui->window->blockSignals(true);
+	p = obs_properties_get(props.get(), "window");
+	cur_idx = FillPropertyCombo(ui->window, p, cur_window);
+	ui->window->setCurrentIndex(cur_idx);
+	ui->window->blockSignals(false);
+
+	if (cur_idx != -1 && obs_property_list_item_disabled(p, cur_idx)) {
+		SetComboItemEnabled(ui->window, cur_idx, false);
+	}
+
+	UpdateWindowVisibility();
+}
+
+GameCaptureToolbar::~GameCaptureToolbar() {}
+
+void GameCaptureToolbar::UpdateWindowVisibility()
+{
+	QString mode = ui->mode->currentData().toString();
+	bool is_window = (mode == "window");
+	ui->windowLabel->setVisible(is_window);
+	ui->window->setVisible(is_window);
+}
+
+void GameCaptureToolbar::on_mode_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->mode->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "capture_mode", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+
+	UpdateWindowVisibility();
+}
+
+void GameCaptureToolbar::on_window_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->window->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "window", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+ImageSourceToolbar::ImageSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ImageSourceToolbar)
+{
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("image-source");
+	ui->pathLabel->setText(obs_module_get_locale_text(mod, "File"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string file = obs_data_get_string(settings, "file");
+
+	ui->path->setText(file.c_str());
+}
+
+ImageSourceToolbar::~ImageSourceToolbar() {}
+
+void ImageSourceToolbar::on_browse_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "file");
+	const char *desc = obs_property_description(p);
+	const char *filter = obs_property_path_filter(p);
+	const char *default_path = obs_property_path_default_path(p);
+
+	QString startDir = ui->path->text();
+	if (startDir.isEmpty())
+		startDir = default_path;
+
+	QString path = OpenFile(this, desc, startDir, filter);
+	if (path.isEmpty()) {
+		return;
+	}
+
+	ui->path->setText(path);
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "file", QT_TO_UTF8(path));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+static inline QColor color_from_int(long long val)
+{
+	return QColor(val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, (val >> 24) & 0xff);
+}
+
+static inline long long color_to_int(QColor color)
+{
+	auto shift = [&](unsigned val, int shift) {
+		return ((val & 0xff) << shift);
+	};
+
+	return shift(color.red(), 0) | shift(color.green(), 8) | shift(color.blue(), 16) | shift(color.alpha(), 24);
+}
+
+ColorSourceToolbar::ColorSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ColorSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	unsigned int val = (unsigned int)obs_data_get_int(settings, "color");
+
+	color = color_from_int(val);
+	UpdateColor();
+}
+
+ColorSourceToolbar::~ColorSourceToolbar() {}
+
+void ColorSourceToolbar::UpdateColor()
+{
+	QPalette palette = QPalette(color);
+	ui->color->setFrameStyle(QFrame::Sunken | QFrame::Panel);
+	ui->color->setText(color.name(QColor::HexRgb));
+	ui->color->setPalette(palette);
+	ui->color->setStyleSheet(QString("background-color :%1; color: %2;")
+					 .arg(palette.color(QPalette::Window).name(QColor::HexRgb))
+					 .arg(palette.color(QPalette::WindowText).name(QColor::HexRgb)));
+	ui->color->setAutoFillBackground(true);
+	ui->color->setAlignment(Qt::AlignCenter);
+}
+
+void ColorSourceToolbar::on_choose_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "color");
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+	UpdateColor();
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_int(settings, "color", color_to_int(color));
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+extern void MakeQFont(obs_data_t *font_obj, QFont &font, bool limit = false);
+
+TextSourceToolbar::TextSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_TextSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+
+	const char *id = obs_source_get_unversioned_id(source);
+	bool ft2 = strcmp(id, "text_ft2_source") == 0;
+	bool read_from_file = obs_data_get_bool(settings, ft2 ? "from_file" : "read_from_file");
+
+	OBSDataAutoRelease font_obj = obs_data_get_obj(settings, "font");
+	MakeQFont(font_obj, font);
+
+	// Use "color1" if it's a freetype source and "color" elsewise
+	unsigned int val = (unsigned int)obs_data_get_int(
+		settings, (strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0) ? "color1" : "color");
+
+	color = color_from_int(val);
+
+	const char *text = obs_data_get_string(settings, "text");
+
+	bool single_line = !read_from_file && (!text || (strchr(text, '\n') == nullptr));
+	ui->emptySpace->setVisible(!single_line);
+	ui->text->setVisible(single_line);
+	if (single_line)
+		ui->text->setText(text);
+}
+
+TextSourceToolbar::~TextSourceToolbar() {}
+
+void TextSourceToolbar::on_selectFont_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	QFontDialog::FontDialogOptions options;
+	uint32_t flags;
+	bool success;
+
+#ifndef _WIN32
+	options = QFontDialog::DontUseNativeDialog;
+#endif
+
+	font = QFontDialog::getFont(&success, font, this, QTStr("Basic.PropertiesWindow.SelectFont.WindowTitle"),
+				    options);
+	if (!success) {
+		return;
+	}
+
+	OBSDataAutoRelease font_obj = obs_data_create();
+
+	obs_data_set_string(font_obj, "face", QT_TO_UTF8(font.family()));
+	obs_data_set_string(font_obj, "style", QT_TO_UTF8(font.styleName()));
+	obs_data_set_int(font_obj, "size", font.pointSize());
+	flags = font.bold() ? OBS_FONT_BOLD : 0;
+	flags |= font.italic() ? OBS_FONT_ITALIC : 0;
+	flags |= font.underline() ? OBS_FONT_UNDERLINE : 0;
+	flags |= font.strikeOut() ? OBS_FONT_STRIKEOUT : 0;
+	obs_data_set_int(font_obj, "flags", flags);
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+
+	obs_data_set_obj(settings, "font", font_obj);
+
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_selectColor_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	bool freetype = strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0;
+
+	obs_property_t *p = obs_properties_get(props.get(), freetype ? "color1" : "color");
+
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (freetype) {
+		obs_data_set_int(settings, "color1", color_to_int(color));
+		obs_data_set_int(settings, "color2", color_to_int(color));
+	} else {
+		obs_data_set_int(settings, "color", color_to_int(color));
+	}
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_text_textChanged()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+	std::string newText = QT_TO_UTF8(ui->text->text());
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (newText == obs_data_get_string(settings, "text")) {
+		return;
+	}
+	SaveOldProperties(source);
+
+	obs_data_set_string(settings, "text", newText.c_str());
+	obs_source_update(source, nullptr);
+
+	SetUndoProperties(source, true);
+}

+ 178 - 0
frontend/components/BrowserToolbar.hpp

@@ -0,0 +1,178 @@
+#pragma once
+
+#include <memory>
+#include <obs.hpp>
+#include <QWidget>
+
+class Ui_BrowserSourceToolbar;
+class Ui_DeviceSelectToolbar;
+class Ui_GameCaptureToolbar;
+class Ui_ImageSourceToolbar;
+class Ui_ColorSourceToolbar;
+class Ui_TextSourceToolbar;
+
+class SourceToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+protected:
+	using properties_delete_t = decltype(&obs_properties_destroy);
+	using properties_t = std::unique_ptr<obs_properties_t, properties_delete_t>;
+
+	properties_t props;
+	OBSDataAutoRelease oldData;
+
+	void SaveOldProperties(obs_source_t *source);
+	void SetUndoProperties(obs_source_t *source, bool repeatable = false);
+
+public:
+	SourceToolbar(QWidget *parent, OBSSource source);
+
+	OBSSource GetSource() { return OBSGetStrongRef(weakSource); }
+
+public slots:
+	virtual void Update() {}
+};
+
+class BrowserToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_BrowserSourceToolbar> ui;
+
+public:
+	BrowserToolbar(QWidget *parent, OBSSource source);
+	~BrowserToolbar();
+
+public slots:
+	void on_refresh_clicked();
+};
+
+class ComboSelectToolbar : public SourceToolbar {
+	Q_OBJECT
+
+protected:
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *prop_name;
+	bool is_int = false;
+
+public:
+	ComboSelectToolbar(QWidget *parent, OBSSource source);
+	~ComboSelectToolbar();
+	virtual void Init();
+
+public slots:
+	void on_device_currentIndexChanged(int idx);
+};
+
+class AudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	AudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class WindowCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	WindowCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class ApplicationAudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DisplayCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	DisplayCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DeviceCaptureToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *activateText;
+	const char *deactivateText;
+	bool active;
+
+public:
+	DeviceCaptureToolbar(QWidget *parent, OBSSource source);
+	~DeviceCaptureToolbar();
+
+public slots:
+	void on_activateButton_clicked();
+};
+
+class GameCaptureToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_GameCaptureToolbar> ui;
+
+	void UpdateWindowVisibility();
+
+public:
+	GameCaptureToolbar(QWidget *parent, OBSSource source);
+	~GameCaptureToolbar();
+
+public slots:
+	void on_mode_currentIndexChanged(int idx);
+	void on_window_currentIndexChanged(int idx);
+};
+
+class ImageSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ImageSourceToolbar> ui;
+
+public:
+	ImageSourceToolbar(QWidget *parent, OBSSource source);
+	~ImageSourceToolbar();
+
+public slots:
+	void on_browse_clicked();
+};
+
+class ColorSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ColorSourceToolbar> ui;
+	QColor color;
+
+	void UpdateColor();
+
+public:
+	ColorSourceToolbar(QWidget *parent, OBSSource source);
+	~ColorSourceToolbar();
+
+public slots:
+	void on_choose_clicked();
+};
+
+class TextSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_TextSourceToolbar> ui;
+	QFont font;
+	QColor color;
+
+public:
+	TextSourceToolbar(QWidget *parent, OBSSource source);
+	~TextSourceToolbar();
+
+public slots:
+	void on_selectFont_clicked();
+	void on_selectColor_clicked();
+	void on_text_textChanged();
+};

+ 707 - 0
frontend/components/ColorSourceToolbar.cpp

@@ -0,0 +1,707 @@
+#include "window-basic-main.hpp"
+#include "moc_context-bar-controls.cpp"
+#include "obs-app.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QStandardItemModel>
+#include <QColorDialog>
+#include <QFontDialog>
+
+#include "ui_browser-source-toolbar.h"
+#include "ui_device-select-toolbar.h"
+#include "ui_game-capture-toolbar.h"
+#include "ui_image-source-toolbar.h"
+#include "ui_color-source-toolbar.h"
+#include "ui_text-source-toolbar.h"
+
+#ifdef _WIN32
+#define get_os_module(win, mac, linux) obs_get_module(win)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, win)
+#elif __APPLE__
+#define get_os_module(win, mac, linux) obs_get_module(mac)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, mac)
+#else
+#define get_os_module(win, mac, linux) obs_get_module(linux)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, linux)
+#endif
+
+/* ========================================================================= */
+
+SourceToolbar::SourceToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  props(obs_source_properties(source), obs_properties_destroy)
+{
+}
+
+void SourceToolbar::SaveOldProperties(obs_source_t *source)
+{
+	oldData = obs_data_create();
+
+	OBSDataAutoRelease oldSettings = obs_source_get_settings(source);
+	obs_data_apply(oldData, oldSettings);
+	obs_data_set_string(oldData, "undo_suuid", obs_source_get_uuid(source));
+}
+
+void SourceToolbar::SetUndoProperties(obs_source_t *source, bool repeatable)
+{
+	if (!oldData) {
+		blog(LOG_ERROR, "%s: somehow oldData was null.", __FUNCTION__);
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+	OBSSource currentSceneSource = main->GetCurrentSceneSource();
+	if (!currentSceneSource)
+		return;
+	std::string scene_uuid = obs_source_get_uuid(currentSceneSource);
+	auto undo_redo = [scene_uuid = std::move(scene_uuid), main](const std::string &data) {
+		OBSDataAutoRelease settings = obs_data_create_from_json(data.c_str());
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(obs_data_get_string(settings, "undo_suuid"));
+		obs_source_reset_settings(source, settings);
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+
+		main->UpdateContextBar();
+	};
+
+	OBSDataAutoRelease new_settings = obs_data_create();
+	OBSDataAutoRelease curr_settings = obs_source_get_settings(source);
+	obs_data_apply(new_settings, curr_settings);
+	obs_data_set_string(new_settings, "undo_suuid", obs_source_get_uuid(source));
+
+	std::string undo_data(obs_data_get_json(oldData));
+	std::string redo_data(obs_data_get_json(new_settings));
+
+	if (undo_data.compare(redo_data) != 0)
+		main->undo_s.add_action(QTStr("Undo.Properties").arg(obs_source_get_name(source)), undo_redo, undo_redo,
+					undo_data, redo_data, repeatable);
+
+	oldData = nullptr;
+}
+
+/* ========================================================================= */
+
+BrowserToolbar::BrowserToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_BrowserSourceToolbar)
+{
+	ui->setupUi(this);
+}
+
+BrowserToolbar::~BrowserToolbar() {}
+
+void BrowserToolbar::on_refresh_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "refreshnocache");
+	obs_property_button_clicked(p, source.Get());
+}
+
+/* ========================================================================= */
+
+ComboSelectToolbar::ComboSelectToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+}
+
+ComboSelectToolbar::~ComboSelectToolbar() {}
+
+static int FillPropertyCombo(QComboBox *c, obs_property_t *p, const std::string &cur_id, bool is_int = false)
+{
+	size_t count = obs_property_list_item_count(p);
+	int cur_idx = -1;
+
+	for (size_t i = 0; i < count; i++) {
+		const char *name = obs_property_list_item_name(p, i);
+		std::string id;
+
+		if (is_int) {
+			id = std::to_string(obs_property_list_item_int(p, i));
+		} else {
+			const char *val = obs_property_list_item_string(p, i);
+			id = val ? val : "";
+		}
+
+		if (cur_id == id)
+			cur_idx = (int)i;
+
+		c->addItem(name, id.c_str());
+	}
+
+	return cur_idx;
+}
+
+void UpdateSourceComboToolbarProperties(QComboBox *combo, OBSSource source, obs_properties_t *props,
+					const char *prop_name, bool is_int)
+{
+	std::string cur_id;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (is_int) {
+		cur_id = std::to_string(obs_data_get_int(settings, prop_name));
+	} else {
+		cur_id = obs_data_get_string(settings, prop_name);
+	}
+
+	combo->blockSignals(true);
+
+	obs_property_t *p = obs_properties_get(props, prop_name);
+	int cur_idx = FillPropertyCombo(combo, p, cur_id, is_int);
+
+	if (cur_idx == -1 || obs_property_list_item_disabled(p, cur_idx)) {
+		if (cur_idx == -1) {
+			combo->insertItem(0, QTStr("Basic.Settings.Audio.UnknownAudioDevice"));
+			cur_idx = 0;
+		}
+
+		SetComboItemEnabled(combo, cur_idx, false);
+	}
+
+	combo->setCurrentIndex(cur_idx);
+	combo->blockSignals(false);
+}
+
+void ComboSelectToolbar::Init()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	UpdateSourceComboToolbarProperties(ui->device, source, props.get(), prop_name, is_int);
+}
+
+void UpdateSourceComboToolbarValue(QComboBox *combo, OBSSource source, int idx, const char *prop_name, bool is_int)
+{
+	QString id = combo->itemData(idx).toString();
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (is_int) {
+		obs_data_set_int(settings, prop_name, id.toInt());
+	} else {
+		obs_data_set_string(settings, prop_name, QT_TO_UTF8(id));
+	}
+	obs_source_update(source, settings);
+}
+
+void ComboSelectToolbar::on_device_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	SaveOldProperties(source);
+	UpdateSourceComboToolbarValue(ui->device, source, idx, prop_name, is_int);
+	SetUndoProperties(source);
+}
+
+AudioCaptureToolbar::AudioCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void AudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-wasapi", "mac-capture", "linux-pulseaudio");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Device", "CoreAudio.Device", "Device");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "device_id";
+
+	ComboSelectToolbar::Init();
+}
+
+WindowCaptureToolbar::WindowCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void WindowCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "WindowCapture.Window", "WindowUtils.Window", "Window");
+	ui->deviceLabel->setText(device_str);
+
+#if !defined(_WIN32) && !defined(__APPLE__) //linux
+	prop_name = "capture_window";
+#else
+	prop_name = "window";
+#endif
+
+#ifdef __APPLE__
+	is_int = true;
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+ApplicationAudioCaptureToolbar::ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source)
+	: ComboSelectToolbar(parent, source)
+{
+}
+
+void ApplicationAudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = obs_get_module("win-wasapi");
+	const char *device_str = obs_module_get_locale_text(mod, "Window");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "window";
+
+	ComboSelectToolbar::Init();
+}
+
+DisplayCaptureToolbar::DisplayCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void DisplayCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Monitor", "DisplayCapture.Display", "Screen");
+	ui->deviceLabel->setText(device_str);
+
+#ifdef _WIN32
+	prop_name = "monitor_id";
+#elif __APPLE__
+	prop_name = "display_uuid";
+#else
+	is_int = true;
+	prop_name = "screen";
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+/* ========================================================================= */
+
+DeviceCaptureToolbar::DeviceCaptureToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+
+	delete ui->deviceLabel;
+	delete ui->device;
+	ui->deviceLabel = nullptr;
+	ui->device = nullptr;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	active = obs_data_get_bool(settings, "active");
+
+	obs_module_t *mod = obs_get_module("win-dshow");
+	if (!mod)
+		return;
+
+	activateText = obs_module_get_locale_text(mod, "Activate");
+	deactivateText = obs_module_get_locale_text(mod, "Deactivate");
+
+	ui->activateButton->setText(active ? deactivateText : activateText);
+}
+
+DeviceCaptureToolbar::~DeviceCaptureToolbar() {}
+
+void DeviceCaptureToolbar::on_activateButton_clicked()
+{
+	OBSSource source = OBSGetStrongRef(weakSource);
+	if (!source) {
+		return;
+	}
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	bool now_active = obs_data_get_bool(settings, "active");
+
+	bool desyncedSetting = now_active != active;
+
+	active = !active;
+
+	const char *text = active ? deactivateText : activateText;
+	ui->activateButton->setText(text);
+
+	if (desyncedSetting) {
+		return;
+	}
+
+	calldata_t cd = {};
+	calldata_set_bool(&cd, "active", active);
+	proc_handler_t *ph = obs_source_get_proc_handler(source);
+	proc_handler_call(ph, "activate", &cd);
+	calldata_free(&cd);
+}
+
+/* ========================================================================= */
+
+GameCaptureToolbar::GameCaptureToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_GameCaptureToolbar)
+{
+	obs_property_t *p;
+	int cur_idx;
+
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("win-capture");
+	if (!mod)
+		return;
+
+	ui->modeLabel->setText(obs_module_get_locale_text(mod, "Mode"));
+	ui->windowLabel->setText(obs_module_get_locale_text(mod, "WindowCapture.Window"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string cur_mode = obs_data_get_string(settings, "capture_mode");
+	std::string cur_window = obs_data_get_string(settings, "window");
+
+	ui->mode->blockSignals(true);
+	p = obs_properties_get(props.get(), "capture_mode");
+	cur_idx = FillPropertyCombo(ui->mode, p, cur_mode);
+	ui->mode->setCurrentIndex(cur_idx);
+	ui->mode->blockSignals(false);
+
+	ui->window->blockSignals(true);
+	p = obs_properties_get(props.get(), "window");
+	cur_idx = FillPropertyCombo(ui->window, p, cur_window);
+	ui->window->setCurrentIndex(cur_idx);
+	ui->window->blockSignals(false);
+
+	if (cur_idx != -1 && obs_property_list_item_disabled(p, cur_idx)) {
+		SetComboItemEnabled(ui->window, cur_idx, false);
+	}
+
+	UpdateWindowVisibility();
+}
+
+GameCaptureToolbar::~GameCaptureToolbar() {}
+
+void GameCaptureToolbar::UpdateWindowVisibility()
+{
+	QString mode = ui->mode->currentData().toString();
+	bool is_window = (mode == "window");
+	ui->windowLabel->setVisible(is_window);
+	ui->window->setVisible(is_window);
+}
+
+void GameCaptureToolbar::on_mode_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->mode->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "capture_mode", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+
+	UpdateWindowVisibility();
+}
+
+void GameCaptureToolbar::on_window_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->window->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "window", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+ImageSourceToolbar::ImageSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ImageSourceToolbar)
+{
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("image-source");
+	ui->pathLabel->setText(obs_module_get_locale_text(mod, "File"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string file = obs_data_get_string(settings, "file");
+
+	ui->path->setText(file.c_str());
+}
+
+ImageSourceToolbar::~ImageSourceToolbar() {}
+
+void ImageSourceToolbar::on_browse_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "file");
+	const char *desc = obs_property_description(p);
+	const char *filter = obs_property_path_filter(p);
+	const char *default_path = obs_property_path_default_path(p);
+
+	QString startDir = ui->path->text();
+	if (startDir.isEmpty())
+		startDir = default_path;
+
+	QString path = OpenFile(this, desc, startDir, filter);
+	if (path.isEmpty()) {
+		return;
+	}
+
+	ui->path->setText(path);
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "file", QT_TO_UTF8(path));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+static inline QColor color_from_int(long long val)
+{
+	return QColor(val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, (val >> 24) & 0xff);
+}
+
+static inline long long color_to_int(QColor color)
+{
+	auto shift = [&](unsigned val, int shift) {
+		return ((val & 0xff) << shift);
+	};
+
+	return shift(color.red(), 0) | shift(color.green(), 8) | shift(color.blue(), 16) | shift(color.alpha(), 24);
+}
+
+ColorSourceToolbar::ColorSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ColorSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	unsigned int val = (unsigned int)obs_data_get_int(settings, "color");
+
+	color = color_from_int(val);
+	UpdateColor();
+}
+
+ColorSourceToolbar::~ColorSourceToolbar() {}
+
+void ColorSourceToolbar::UpdateColor()
+{
+	QPalette palette = QPalette(color);
+	ui->color->setFrameStyle(QFrame::Sunken | QFrame::Panel);
+	ui->color->setText(color.name(QColor::HexRgb));
+	ui->color->setPalette(palette);
+	ui->color->setStyleSheet(QString("background-color :%1; color: %2;")
+					 .arg(palette.color(QPalette::Window).name(QColor::HexRgb))
+					 .arg(palette.color(QPalette::WindowText).name(QColor::HexRgb)));
+	ui->color->setAutoFillBackground(true);
+	ui->color->setAlignment(Qt::AlignCenter);
+}
+
+void ColorSourceToolbar::on_choose_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "color");
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+	UpdateColor();
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_int(settings, "color", color_to_int(color));
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+extern void MakeQFont(obs_data_t *font_obj, QFont &font, bool limit = false);
+
+TextSourceToolbar::TextSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_TextSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+
+	const char *id = obs_source_get_unversioned_id(source);
+	bool ft2 = strcmp(id, "text_ft2_source") == 0;
+	bool read_from_file = obs_data_get_bool(settings, ft2 ? "from_file" : "read_from_file");
+
+	OBSDataAutoRelease font_obj = obs_data_get_obj(settings, "font");
+	MakeQFont(font_obj, font);
+
+	// Use "color1" if it's a freetype source and "color" elsewise
+	unsigned int val = (unsigned int)obs_data_get_int(
+		settings, (strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0) ? "color1" : "color");
+
+	color = color_from_int(val);
+
+	const char *text = obs_data_get_string(settings, "text");
+
+	bool single_line = !read_from_file && (!text || (strchr(text, '\n') == nullptr));
+	ui->emptySpace->setVisible(!single_line);
+	ui->text->setVisible(single_line);
+	if (single_line)
+		ui->text->setText(text);
+}
+
+TextSourceToolbar::~TextSourceToolbar() {}
+
+void TextSourceToolbar::on_selectFont_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	QFontDialog::FontDialogOptions options;
+	uint32_t flags;
+	bool success;
+
+#ifndef _WIN32
+	options = QFontDialog::DontUseNativeDialog;
+#endif
+
+	font = QFontDialog::getFont(&success, font, this, QTStr("Basic.PropertiesWindow.SelectFont.WindowTitle"),
+				    options);
+	if (!success) {
+		return;
+	}
+
+	OBSDataAutoRelease font_obj = obs_data_create();
+
+	obs_data_set_string(font_obj, "face", QT_TO_UTF8(font.family()));
+	obs_data_set_string(font_obj, "style", QT_TO_UTF8(font.styleName()));
+	obs_data_set_int(font_obj, "size", font.pointSize());
+	flags = font.bold() ? OBS_FONT_BOLD : 0;
+	flags |= font.italic() ? OBS_FONT_ITALIC : 0;
+	flags |= font.underline() ? OBS_FONT_UNDERLINE : 0;
+	flags |= font.strikeOut() ? OBS_FONT_STRIKEOUT : 0;
+	obs_data_set_int(font_obj, "flags", flags);
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+
+	obs_data_set_obj(settings, "font", font_obj);
+
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_selectColor_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	bool freetype = strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0;
+
+	obs_property_t *p = obs_properties_get(props.get(), freetype ? "color1" : "color");
+
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (freetype) {
+		obs_data_set_int(settings, "color1", color_to_int(color));
+		obs_data_set_int(settings, "color2", color_to_int(color));
+	} else {
+		obs_data_set_int(settings, "color", color_to_int(color));
+	}
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_text_textChanged()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+	std::string newText = QT_TO_UTF8(ui->text->text());
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (newText == obs_data_get_string(settings, "text")) {
+		return;
+	}
+	SaveOldProperties(source);
+
+	obs_data_set_string(settings, "text", newText.c_str());
+	obs_source_update(source, nullptr);
+
+	SetUndoProperties(source, true);
+}

+ 178 - 0
frontend/components/ColorSourceToolbar.hpp

@@ -0,0 +1,178 @@
+#pragma once
+
+#include <memory>
+#include <obs.hpp>
+#include <QWidget>
+
+class Ui_BrowserSourceToolbar;
+class Ui_DeviceSelectToolbar;
+class Ui_GameCaptureToolbar;
+class Ui_ImageSourceToolbar;
+class Ui_ColorSourceToolbar;
+class Ui_TextSourceToolbar;
+
+class SourceToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+protected:
+	using properties_delete_t = decltype(&obs_properties_destroy);
+	using properties_t = std::unique_ptr<obs_properties_t, properties_delete_t>;
+
+	properties_t props;
+	OBSDataAutoRelease oldData;
+
+	void SaveOldProperties(obs_source_t *source);
+	void SetUndoProperties(obs_source_t *source, bool repeatable = false);
+
+public:
+	SourceToolbar(QWidget *parent, OBSSource source);
+
+	OBSSource GetSource() { return OBSGetStrongRef(weakSource); }
+
+public slots:
+	virtual void Update() {}
+};
+
+class BrowserToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_BrowserSourceToolbar> ui;
+
+public:
+	BrowserToolbar(QWidget *parent, OBSSource source);
+	~BrowserToolbar();
+
+public slots:
+	void on_refresh_clicked();
+};
+
+class ComboSelectToolbar : public SourceToolbar {
+	Q_OBJECT
+
+protected:
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *prop_name;
+	bool is_int = false;
+
+public:
+	ComboSelectToolbar(QWidget *parent, OBSSource source);
+	~ComboSelectToolbar();
+	virtual void Init();
+
+public slots:
+	void on_device_currentIndexChanged(int idx);
+};
+
+class AudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	AudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class WindowCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	WindowCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class ApplicationAudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DisplayCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	DisplayCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DeviceCaptureToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *activateText;
+	const char *deactivateText;
+	bool active;
+
+public:
+	DeviceCaptureToolbar(QWidget *parent, OBSSource source);
+	~DeviceCaptureToolbar();
+
+public slots:
+	void on_activateButton_clicked();
+};
+
+class GameCaptureToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_GameCaptureToolbar> ui;
+
+	void UpdateWindowVisibility();
+
+public:
+	GameCaptureToolbar(QWidget *parent, OBSSource source);
+	~GameCaptureToolbar();
+
+public slots:
+	void on_mode_currentIndexChanged(int idx);
+	void on_window_currentIndexChanged(int idx);
+};
+
+class ImageSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ImageSourceToolbar> ui;
+
+public:
+	ImageSourceToolbar(QWidget *parent, OBSSource source);
+	~ImageSourceToolbar();
+
+public slots:
+	void on_browse_clicked();
+};
+
+class ColorSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ColorSourceToolbar> ui;
+	QColor color;
+
+	void UpdateColor();
+
+public:
+	ColorSourceToolbar(QWidget *parent, OBSSource source);
+	~ColorSourceToolbar();
+
+public slots:
+	void on_choose_clicked();
+};
+
+class TextSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_TextSourceToolbar> ui;
+	QFont font;
+	QColor color;
+
+public:
+	TextSourceToolbar(QWidget *parent, OBSSource source);
+	~TextSourceToolbar();
+
+public slots:
+	void on_selectFont_clicked();
+	void on_selectColor_clicked();
+	void on_text_textChanged();
+};

+ 707 - 0
frontend/components/ComboSelectToolbar.cpp

@@ -0,0 +1,707 @@
+#include "window-basic-main.hpp"
+#include "moc_context-bar-controls.cpp"
+#include "obs-app.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QStandardItemModel>
+#include <QColorDialog>
+#include <QFontDialog>
+
+#include "ui_browser-source-toolbar.h"
+#include "ui_device-select-toolbar.h"
+#include "ui_game-capture-toolbar.h"
+#include "ui_image-source-toolbar.h"
+#include "ui_color-source-toolbar.h"
+#include "ui_text-source-toolbar.h"
+
+#ifdef _WIN32
+#define get_os_module(win, mac, linux) obs_get_module(win)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, win)
+#elif __APPLE__
+#define get_os_module(win, mac, linux) obs_get_module(mac)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, mac)
+#else
+#define get_os_module(win, mac, linux) obs_get_module(linux)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, linux)
+#endif
+
+/* ========================================================================= */
+
+SourceToolbar::SourceToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  props(obs_source_properties(source), obs_properties_destroy)
+{
+}
+
+void SourceToolbar::SaveOldProperties(obs_source_t *source)
+{
+	oldData = obs_data_create();
+
+	OBSDataAutoRelease oldSettings = obs_source_get_settings(source);
+	obs_data_apply(oldData, oldSettings);
+	obs_data_set_string(oldData, "undo_suuid", obs_source_get_uuid(source));
+}
+
+void SourceToolbar::SetUndoProperties(obs_source_t *source, bool repeatable)
+{
+	if (!oldData) {
+		blog(LOG_ERROR, "%s: somehow oldData was null.", __FUNCTION__);
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+	OBSSource currentSceneSource = main->GetCurrentSceneSource();
+	if (!currentSceneSource)
+		return;
+	std::string scene_uuid = obs_source_get_uuid(currentSceneSource);
+	auto undo_redo = [scene_uuid = std::move(scene_uuid), main](const std::string &data) {
+		OBSDataAutoRelease settings = obs_data_create_from_json(data.c_str());
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(obs_data_get_string(settings, "undo_suuid"));
+		obs_source_reset_settings(source, settings);
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+
+		main->UpdateContextBar();
+	};
+
+	OBSDataAutoRelease new_settings = obs_data_create();
+	OBSDataAutoRelease curr_settings = obs_source_get_settings(source);
+	obs_data_apply(new_settings, curr_settings);
+	obs_data_set_string(new_settings, "undo_suuid", obs_source_get_uuid(source));
+
+	std::string undo_data(obs_data_get_json(oldData));
+	std::string redo_data(obs_data_get_json(new_settings));
+
+	if (undo_data.compare(redo_data) != 0)
+		main->undo_s.add_action(QTStr("Undo.Properties").arg(obs_source_get_name(source)), undo_redo, undo_redo,
+					undo_data, redo_data, repeatable);
+
+	oldData = nullptr;
+}
+
+/* ========================================================================= */
+
+BrowserToolbar::BrowserToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_BrowserSourceToolbar)
+{
+	ui->setupUi(this);
+}
+
+BrowserToolbar::~BrowserToolbar() {}
+
+void BrowserToolbar::on_refresh_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "refreshnocache");
+	obs_property_button_clicked(p, source.Get());
+}
+
+/* ========================================================================= */
+
+ComboSelectToolbar::ComboSelectToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+}
+
+ComboSelectToolbar::~ComboSelectToolbar() {}
+
+static int FillPropertyCombo(QComboBox *c, obs_property_t *p, const std::string &cur_id, bool is_int = false)
+{
+	size_t count = obs_property_list_item_count(p);
+	int cur_idx = -1;
+
+	for (size_t i = 0; i < count; i++) {
+		const char *name = obs_property_list_item_name(p, i);
+		std::string id;
+
+		if (is_int) {
+			id = std::to_string(obs_property_list_item_int(p, i));
+		} else {
+			const char *val = obs_property_list_item_string(p, i);
+			id = val ? val : "";
+		}
+
+		if (cur_id == id)
+			cur_idx = (int)i;
+
+		c->addItem(name, id.c_str());
+	}
+
+	return cur_idx;
+}
+
+void UpdateSourceComboToolbarProperties(QComboBox *combo, OBSSource source, obs_properties_t *props,
+					const char *prop_name, bool is_int)
+{
+	std::string cur_id;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (is_int) {
+		cur_id = std::to_string(obs_data_get_int(settings, prop_name));
+	} else {
+		cur_id = obs_data_get_string(settings, prop_name);
+	}
+
+	combo->blockSignals(true);
+
+	obs_property_t *p = obs_properties_get(props, prop_name);
+	int cur_idx = FillPropertyCombo(combo, p, cur_id, is_int);
+
+	if (cur_idx == -1 || obs_property_list_item_disabled(p, cur_idx)) {
+		if (cur_idx == -1) {
+			combo->insertItem(0, QTStr("Basic.Settings.Audio.UnknownAudioDevice"));
+			cur_idx = 0;
+		}
+
+		SetComboItemEnabled(combo, cur_idx, false);
+	}
+
+	combo->setCurrentIndex(cur_idx);
+	combo->blockSignals(false);
+}
+
+void ComboSelectToolbar::Init()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	UpdateSourceComboToolbarProperties(ui->device, source, props.get(), prop_name, is_int);
+}
+
+void UpdateSourceComboToolbarValue(QComboBox *combo, OBSSource source, int idx, const char *prop_name, bool is_int)
+{
+	QString id = combo->itemData(idx).toString();
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (is_int) {
+		obs_data_set_int(settings, prop_name, id.toInt());
+	} else {
+		obs_data_set_string(settings, prop_name, QT_TO_UTF8(id));
+	}
+	obs_source_update(source, settings);
+}
+
+void ComboSelectToolbar::on_device_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	SaveOldProperties(source);
+	UpdateSourceComboToolbarValue(ui->device, source, idx, prop_name, is_int);
+	SetUndoProperties(source);
+}
+
+AudioCaptureToolbar::AudioCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void AudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-wasapi", "mac-capture", "linux-pulseaudio");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Device", "CoreAudio.Device", "Device");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "device_id";
+
+	ComboSelectToolbar::Init();
+}
+
+WindowCaptureToolbar::WindowCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void WindowCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "WindowCapture.Window", "WindowUtils.Window", "Window");
+	ui->deviceLabel->setText(device_str);
+
+#if !defined(_WIN32) && !defined(__APPLE__) //linux
+	prop_name = "capture_window";
+#else
+	prop_name = "window";
+#endif
+
+#ifdef __APPLE__
+	is_int = true;
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+ApplicationAudioCaptureToolbar::ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source)
+	: ComboSelectToolbar(parent, source)
+{
+}
+
+void ApplicationAudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = obs_get_module("win-wasapi");
+	const char *device_str = obs_module_get_locale_text(mod, "Window");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "window";
+
+	ComboSelectToolbar::Init();
+}
+
+DisplayCaptureToolbar::DisplayCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void DisplayCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Monitor", "DisplayCapture.Display", "Screen");
+	ui->deviceLabel->setText(device_str);
+
+#ifdef _WIN32
+	prop_name = "monitor_id";
+#elif __APPLE__
+	prop_name = "display_uuid";
+#else
+	is_int = true;
+	prop_name = "screen";
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+/* ========================================================================= */
+
+DeviceCaptureToolbar::DeviceCaptureToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+
+	delete ui->deviceLabel;
+	delete ui->device;
+	ui->deviceLabel = nullptr;
+	ui->device = nullptr;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	active = obs_data_get_bool(settings, "active");
+
+	obs_module_t *mod = obs_get_module("win-dshow");
+	if (!mod)
+		return;
+
+	activateText = obs_module_get_locale_text(mod, "Activate");
+	deactivateText = obs_module_get_locale_text(mod, "Deactivate");
+
+	ui->activateButton->setText(active ? deactivateText : activateText);
+}
+
+DeviceCaptureToolbar::~DeviceCaptureToolbar() {}
+
+void DeviceCaptureToolbar::on_activateButton_clicked()
+{
+	OBSSource source = OBSGetStrongRef(weakSource);
+	if (!source) {
+		return;
+	}
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	bool now_active = obs_data_get_bool(settings, "active");
+
+	bool desyncedSetting = now_active != active;
+
+	active = !active;
+
+	const char *text = active ? deactivateText : activateText;
+	ui->activateButton->setText(text);
+
+	if (desyncedSetting) {
+		return;
+	}
+
+	calldata_t cd = {};
+	calldata_set_bool(&cd, "active", active);
+	proc_handler_t *ph = obs_source_get_proc_handler(source);
+	proc_handler_call(ph, "activate", &cd);
+	calldata_free(&cd);
+}
+
+/* ========================================================================= */
+
+GameCaptureToolbar::GameCaptureToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_GameCaptureToolbar)
+{
+	obs_property_t *p;
+	int cur_idx;
+
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("win-capture");
+	if (!mod)
+		return;
+
+	ui->modeLabel->setText(obs_module_get_locale_text(mod, "Mode"));
+	ui->windowLabel->setText(obs_module_get_locale_text(mod, "WindowCapture.Window"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string cur_mode = obs_data_get_string(settings, "capture_mode");
+	std::string cur_window = obs_data_get_string(settings, "window");
+
+	ui->mode->blockSignals(true);
+	p = obs_properties_get(props.get(), "capture_mode");
+	cur_idx = FillPropertyCombo(ui->mode, p, cur_mode);
+	ui->mode->setCurrentIndex(cur_idx);
+	ui->mode->blockSignals(false);
+
+	ui->window->blockSignals(true);
+	p = obs_properties_get(props.get(), "window");
+	cur_idx = FillPropertyCombo(ui->window, p, cur_window);
+	ui->window->setCurrentIndex(cur_idx);
+	ui->window->blockSignals(false);
+
+	if (cur_idx != -1 && obs_property_list_item_disabled(p, cur_idx)) {
+		SetComboItemEnabled(ui->window, cur_idx, false);
+	}
+
+	UpdateWindowVisibility();
+}
+
+GameCaptureToolbar::~GameCaptureToolbar() {}
+
+void GameCaptureToolbar::UpdateWindowVisibility()
+{
+	QString mode = ui->mode->currentData().toString();
+	bool is_window = (mode == "window");
+	ui->windowLabel->setVisible(is_window);
+	ui->window->setVisible(is_window);
+}
+
+void GameCaptureToolbar::on_mode_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->mode->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "capture_mode", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+
+	UpdateWindowVisibility();
+}
+
+void GameCaptureToolbar::on_window_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->window->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "window", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+ImageSourceToolbar::ImageSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ImageSourceToolbar)
+{
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("image-source");
+	ui->pathLabel->setText(obs_module_get_locale_text(mod, "File"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string file = obs_data_get_string(settings, "file");
+
+	ui->path->setText(file.c_str());
+}
+
+ImageSourceToolbar::~ImageSourceToolbar() {}
+
+void ImageSourceToolbar::on_browse_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "file");
+	const char *desc = obs_property_description(p);
+	const char *filter = obs_property_path_filter(p);
+	const char *default_path = obs_property_path_default_path(p);
+
+	QString startDir = ui->path->text();
+	if (startDir.isEmpty())
+		startDir = default_path;
+
+	QString path = OpenFile(this, desc, startDir, filter);
+	if (path.isEmpty()) {
+		return;
+	}
+
+	ui->path->setText(path);
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "file", QT_TO_UTF8(path));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+static inline QColor color_from_int(long long val)
+{
+	return QColor(val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, (val >> 24) & 0xff);
+}
+
+static inline long long color_to_int(QColor color)
+{
+	auto shift = [&](unsigned val, int shift) {
+		return ((val & 0xff) << shift);
+	};
+
+	return shift(color.red(), 0) | shift(color.green(), 8) | shift(color.blue(), 16) | shift(color.alpha(), 24);
+}
+
+ColorSourceToolbar::ColorSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ColorSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	unsigned int val = (unsigned int)obs_data_get_int(settings, "color");
+
+	color = color_from_int(val);
+	UpdateColor();
+}
+
+ColorSourceToolbar::~ColorSourceToolbar() {}
+
+void ColorSourceToolbar::UpdateColor()
+{
+	QPalette palette = QPalette(color);
+	ui->color->setFrameStyle(QFrame::Sunken | QFrame::Panel);
+	ui->color->setText(color.name(QColor::HexRgb));
+	ui->color->setPalette(palette);
+	ui->color->setStyleSheet(QString("background-color :%1; color: %2;")
+					 .arg(palette.color(QPalette::Window).name(QColor::HexRgb))
+					 .arg(palette.color(QPalette::WindowText).name(QColor::HexRgb)));
+	ui->color->setAutoFillBackground(true);
+	ui->color->setAlignment(Qt::AlignCenter);
+}
+
+void ColorSourceToolbar::on_choose_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "color");
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+	UpdateColor();
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_int(settings, "color", color_to_int(color));
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+extern void MakeQFont(obs_data_t *font_obj, QFont &font, bool limit = false);
+
+TextSourceToolbar::TextSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_TextSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+
+	const char *id = obs_source_get_unversioned_id(source);
+	bool ft2 = strcmp(id, "text_ft2_source") == 0;
+	bool read_from_file = obs_data_get_bool(settings, ft2 ? "from_file" : "read_from_file");
+
+	OBSDataAutoRelease font_obj = obs_data_get_obj(settings, "font");
+	MakeQFont(font_obj, font);
+
+	// Use "color1" if it's a freetype source and "color" elsewise
+	unsigned int val = (unsigned int)obs_data_get_int(
+		settings, (strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0) ? "color1" : "color");
+
+	color = color_from_int(val);
+
+	const char *text = obs_data_get_string(settings, "text");
+
+	bool single_line = !read_from_file && (!text || (strchr(text, '\n') == nullptr));
+	ui->emptySpace->setVisible(!single_line);
+	ui->text->setVisible(single_line);
+	if (single_line)
+		ui->text->setText(text);
+}
+
+TextSourceToolbar::~TextSourceToolbar() {}
+
+void TextSourceToolbar::on_selectFont_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	QFontDialog::FontDialogOptions options;
+	uint32_t flags;
+	bool success;
+
+#ifndef _WIN32
+	options = QFontDialog::DontUseNativeDialog;
+#endif
+
+	font = QFontDialog::getFont(&success, font, this, QTStr("Basic.PropertiesWindow.SelectFont.WindowTitle"),
+				    options);
+	if (!success) {
+		return;
+	}
+
+	OBSDataAutoRelease font_obj = obs_data_create();
+
+	obs_data_set_string(font_obj, "face", QT_TO_UTF8(font.family()));
+	obs_data_set_string(font_obj, "style", QT_TO_UTF8(font.styleName()));
+	obs_data_set_int(font_obj, "size", font.pointSize());
+	flags = font.bold() ? OBS_FONT_BOLD : 0;
+	flags |= font.italic() ? OBS_FONT_ITALIC : 0;
+	flags |= font.underline() ? OBS_FONT_UNDERLINE : 0;
+	flags |= font.strikeOut() ? OBS_FONT_STRIKEOUT : 0;
+	obs_data_set_int(font_obj, "flags", flags);
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+
+	obs_data_set_obj(settings, "font", font_obj);
+
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_selectColor_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	bool freetype = strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0;
+
+	obs_property_t *p = obs_properties_get(props.get(), freetype ? "color1" : "color");
+
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (freetype) {
+		obs_data_set_int(settings, "color1", color_to_int(color));
+		obs_data_set_int(settings, "color2", color_to_int(color));
+	} else {
+		obs_data_set_int(settings, "color", color_to_int(color));
+	}
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_text_textChanged()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+	std::string newText = QT_TO_UTF8(ui->text->text());
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (newText == obs_data_get_string(settings, "text")) {
+		return;
+	}
+	SaveOldProperties(source);
+
+	obs_data_set_string(settings, "text", newText.c_str());
+	obs_source_update(source, nullptr);
+
+	SetUndoProperties(source, true);
+}

+ 178 - 0
frontend/components/ComboSelectToolbar.hpp

@@ -0,0 +1,178 @@
+#pragma once
+
+#include <memory>
+#include <obs.hpp>
+#include <QWidget>
+
+class Ui_BrowserSourceToolbar;
+class Ui_DeviceSelectToolbar;
+class Ui_GameCaptureToolbar;
+class Ui_ImageSourceToolbar;
+class Ui_ColorSourceToolbar;
+class Ui_TextSourceToolbar;
+
+class SourceToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+protected:
+	using properties_delete_t = decltype(&obs_properties_destroy);
+	using properties_t = std::unique_ptr<obs_properties_t, properties_delete_t>;
+
+	properties_t props;
+	OBSDataAutoRelease oldData;
+
+	void SaveOldProperties(obs_source_t *source);
+	void SetUndoProperties(obs_source_t *source, bool repeatable = false);
+
+public:
+	SourceToolbar(QWidget *parent, OBSSource source);
+
+	OBSSource GetSource() { return OBSGetStrongRef(weakSource); }
+
+public slots:
+	virtual void Update() {}
+};
+
+class BrowserToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_BrowserSourceToolbar> ui;
+
+public:
+	BrowserToolbar(QWidget *parent, OBSSource source);
+	~BrowserToolbar();
+
+public slots:
+	void on_refresh_clicked();
+};
+
+class ComboSelectToolbar : public SourceToolbar {
+	Q_OBJECT
+
+protected:
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *prop_name;
+	bool is_int = false;
+
+public:
+	ComboSelectToolbar(QWidget *parent, OBSSource source);
+	~ComboSelectToolbar();
+	virtual void Init();
+
+public slots:
+	void on_device_currentIndexChanged(int idx);
+};
+
+class AudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	AudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class WindowCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	WindowCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class ApplicationAudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DisplayCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	DisplayCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DeviceCaptureToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *activateText;
+	const char *deactivateText;
+	bool active;
+
+public:
+	DeviceCaptureToolbar(QWidget *parent, OBSSource source);
+	~DeviceCaptureToolbar();
+
+public slots:
+	void on_activateButton_clicked();
+};
+
+class GameCaptureToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_GameCaptureToolbar> ui;
+
+	void UpdateWindowVisibility();
+
+public:
+	GameCaptureToolbar(QWidget *parent, OBSSource source);
+	~GameCaptureToolbar();
+
+public slots:
+	void on_mode_currentIndexChanged(int idx);
+	void on_window_currentIndexChanged(int idx);
+};
+
+class ImageSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ImageSourceToolbar> ui;
+
+public:
+	ImageSourceToolbar(QWidget *parent, OBSSource source);
+	~ImageSourceToolbar();
+
+public slots:
+	void on_browse_clicked();
+};
+
+class ColorSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ColorSourceToolbar> ui;
+	QColor color;
+
+	void UpdateColor();
+
+public:
+	ColorSourceToolbar(QWidget *parent, OBSSource source);
+	~ColorSourceToolbar();
+
+public slots:
+	void on_choose_clicked();
+};
+
+class TextSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_TextSourceToolbar> ui;
+	QFont font;
+	QColor color;
+
+public:
+	TextSourceToolbar(QWidget *parent, OBSSource source);
+	~TextSourceToolbar();
+
+public slots:
+	void on_selectFont_clicked();
+	void on_selectColor_clicked();
+	void on_text_textChanged();
+};

+ 707 - 0
frontend/components/DeviceCaptureToolbar.cpp

@@ -0,0 +1,707 @@
+#include "window-basic-main.hpp"
+#include "moc_context-bar-controls.cpp"
+#include "obs-app.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QStandardItemModel>
+#include <QColorDialog>
+#include <QFontDialog>
+
+#include "ui_browser-source-toolbar.h"
+#include "ui_device-select-toolbar.h"
+#include "ui_game-capture-toolbar.h"
+#include "ui_image-source-toolbar.h"
+#include "ui_color-source-toolbar.h"
+#include "ui_text-source-toolbar.h"
+
+#ifdef _WIN32
+#define get_os_module(win, mac, linux) obs_get_module(win)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, win)
+#elif __APPLE__
+#define get_os_module(win, mac, linux) obs_get_module(mac)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, mac)
+#else
+#define get_os_module(win, mac, linux) obs_get_module(linux)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, linux)
+#endif
+
+/* ========================================================================= */
+
+SourceToolbar::SourceToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  props(obs_source_properties(source), obs_properties_destroy)
+{
+}
+
+void SourceToolbar::SaveOldProperties(obs_source_t *source)
+{
+	oldData = obs_data_create();
+
+	OBSDataAutoRelease oldSettings = obs_source_get_settings(source);
+	obs_data_apply(oldData, oldSettings);
+	obs_data_set_string(oldData, "undo_suuid", obs_source_get_uuid(source));
+}
+
+void SourceToolbar::SetUndoProperties(obs_source_t *source, bool repeatable)
+{
+	if (!oldData) {
+		blog(LOG_ERROR, "%s: somehow oldData was null.", __FUNCTION__);
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+	OBSSource currentSceneSource = main->GetCurrentSceneSource();
+	if (!currentSceneSource)
+		return;
+	std::string scene_uuid = obs_source_get_uuid(currentSceneSource);
+	auto undo_redo = [scene_uuid = std::move(scene_uuid), main](const std::string &data) {
+		OBSDataAutoRelease settings = obs_data_create_from_json(data.c_str());
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(obs_data_get_string(settings, "undo_suuid"));
+		obs_source_reset_settings(source, settings);
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+
+		main->UpdateContextBar();
+	};
+
+	OBSDataAutoRelease new_settings = obs_data_create();
+	OBSDataAutoRelease curr_settings = obs_source_get_settings(source);
+	obs_data_apply(new_settings, curr_settings);
+	obs_data_set_string(new_settings, "undo_suuid", obs_source_get_uuid(source));
+
+	std::string undo_data(obs_data_get_json(oldData));
+	std::string redo_data(obs_data_get_json(new_settings));
+
+	if (undo_data.compare(redo_data) != 0)
+		main->undo_s.add_action(QTStr("Undo.Properties").arg(obs_source_get_name(source)), undo_redo, undo_redo,
+					undo_data, redo_data, repeatable);
+
+	oldData = nullptr;
+}
+
+/* ========================================================================= */
+
+BrowserToolbar::BrowserToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_BrowserSourceToolbar)
+{
+	ui->setupUi(this);
+}
+
+BrowserToolbar::~BrowserToolbar() {}
+
+void BrowserToolbar::on_refresh_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "refreshnocache");
+	obs_property_button_clicked(p, source.Get());
+}
+
+/* ========================================================================= */
+
+ComboSelectToolbar::ComboSelectToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+}
+
+ComboSelectToolbar::~ComboSelectToolbar() {}
+
+static int FillPropertyCombo(QComboBox *c, obs_property_t *p, const std::string &cur_id, bool is_int = false)
+{
+	size_t count = obs_property_list_item_count(p);
+	int cur_idx = -1;
+
+	for (size_t i = 0; i < count; i++) {
+		const char *name = obs_property_list_item_name(p, i);
+		std::string id;
+
+		if (is_int) {
+			id = std::to_string(obs_property_list_item_int(p, i));
+		} else {
+			const char *val = obs_property_list_item_string(p, i);
+			id = val ? val : "";
+		}
+
+		if (cur_id == id)
+			cur_idx = (int)i;
+
+		c->addItem(name, id.c_str());
+	}
+
+	return cur_idx;
+}
+
+void UpdateSourceComboToolbarProperties(QComboBox *combo, OBSSource source, obs_properties_t *props,
+					const char *prop_name, bool is_int)
+{
+	std::string cur_id;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (is_int) {
+		cur_id = std::to_string(obs_data_get_int(settings, prop_name));
+	} else {
+		cur_id = obs_data_get_string(settings, prop_name);
+	}
+
+	combo->blockSignals(true);
+
+	obs_property_t *p = obs_properties_get(props, prop_name);
+	int cur_idx = FillPropertyCombo(combo, p, cur_id, is_int);
+
+	if (cur_idx == -1 || obs_property_list_item_disabled(p, cur_idx)) {
+		if (cur_idx == -1) {
+			combo->insertItem(0, QTStr("Basic.Settings.Audio.UnknownAudioDevice"));
+			cur_idx = 0;
+		}
+
+		SetComboItemEnabled(combo, cur_idx, false);
+	}
+
+	combo->setCurrentIndex(cur_idx);
+	combo->blockSignals(false);
+}
+
+void ComboSelectToolbar::Init()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	UpdateSourceComboToolbarProperties(ui->device, source, props.get(), prop_name, is_int);
+}
+
+void UpdateSourceComboToolbarValue(QComboBox *combo, OBSSource source, int idx, const char *prop_name, bool is_int)
+{
+	QString id = combo->itemData(idx).toString();
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (is_int) {
+		obs_data_set_int(settings, prop_name, id.toInt());
+	} else {
+		obs_data_set_string(settings, prop_name, QT_TO_UTF8(id));
+	}
+	obs_source_update(source, settings);
+}
+
+void ComboSelectToolbar::on_device_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	SaveOldProperties(source);
+	UpdateSourceComboToolbarValue(ui->device, source, idx, prop_name, is_int);
+	SetUndoProperties(source);
+}
+
+AudioCaptureToolbar::AudioCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void AudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-wasapi", "mac-capture", "linux-pulseaudio");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Device", "CoreAudio.Device", "Device");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "device_id";
+
+	ComboSelectToolbar::Init();
+}
+
+WindowCaptureToolbar::WindowCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void WindowCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "WindowCapture.Window", "WindowUtils.Window", "Window");
+	ui->deviceLabel->setText(device_str);
+
+#if !defined(_WIN32) && !defined(__APPLE__) //linux
+	prop_name = "capture_window";
+#else
+	prop_name = "window";
+#endif
+
+#ifdef __APPLE__
+	is_int = true;
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+ApplicationAudioCaptureToolbar::ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source)
+	: ComboSelectToolbar(parent, source)
+{
+}
+
+void ApplicationAudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = obs_get_module("win-wasapi");
+	const char *device_str = obs_module_get_locale_text(mod, "Window");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "window";
+
+	ComboSelectToolbar::Init();
+}
+
+DisplayCaptureToolbar::DisplayCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void DisplayCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Monitor", "DisplayCapture.Display", "Screen");
+	ui->deviceLabel->setText(device_str);
+
+#ifdef _WIN32
+	prop_name = "monitor_id";
+#elif __APPLE__
+	prop_name = "display_uuid";
+#else
+	is_int = true;
+	prop_name = "screen";
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+/* ========================================================================= */
+
+DeviceCaptureToolbar::DeviceCaptureToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+
+	delete ui->deviceLabel;
+	delete ui->device;
+	ui->deviceLabel = nullptr;
+	ui->device = nullptr;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	active = obs_data_get_bool(settings, "active");
+
+	obs_module_t *mod = obs_get_module("win-dshow");
+	if (!mod)
+		return;
+
+	activateText = obs_module_get_locale_text(mod, "Activate");
+	deactivateText = obs_module_get_locale_text(mod, "Deactivate");
+
+	ui->activateButton->setText(active ? deactivateText : activateText);
+}
+
+DeviceCaptureToolbar::~DeviceCaptureToolbar() {}
+
+void DeviceCaptureToolbar::on_activateButton_clicked()
+{
+	OBSSource source = OBSGetStrongRef(weakSource);
+	if (!source) {
+		return;
+	}
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	bool now_active = obs_data_get_bool(settings, "active");
+
+	bool desyncedSetting = now_active != active;
+
+	active = !active;
+
+	const char *text = active ? deactivateText : activateText;
+	ui->activateButton->setText(text);
+
+	if (desyncedSetting) {
+		return;
+	}
+
+	calldata_t cd = {};
+	calldata_set_bool(&cd, "active", active);
+	proc_handler_t *ph = obs_source_get_proc_handler(source);
+	proc_handler_call(ph, "activate", &cd);
+	calldata_free(&cd);
+}
+
+/* ========================================================================= */
+
+GameCaptureToolbar::GameCaptureToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_GameCaptureToolbar)
+{
+	obs_property_t *p;
+	int cur_idx;
+
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("win-capture");
+	if (!mod)
+		return;
+
+	ui->modeLabel->setText(obs_module_get_locale_text(mod, "Mode"));
+	ui->windowLabel->setText(obs_module_get_locale_text(mod, "WindowCapture.Window"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string cur_mode = obs_data_get_string(settings, "capture_mode");
+	std::string cur_window = obs_data_get_string(settings, "window");
+
+	ui->mode->blockSignals(true);
+	p = obs_properties_get(props.get(), "capture_mode");
+	cur_idx = FillPropertyCombo(ui->mode, p, cur_mode);
+	ui->mode->setCurrentIndex(cur_idx);
+	ui->mode->blockSignals(false);
+
+	ui->window->blockSignals(true);
+	p = obs_properties_get(props.get(), "window");
+	cur_idx = FillPropertyCombo(ui->window, p, cur_window);
+	ui->window->setCurrentIndex(cur_idx);
+	ui->window->blockSignals(false);
+
+	if (cur_idx != -1 && obs_property_list_item_disabled(p, cur_idx)) {
+		SetComboItemEnabled(ui->window, cur_idx, false);
+	}
+
+	UpdateWindowVisibility();
+}
+
+GameCaptureToolbar::~GameCaptureToolbar() {}
+
+void GameCaptureToolbar::UpdateWindowVisibility()
+{
+	QString mode = ui->mode->currentData().toString();
+	bool is_window = (mode == "window");
+	ui->windowLabel->setVisible(is_window);
+	ui->window->setVisible(is_window);
+}
+
+void GameCaptureToolbar::on_mode_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->mode->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "capture_mode", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+
+	UpdateWindowVisibility();
+}
+
+void GameCaptureToolbar::on_window_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->window->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "window", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+ImageSourceToolbar::ImageSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ImageSourceToolbar)
+{
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("image-source");
+	ui->pathLabel->setText(obs_module_get_locale_text(mod, "File"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string file = obs_data_get_string(settings, "file");
+
+	ui->path->setText(file.c_str());
+}
+
+ImageSourceToolbar::~ImageSourceToolbar() {}
+
+void ImageSourceToolbar::on_browse_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "file");
+	const char *desc = obs_property_description(p);
+	const char *filter = obs_property_path_filter(p);
+	const char *default_path = obs_property_path_default_path(p);
+
+	QString startDir = ui->path->text();
+	if (startDir.isEmpty())
+		startDir = default_path;
+
+	QString path = OpenFile(this, desc, startDir, filter);
+	if (path.isEmpty()) {
+		return;
+	}
+
+	ui->path->setText(path);
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "file", QT_TO_UTF8(path));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+static inline QColor color_from_int(long long val)
+{
+	return QColor(val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, (val >> 24) & 0xff);
+}
+
+static inline long long color_to_int(QColor color)
+{
+	auto shift = [&](unsigned val, int shift) {
+		return ((val & 0xff) << shift);
+	};
+
+	return shift(color.red(), 0) | shift(color.green(), 8) | shift(color.blue(), 16) | shift(color.alpha(), 24);
+}
+
+ColorSourceToolbar::ColorSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ColorSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	unsigned int val = (unsigned int)obs_data_get_int(settings, "color");
+
+	color = color_from_int(val);
+	UpdateColor();
+}
+
+ColorSourceToolbar::~ColorSourceToolbar() {}
+
+void ColorSourceToolbar::UpdateColor()
+{
+	QPalette palette = QPalette(color);
+	ui->color->setFrameStyle(QFrame::Sunken | QFrame::Panel);
+	ui->color->setText(color.name(QColor::HexRgb));
+	ui->color->setPalette(palette);
+	ui->color->setStyleSheet(QString("background-color :%1; color: %2;")
+					 .arg(palette.color(QPalette::Window).name(QColor::HexRgb))
+					 .arg(palette.color(QPalette::WindowText).name(QColor::HexRgb)));
+	ui->color->setAutoFillBackground(true);
+	ui->color->setAlignment(Qt::AlignCenter);
+}
+
+void ColorSourceToolbar::on_choose_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "color");
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+	UpdateColor();
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_int(settings, "color", color_to_int(color));
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+extern void MakeQFont(obs_data_t *font_obj, QFont &font, bool limit = false);
+
+TextSourceToolbar::TextSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_TextSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+
+	const char *id = obs_source_get_unversioned_id(source);
+	bool ft2 = strcmp(id, "text_ft2_source") == 0;
+	bool read_from_file = obs_data_get_bool(settings, ft2 ? "from_file" : "read_from_file");
+
+	OBSDataAutoRelease font_obj = obs_data_get_obj(settings, "font");
+	MakeQFont(font_obj, font);
+
+	// Use "color1" if it's a freetype source and "color" elsewise
+	unsigned int val = (unsigned int)obs_data_get_int(
+		settings, (strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0) ? "color1" : "color");
+
+	color = color_from_int(val);
+
+	const char *text = obs_data_get_string(settings, "text");
+
+	bool single_line = !read_from_file && (!text || (strchr(text, '\n') == nullptr));
+	ui->emptySpace->setVisible(!single_line);
+	ui->text->setVisible(single_line);
+	if (single_line)
+		ui->text->setText(text);
+}
+
+TextSourceToolbar::~TextSourceToolbar() {}
+
+void TextSourceToolbar::on_selectFont_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	QFontDialog::FontDialogOptions options;
+	uint32_t flags;
+	bool success;
+
+#ifndef _WIN32
+	options = QFontDialog::DontUseNativeDialog;
+#endif
+
+	font = QFontDialog::getFont(&success, font, this, QTStr("Basic.PropertiesWindow.SelectFont.WindowTitle"),
+				    options);
+	if (!success) {
+		return;
+	}
+
+	OBSDataAutoRelease font_obj = obs_data_create();
+
+	obs_data_set_string(font_obj, "face", QT_TO_UTF8(font.family()));
+	obs_data_set_string(font_obj, "style", QT_TO_UTF8(font.styleName()));
+	obs_data_set_int(font_obj, "size", font.pointSize());
+	flags = font.bold() ? OBS_FONT_BOLD : 0;
+	flags |= font.italic() ? OBS_FONT_ITALIC : 0;
+	flags |= font.underline() ? OBS_FONT_UNDERLINE : 0;
+	flags |= font.strikeOut() ? OBS_FONT_STRIKEOUT : 0;
+	obs_data_set_int(font_obj, "flags", flags);
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+
+	obs_data_set_obj(settings, "font", font_obj);
+
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_selectColor_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	bool freetype = strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0;
+
+	obs_property_t *p = obs_properties_get(props.get(), freetype ? "color1" : "color");
+
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (freetype) {
+		obs_data_set_int(settings, "color1", color_to_int(color));
+		obs_data_set_int(settings, "color2", color_to_int(color));
+	} else {
+		obs_data_set_int(settings, "color", color_to_int(color));
+	}
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_text_textChanged()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+	std::string newText = QT_TO_UTF8(ui->text->text());
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (newText == obs_data_get_string(settings, "text")) {
+		return;
+	}
+	SaveOldProperties(source);
+
+	obs_data_set_string(settings, "text", newText.c_str());
+	obs_source_update(source, nullptr);
+
+	SetUndoProperties(source, true);
+}

+ 178 - 0
frontend/components/DeviceCaptureToolbar.hpp

@@ -0,0 +1,178 @@
+#pragma once
+
+#include <memory>
+#include <obs.hpp>
+#include <QWidget>
+
+class Ui_BrowserSourceToolbar;
+class Ui_DeviceSelectToolbar;
+class Ui_GameCaptureToolbar;
+class Ui_ImageSourceToolbar;
+class Ui_ColorSourceToolbar;
+class Ui_TextSourceToolbar;
+
+class SourceToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+protected:
+	using properties_delete_t = decltype(&obs_properties_destroy);
+	using properties_t = std::unique_ptr<obs_properties_t, properties_delete_t>;
+
+	properties_t props;
+	OBSDataAutoRelease oldData;
+
+	void SaveOldProperties(obs_source_t *source);
+	void SetUndoProperties(obs_source_t *source, bool repeatable = false);
+
+public:
+	SourceToolbar(QWidget *parent, OBSSource source);
+
+	OBSSource GetSource() { return OBSGetStrongRef(weakSource); }
+
+public slots:
+	virtual void Update() {}
+};
+
+class BrowserToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_BrowserSourceToolbar> ui;
+
+public:
+	BrowserToolbar(QWidget *parent, OBSSource source);
+	~BrowserToolbar();
+
+public slots:
+	void on_refresh_clicked();
+};
+
+class ComboSelectToolbar : public SourceToolbar {
+	Q_OBJECT
+
+protected:
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *prop_name;
+	bool is_int = false;
+
+public:
+	ComboSelectToolbar(QWidget *parent, OBSSource source);
+	~ComboSelectToolbar();
+	virtual void Init();
+
+public slots:
+	void on_device_currentIndexChanged(int idx);
+};
+
+class AudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	AudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class WindowCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	WindowCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class ApplicationAudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DisplayCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	DisplayCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DeviceCaptureToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *activateText;
+	const char *deactivateText;
+	bool active;
+
+public:
+	DeviceCaptureToolbar(QWidget *parent, OBSSource source);
+	~DeviceCaptureToolbar();
+
+public slots:
+	void on_activateButton_clicked();
+};
+
+class GameCaptureToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_GameCaptureToolbar> ui;
+
+	void UpdateWindowVisibility();
+
+public:
+	GameCaptureToolbar(QWidget *parent, OBSSource source);
+	~GameCaptureToolbar();
+
+public slots:
+	void on_mode_currentIndexChanged(int idx);
+	void on_window_currentIndexChanged(int idx);
+};
+
+class ImageSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ImageSourceToolbar> ui;
+
+public:
+	ImageSourceToolbar(QWidget *parent, OBSSource source);
+	~ImageSourceToolbar();
+
+public slots:
+	void on_browse_clicked();
+};
+
+class ColorSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ColorSourceToolbar> ui;
+	QColor color;
+
+	void UpdateColor();
+
+public:
+	ColorSourceToolbar(QWidget *parent, OBSSource source);
+	~ColorSourceToolbar();
+
+public slots:
+	void on_choose_clicked();
+};
+
+class TextSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_TextSourceToolbar> ui;
+	QFont font;
+	QColor color;
+
+public:
+	TextSourceToolbar(QWidget *parent, OBSSource source);
+	~TextSourceToolbar();
+
+public slots:
+	void on_selectFont_clicked();
+	void on_selectColor_clicked();
+	void on_text_textChanged();
+};

+ 707 - 0
frontend/components/DisplayCaptureToolbar.cpp

@@ -0,0 +1,707 @@
+#include "window-basic-main.hpp"
+#include "moc_context-bar-controls.cpp"
+#include "obs-app.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QStandardItemModel>
+#include <QColorDialog>
+#include <QFontDialog>
+
+#include "ui_browser-source-toolbar.h"
+#include "ui_device-select-toolbar.h"
+#include "ui_game-capture-toolbar.h"
+#include "ui_image-source-toolbar.h"
+#include "ui_color-source-toolbar.h"
+#include "ui_text-source-toolbar.h"
+
+#ifdef _WIN32
+#define get_os_module(win, mac, linux) obs_get_module(win)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, win)
+#elif __APPLE__
+#define get_os_module(win, mac, linux) obs_get_module(mac)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, mac)
+#else
+#define get_os_module(win, mac, linux) obs_get_module(linux)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, linux)
+#endif
+
+/* ========================================================================= */
+
+SourceToolbar::SourceToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  props(obs_source_properties(source), obs_properties_destroy)
+{
+}
+
+void SourceToolbar::SaveOldProperties(obs_source_t *source)
+{
+	oldData = obs_data_create();
+
+	OBSDataAutoRelease oldSettings = obs_source_get_settings(source);
+	obs_data_apply(oldData, oldSettings);
+	obs_data_set_string(oldData, "undo_suuid", obs_source_get_uuid(source));
+}
+
+void SourceToolbar::SetUndoProperties(obs_source_t *source, bool repeatable)
+{
+	if (!oldData) {
+		blog(LOG_ERROR, "%s: somehow oldData was null.", __FUNCTION__);
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+	OBSSource currentSceneSource = main->GetCurrentSceneSource();
+	if (!currentSceneSource)
+		return;
+	std::string scene_uuid = obs_source_get_uuid(currentSceneSource);
+	auto undo_redo = [scene_uuid = std::move(scene_uuid), main](const std::string &data) {
+		OBSDataAutoRelease settings = obs_data_create_from_json(data.c_str());
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(obs_data_get_string(settings, "undo_suuid"));
+		obs_source_reset_settings(source, settings);
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+
+		main->UpdateContextBar();
+	};
+
+	OBSDataAutoRelease new_settings = obs_data_create();
+	OBSDataAutoRelease curr_settings = obs_source_get_settings(source);
+	obs_data_apply(new_settings, curr_settings);
+	obs_data_set_string(new_settings, "undo_suuid", obs_source_get_uuid(source));
+
+	std::string undo_data(obs_data_get_json(oldData));
+	std::string redo_data(obs_data_get_json(new_settings));
+
+	if (undo_data.compare(redo_data) != 0)
+		main->undo_s.add_action(QTStr("Undo.Properties").arg(obs_source_get_name(source)), undo_redo, undo_redo,
+					undo_data, redo_data, repeatable);
+
+	oldData = nullptr;
+}
+
+/* ========================================================================= */
+
+BrowserToolbar::BrowserToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_BrowserSourceToolbar)
+{
+	ui->setupUi(this);
+}
+
+BrowserToolbar::~BrowserToolbar() {}
+
+void BrowserToolbar::on_refresh_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "refreshnocache");
+	obs_property_button_clicked(p, source.Get());
+}
+
+/* ========================================================================= */
+
+ComboSelectToolbar::ComboSelectToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+}
+
+ComboSelectToolbar::~ComboSelectToolbar() {}
+
+static int FillPropertyCombo(QComboBox *c, obs_property_t *p, const std::string &cur_id, bool is_int = false)
+{
+	size_t count = obs_property_list_item_count(p);
+	int cur_idx = -1;
+
+	for (size_t i = 0; i < count; i++) {
+		const char *name = obs_property_list_item_name(p, i);
+		std::string id;
+
+		if (is_int) {
+			id = std::to_string(obs_property_list_item_int(p, i));
+		} else {
+			const char *val = obs_property_list_item_string(p, i);
+			id = val ? val : "";
+		}
+
+		if (cur_id == id)
+			cur_idx = (int)i;
+
+		c->addItem(name, id.c_str());
+	}
+
+	return cur_idx;
+}
+
+void UpdateSourceComboToolbarProperties(QComboBox *combo, OBSSource source, obs_properties_t *props,
+					const char *prop_name, bool is_int)
+{
+	std::string cur_id;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (is_int) {
+		cur_id = std::to_string(obs_data_get_int(settings, prop_name));
+	} else {
+		cur_id = obs_data_get_string(settings, prop_name);
+	}
+
+	combo->blockSignals(true);
+
+	obs_property_t *p = obs_properties_get(props, prop_name);
+	int cur_idx = FillPropertyCombo(combo, p, cur_id, is_int);
+
+	if (cur_idx == -1 || obs_property_list_item_disabled(p, cur_idx)) {
+		if (cur_idx == -1) {
+			combo->insertItem(0, QTStr("Basic.Settings.Audio.UnknownAudioDevice"));
+			cur_idx = 0;
+		}
+
+		SetComboItemEnabled(combo, cur_idx, false);
+	}
+
+	combo->setCurrentIndex(cur_idx);
+	combo->blockSignals(false);
+}
+
+void ComboSelectToolbar::Init()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	UpdateSourceComboToolbarProperties(ui->device, source, props.get(), prop_name, is_int);
+}
+
+void UpdateSourceComboToolbarValue(QComboBox *combo, OBSSource source, int idx, const char *prop_name, bool is_int)
+{
+	QString id = combo->itemData(idx).toString();
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (is_int) {
+		obs_data_set_int(settings, prop_name, id.toInt());
+	} else {
+		obs_data_set_string(settings, prop_name, QT_TO_UTF8(id));
+	}
+	obs_source_update(source, settings);
+}
+
+void ComboSelectToolbar::on_device_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	SaveOldProperties(source);
+	UpdateSourceComboToolbarValue(ui->device, source, idx, prop_name, is_int);
+	SetUndoProperties(source);
+}
+
+AudioCaptureToolbar::AudioCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void AudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-wasapi", "mac-capture", "linux-pulseaudio");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Device", "CoreAudio.Device", "Device");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "device_id";
+
+	ComboSelectToolbar::Init();
+}
+
+WindowCaptureToolbar::WindowCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void WindowCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "WindowCapture.Window", "WindowUtils.Window", "Window");
+	ui->deviceLabel->setText(device_str);
+
+#if !defined(_WIN32) && !defined(__APPLE__) //linux
+	prop_name = "capture_window";
+#else
+	prop_name = "window";
+#endif
+
+#ifdef __APPLE__
+	is_int = true;
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+ApplicationAudioCaptureToolbar::ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source)
+	: ComboSelectToolbar(parent, source)
+{
+}
+
+void ApplicationAudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = obs_get_module("win-wasapi");
+	const char *device_str = obs_module_get_locale_text(mod, "Window");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "window";
+
+	ComboSelectToolbar::Init();
+}
+
+DisplayCaptureToolbar::DisplayCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void DisplayCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Monitor", "DisplayCapture.Display", "Screen");
+	ui->deviceLabel->setText(device_str);
+
+#ifdef _WIN32
+	prop_name = "monitor_id";
+#elif __APPLE__
+	prop_name = "display_uuid";
+#else
+	is_int = true;
+	prop_name = "screen";
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+/* ========================================================================= */
+
+DeviceCaptureToolbar::DeviceCaptureToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+
+	delete ui->deviceLabel;
+	delete ui->device;
+	ui->deviceLabel = nullptr;
+	ui->device = nullptr;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	active = obs_data_get_bool(settings, "active");
+
+	obs_module_t *mod = obs_get_module("win-dshow");
+	if (!mod)
+		return;
+
+	activateText = obs_module_get_locale_text(mod, "Activate");
+	deactivateText = obs_module_get_locale_text(mod, "Deactivate");
+
+	ui->activateButton->setText(active ? deactivateText : activateText);
+}
+
+DeviceCaptureToolbar::~DeviceCaptureToolbar() {}
+
+void DeviceCaptureToolbar::on_activateButton_clicked()
+{
+	OBSSource source = OBSGetStrongRef(weakSource);
+	if (!source) {
+		return;
+	}
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	bool now_active = obs_data_get_bool(settings, "active");
+
+	bool desyncedSetting = now_active != active;
+
+	active = !active;
+
+	const char *text = active ? deactivateText : activateText;
+	ui->activateButton->setText(text);
+
+	if (desyncedSetting) {
+		return;
+	}
+
+	calldata_t cd = {};
+	calldata_set_bool(&cd, "active", active);
+	proc_handler_t *ph = obs_source_get_proc_handler(source);
+	proc_handler_call(ph, "activate", &cd);
+	calldata_free(&cd);
+}
+
+/* ========================================================================= */
+
+GameCaptureToolbar::GameCaptureToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_GameCaptureToolbar)
+{
+	obs_property_t *p;
+	int cur_idx;
+
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("win-capture");
+	if (!mod)
+		return;
+
+	ui->modeLabel->setText(obs_module_get_locale_text(mod, "Mode"));
+	ui->windowLabel->setText(obs_module_get_locale_text(mod, "WindowCapture.Window"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string cur_mode = obs_data_get_string(settings, "capture_mode");
+	std::string cur_window = obs_data_get_string(settings, "window");
+
+	ui->mode->blockSignals(true);
+	p = obs_properties_get(props.get(), "capture_mode");
+	cur_idx = FillPropertyCombo(ui->mode, p, cur_mode);
+	ui->mode->setCurrentIndex(cur_idx);
+	ui->mode->blockSignals(false);
+
+	ui->window->blockSignals(true);
+	p = obs_properties_get(props.get(), "window");
+	cur_idx = FillPropertyCombo(ui->window, p, cur_window);
+	ui->window->setCurrentIndex(cur_idx);
+	ui->window->blockSignals(false);
+
+	if (cur_idx != -1 && obs_property_list_item_disabled(p, cur_idx)) {
+		SetComboItemEnabled(ui->window, cur_idx, false);
+	}
+
+	UpdateWindowVisibility();
+}
+
+GameCaptureToolbar::~GameCaptureToolbar() {}
+
+void GameCaptureToolbar::UpdateWindowVisibility()
+{
+	QString mode = ui->mode->currentData().toString();
+	bool is_window = (mode == "window");
+	ui->windowLabel->setVisible(is_window);
+	ui->window->setVisible(is_window);
+}
+
+void GameCaptureToolbar::on_mode_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->mode->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "capture_mode", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+
+	UpdateWindowVisibility();
+}
+
+void GameCaptureToolbar::on_window_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->window->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "window", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+ImageSourceToolbar::ImageSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ImageSourceToolbar)
+{
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("image-source");
+	ui->pathLabel->setText(obs_module_get_locale_text(mod, "File"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string file = obs_data_get_string(settings, "file");
+
+	ui->path->setText(file.c_str());
+}
+
+ImageSourceToolbar::~ImageSourceToolbar() {}
+
+void ImageSourceToolbar::on_browse_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "file");
+	const char *desc = obs_property_description(p);
+	const char *filter = obs_property_path_filter(p);
+	const char *default_path = obs_property_path_default_path(p);
+
+	QString startDir = ui->path->text();
+	if (startDir.isEmpty())
+		startDir = default_path;
+
+	QString path = OpenFile(this, desc, startDir, filter);
+	if (path.isEmpty()) {
+		return;
+	}
+
+	ui->path->setText(path);
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "file", QT_TO_UTF8(path));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+static inline QColor color_from_int(long long val)
+{
+	return QColor(val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, (val >> 24) & 0xff);
+}
+
+static inline long long color_to_int(QColor color)
+{
+	auto shift = [&](unsigned val, int shift) {
+		return ((val & 0xff) << shift);
+	};
+
+	return shift(color.red(), 0) | shift(color.green(), 8) | shift(color.blue(), 16) | shift(color.alpha(), 24);
+}
+
+ColorSourceToolbar::ColorSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ColorSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	unsigned int val = (unsigned int)obs_data_get_int(settings, "color");
+
+	color = color_from_int(val);
+	UpdateColor();
+}
+
+ColorSourceToolbar::~ColorSourceToolbar() {}
+
+void ColorSourceToolbar::UpdateColor()
+{
+	QPalette palette = QPalette(color);
+	ui->color->setFrameStyle(QFrame::Sunken | QFrame::Panel);
+	ui->color->setText(color.name(QColor::HexRgb));
+	ui->color->setPalette(palette);
+	ui->color->setStyleSheet(QString("background-color :%1; color: %2;")
+					 .arg(palette.color(QPalette::Window).name(QColor::HexRgb))
+					 .arg(palette.color(QPalette::WindowText).name(QColor::HexRgb)));
+	ui->color->setAutoFillBackground(true);
+	ui->color->setAlignment(Qt::AlignCenter);
+}
+
+void ColorSourceToolbar::on_choose_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "color");
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+	UpdateColor();
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_int(settings, "color", color_to_int(color));
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+extern void MakeQFont(obs_data_t *font_obj, QFont &font, bool limit = false);
+
+TextSourceToolbar::TextSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_TextSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+
+	const char *id = obs_source_get_unversioned_id(source);
+	bool ft2 = strcmp(id, "text_ft2_source") == 0;
+	bool read_from_file = obs_data_get_bool(settings, ft2 ? "from_file" : "read_from_file");
+
+	OBSDataAutoRelease font_obj = obs_data_get_obj(settings, "font");
+	MakeQFont(font_obj, font);
+
+	// Use "color1" if it's a freetype source and "color" elsewise
+	unsigned int val = (unsigned int)obs_data_get_int(
+		settings, (strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0) ? "color1" : "color");
+
+	color = color_from_int(val);
+
+	const char *text = obs_data_get_string(settings, "text");
+
+	bool single_line = !read_from_file && (!text || (strchr(text, '\n') == nullptr));
+	ui->emptySpace->setVisible(!single_line);
+	ui->text->setVisible(single_line);
+	if (single_line)
+		ui->text->setText(text);
+}
+
+TextSourceToolbar::~TextSourceToolbar() {}
+
+void TextSourceToolbar::on_selectFont_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	QFontDialog::FontDialogOptions options;
+	uint32_t flags;
+	bool success;
+
+#ifndef _WIN32
+	options = QFontDialog::DontUseNativeDialog;
+#endif
+
+	font = QFontDialog::getFont(&success, font, this, QTStr("Basic.PropertiesWindow.SelectFont.WindowTitle"),
+				    options);
+	if (!success) {
+		return;
+	}
+
+	OBSDataAutoRelease font_obj = obs_data_create();
+
+	obs_data_set_string(font_obj, "face", QT_TO_UTF8(font.family()));
+	obs_data_set_string(font_obj, "style", QT_TO_UTF8(font.styleName()));
+	obs_data_set_int(font_obj, "size", font.pointSize());
+	flags = font.bold() ? OBS_FONT_BOLD : 0;
+	flags |= font.italic() ? OBS_FONT_ITALIC : 0;
+	flags |= font.underline() ? OBS_FONT_UNDERLINE : 0;
+	flags |= font.strikeOut() ? OBS_FONT_STRIKEOUT : 0;
+	obs_data_set_int(font_obj, "flags", flags);
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+
+	obs_data_set_obj(settings, "font", font_obj);
+
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_selectColor_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	bool freetype = strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0;
+
+	obs_property_t *p = obs_properties_get(props.get(), freetype ? "color1" : "color");
+
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (freetype) {
+		obs_data_set_int(settings, "color1", color_to_int(color));
+		obs_data_set_int(settings, "color2", color_to_int(color));
+	} else {
+		obs_data_set_int(settings, "color", color_to_int(color));
+	}
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_text_textChanged()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+	std::string newText = QT_TO_UTF8(ui->text->text());
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (newText == obs_data_get_string(settings, "text")) {
+		return;
+	}
+	SaveOldProperties(source);
+
+	obs_data_set_string(settings, "text", newText.c_str());
+	obs_source_update(source, nullptr);
+
+	SetUndoProperties(source, true);
+}

+ 178 - 0
frontend/components/DisplayCaptureToolbar.hpp

@@ -0,0 +1,178 @@
+#pragma once
+
+#include <memory>
+#include <obs.hpp>
+#include <QWidget>
+
+class Ui_BrowserSourceToolbar;
+class Ui_DeviceSelectToolbar;
+class Ui_GameCaptureToolbar;
+class Ui_ImageSourceToolbar;
+class Ui_ColorSourceToolbar;
+class Ui_TextSourceToolbar;
+
+class SourceToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+protected:
+	using properties_delete_t = decltype(&obs_properties_destroy);
+	using properties_t = std::unique_ptr<obs_properties_t, properties_delete_t>;
+
+	properties_t props;
+	OBSDataAutoRelease oldData;
+
+	void SaveOldProperties(obs_source_t *source);
+	void SetUndoProperties(obs_source_t *source, bool repeatable = false);
+
+public:
+	SourceToolbar(QWidget *parent, OBSSource source);
+
+	OBSSource GetSource() { return OBSGetStrongRef(weakSource); }
+
+public slots:
+	virtual void Update() {}
+};
+
+class BrowserToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_BrowserSourceToolbar> ui;
+
+public:
+	BrowserToolbar(QWidget *parent, OBSSource source);
+	~BrowserToolbar();
+
+public slots:
+	void on_refresh_clicked();
+};
+
+class ComboSelectToolbar : public SourceToolbar {
+	Q_OBJECT
+
+protected:
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *prop_name;
+	bool is_int = false;
+
+public:
+	ComboSelectToolbar(QWidget *parent, OBSSource source);
+	~ComboSelectToolbar();
+	virtual void Init();
+
+public slots:
+	void on_device_currentIndexChanged(int idx);
+};
+
+class AudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	AudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class WindowCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	WindowCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class ApplicationAudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DisplayCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	DisplayCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DeviceCaptureToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *activateText;
+	const char *deactivateText;
+	bool active;
+
+public:
+	DeviceCaptureToolbar(QWidget *parent, OBSSource source);
+	~DeviceCaptureToolbar();
+
+public slots:
+	void on_activateButton_clicked();
+};
+
+class GameCaptureToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_GameCaptureToolbar> ui;
+
+	void UpdateWindowVisibility();
+
+public:
+	GameCaptureToolbar(QWidget *parent, OBSSource source);
+	~GameCaptureToolbar();
+
+public slots:
+	void on_mode_currentIndexChanged(int idx);
+	void on_window_currentIndexChanged(int idx);
+};
+
+class ImageSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ImageSourceToolbar> ui;
+
+public:
+	ImageSourceToolbar(QWidget *parent, OBSSource source);
+	~ImageSourceToolbar();
+
+public slots:
+	void on_browse_clicked();
+};
+
+class ColorSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ColorSourceToolbar> ui;
+	QColor color;
+
+	void UpdateColor();
+
+public:
+	ColorSourceToolbar(QWidget *parent, OBSSource source);
+	~ColorSourceToolbar();
+
+public slots:
+	void on_choose_clicked();
+};
+
+class TextSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_TextSourceToolbar> ui;
+	QFont font;
+	QColor color;
+
+public:
+	TextSourceToolbar(QWidget *parent, OBSSource source);
+	~TextSourceToolbar();
+
+public slots:
+	void on_selectFont_clicked();
+	void on_selectColor_clicked();
+	void on_text_textChanged();
+};

+ 707 - 0
frontend/components/GameCaptureToolbar.cpp

@@ -0,0 +1,707 @@
+#include "window-basic-main.hpp"
+#include "moc_context-bar-controls.cpp"
+#include "obs-app.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QStandardItemModel>
+#include <QColorDialog>
+#include <QFontDialog>
+
+#include "ui_browser-source-toolbar.h"
+#include "ui_device-select-toolbar.h"
+#include "ui_game-capture-toolbar.h"
+#include "ui_image-source-toolbar.h"
+#include "ui_color-source-toolbar.h"
+#include "ui_text-source-toolbar.h"
+
+#ifdef _WIN32
+#define get_os_module(win, mac, linux) obs_get_module(win)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, win)
+#elif __APPLE__
+#define get_os_module(win, mac, linux) obs_get_module(mac)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, mac)
+#else
+#define get_os_module(win, mac, linux) obs_get_module(linux)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, linux)
+#endif
+
+/* ========================================================================= */
+
+SourceToolbar::SourceToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  props(obs_source_properties(source), obs_properties_destroy)
+{
+}
+
+void SourceToolbar::SaveOldProperties(obs_source_t *source)
+{
+	oldData = obs_data_create();
+
+	OBSDataAutoRelease oldSettings = obs_source_get_settings(source);
+	obs_data_apply(oldData, oldSettings);
+	obs_data_set_string(oldData, "undo_suuid", obs_source_get_uuid(source));
+}
+
+void SourceToolbar::SetUndoProperties(obs_source_t *source, bool repeatable)
+{
+	if (!oldData) {
+		blog(LOG_ERROR, "%s: somehow oldData was null.", __FUNCTION__);
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+	OBSSource currentSceneSource = main->GetCurrentSceneSource();
+	if (!currentSceneSource)
+		return;
+	std::string scene_uuid = obs_source_get_uuid(currentSceneSource);
+	auto undo_redo = [scene_uuid = std::move(scene_uuid), main](const std::string &data) {
+		OBSDataAutoRelease settings = obs_data_create_from_json(data.c_str());
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(obs_data_get_string(settings, "undo_suuid"));
+		obs_source_reset_settings(source, settings);
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+
+		main->UpdateContextBar();
+	};
+
+	OBSDataAutoRelease new_settings = obs_data_create();
+	OBSDataAutoRelease curr_settings = obs_source_get_settings(source);
+	obs_data_apply(new_settings, curr_settings);
+	obs_data_set_string(new_settings, "undo_suuid", obs_source_get_uuid(source));
+
+	std::string undo_data(obs_data_get_json(oldData));
+	std::string redo_data(obs_data_get_json(new_settings));
+
+	if (undo_data.compare(redo_data) != 0)
+		main->undo_s.add_action(QTStr("Undo.Properties").arg(obs_source_get_name(source)), undo_redo, undo_redo,
+					undo_data, redo_data, repeatable);
+
+	oldData = nullptr;
+}
+
+/* ========================================================================= */
+
+BrowserToolbar::BrowserToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_BrowserSourceToolbar)
+{
+	ui->setupUi(this);
+}
+
+BrowserToolbar::~BrowserToolbar() {}
+
+void BrowserToolbar::on_refresh_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "refreshnocache");
+	obs_property_button_clicked(p, source.Get());
+}
+
+/* ========================================================================= */
+
+ComboSelectToolbar::ComboSelectToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+}
+
+ComboSelectToolbar::~ComboSelectToolbar() {}
+
+static int FillPropertyCombo(QComboBox *c, obs_property_t *p, const std::string &cur_id, bool is_int = false)
+{
+	size_t count = obs_property_list_item_count(p);
+	int cur_idx = -1;
+
+	for (size_t i = 0; i < count; i++) {
+		const char *name = obs_property_list_item_name(p, i);
+		std::string id;
+
+		if (is_int) {
+			id = std::to_string(obs_property_list_item_int(p, i));
+		} else {
+			const char *val = obs_property_list_item_string(p, i);
+			id = val ? val : "";
+		}
+
+		if (cur_id == id)
+			cur_idx = (int)i;
+
+		c->addItem(name, id.c_str());
+	}
+
+	return cur_idx;
+}
+
+void UpdateSourceComboToolbarProperties(QComboBox *combo, OBSSource source, obs_properties_t *props,
+					const char *prop_name, bool is_int)
+{
+	std::string cur_id;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (is_int) {
+		cur_id = std::to_string(obs_data_get_int(settings, prop_name));
+	} else {
+		cur_id = obs_data_get_string(settings, prop_name);
+	}
+
+	combo->blockSignals(true);
+
+	obs_property_t *p = obs_properties_get(props, prop_name);
+	int cur_idx = FillPropertyCombo(combo, p, cur_id, is_int);
+
+	if (cur_idx == -1 || obs_property_list_item_disabled(p, cur_idx)) {
+		if (cur_idx == -1) {
+			combo->insertItem(0, QTStr("Basic.Settings.Audio.UnknownAudioDevice"));
+			cur_idx = 0;
+		}
+
+		SetComboItemEnabled(combo, cur_idx, false);
+	}
+
+	combo->setCurrentIndex(cur_idx);
+	combo->blockSignals(false);
+}
+
+void ComboSelectToolbar::Init()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	UpdateSourceComboToolbarProperties(ui->device, source, props.get(), prop_name, is_int);
+}
+
+void UpdateSourceComboToolbarValue(QComboBox *combo, OBSSource source, int idx, const char *prop_name, bool is_int)
+{
+	QString id = combo->itemData(idx).toString();
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (is_int) {
+		obs_data_set_int(settings, prop_name, id.toInt());
+	} else {
+		obs_data_set_string(settings, prop_name, QT_TO_UTF8(id));
+	}
+	obs_source_update(source, settings);
+}
+
+void ComboSelectToolbar::on_device_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	SaveOldProperties(source);
+	UpdateSourceComboToolbarValue(ui->device, source, idx, prop_name, is_int);
+	SetUndoProperties(source);
+}
+
+AudioCaptureToolbar::AudioCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void AudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-wasapi", "mac-capture", "linux-pulseaudio");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Device", "CoreAudio.Device", "Device");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "device_id";
+
+	ComboSelectToolbar::Init();
+}
+
+WindowCaptureToolbar::WindowCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void WindowCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "WindowCapture.Window", "WindowUtils.Window", "Window");
+	ui->deviceLabel->setText(device_str);
+
+#if !defined(_WIN32) && !defined(__APPLE__) //linux
+	prop_name = "capture_window";
+#else
+	prop_name = "window";
+#endif
+
+#ifdef __APPLE__
+	is_int = true;
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+ApplicationAudioCaptureToolbar::ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source)
+	: ComboSelectToolbar(parent, source)
+{
+}
+
+void ApplicationAudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = obs_get_module("win-wasapi");
+	const char *device_str = obs_module_get_locale_text(mod, "Window");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "window";
+
+	ComboSelectToolbar::Init();
+}
+
+DisplayCaptureToolbar::DisplayCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void DisplayCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Monitor", "DisplayCapture.Display", "Screen");
+	ui->deviceLabel->setText(device_str);
+
+#ifdef _WIN32
+	prop_name = "monitor_id";
+#elif __APPLE__
+	prop_name = "display_uuid";
+#else
+	is_int = true;
+	prop_name = "screen";
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+/* ========================================================================= */
+
+DeviceCaptureToolbar::DeviceCaptureToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+
+	delete ui->deviceLabel;
+	delete ui->device;
+	ui->deviceLabel = nullptr;
+	ui->device = nullptr;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	active = obs_data_get_bool(settings, "active");
+
+	obs_module_t *mod = obs_get_module("win-dshow");
+	if (!mod)
+		return;
+
+	activateText = obs_module_get_locale_text(mod, "Activate");
+	deactivateText = obs_module_get_locale_text(mod, "Deactivate");
+
+	ui->activateButton->setText(active ? deactivateText : activateText);
+}
+
+DeviceCaptureToolbar::~DeviceCaptureToolbar() {}
+
+void DeviceCaptureToolbar::on_activateButton_clicked()
+{
+	OBSSource source = OBSGetStrongRef(weakSource);
+	if (!source) {
+		return;
+	}
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	bool now_active = obs_data_get_bool(settings, "active");
+
+	bool desyncedSetting = now_active != active;
+
+	active = !active;
+
+	const char *text = active ? deactivateText : activateText;
+	ui->activateButton->setText(text);
+
+	if (desyncedSetting) {
+		return;
+	}
+
+	calldata_t cd = {};
+	calldata_set_bool(&cd, "active", active);
+	proc_handler_t *ph = obs_source_get_proc_handler(source);
+	proc_handler_call(ph, "activate", &cd);
+	calldata_free(&cd);
+}
+
+/* ========================================================================= */
+
+GameCaptureToolbar::GameCaptureToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_GameCaptureToolbar)
+{
+	obs_property_t *p;
+	int cur_idx;
+
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("win-capture");
+	if (!mod)
+		return;
+
+	ui->modeLabel->setText(obs_module_get_locale_text(mod, "Mode"));
+	ui->windowLabel->setText(obs_module_get_locale_text(mod, "WindowCapture.Window"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string cur_mode = obs_data_get_string(settings, "capture_mode");
+	std::string cur_window = obs_data_get_string(settings, "window");
+
+	ui->mode->blockSignals(true);
+	p = obs_properties_get(props.get(), "capture_mode");
+	cur_idx = FillPropertyCombo(ui->mode, p, cur_mode);
+	ui->mode->setCurrentIndex(cur_idx);
+	ui->mode->blockSignals(false);
+
+	ui->window->blockSignals(true);
+	p = obs_properties_get(props.get(), "window");
+	cur_idx = FillPropertyCombo(ui->window, p, cur_window);
+	ui->window->setCurrentIndex(cur_idx);
+	ui->window->blockSignals(false);
+
+	if (cur_idx != -1 && obs_property_list_item_disabled(p, cur_idx)) {
+		SetComboItemEnabled(ui->window, cur_idx, false);
+	}
+
+	UpdateWindowVisibility();
+}
+
+GameCaptureToolbar::~GameCaptureToolbar() {}
+
+void GameCaptureToolbar::UpdateWindowVisibility()
+{
+	QString mode = ui->mode->currentData().toString();
+	bool is_window = (mode == "window");
+	ui->windowLabel->setVisible(is_window);
+	ui->window->setVisible(is_window);
+}
+
+void GameCaptureToolbar::on_mode_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->mode->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "capture_mode", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+
+	UpdateWindowVisibility();
+}
+
+void GameCaptureToolbar::on_window_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->window->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "window", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+ImageSourceToolbar::ImageSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ImageSourceToolbar)
+{
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("image-source");
+	ui->pathLabel->setText(obs_module_get_locale_text(mod, "File"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string file = obs_data_get_string(settings, "file");
+
+	ui->path->setText(file.c_str());
+}
+
+ImageSourceToolbar::~ImageSourceToolbar() {}
+
+void ImageSourceToolbar::on_browse_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "file");
+	const char *desc = obs_property_description(p);
+	const char *filter = obs_property_path_filter(p);
+	const char *default_path = obs_property_path_default_path(p);
+
+	QString startDir = ui->path->text();
+	if (startDir.isEmpty())
+		startDir = default_path;
+
+	QString path = OpenFile(this, desc, startDir, filter);
+	if (path.isEmpty()) {
+		return;
+	}
+
+	ui->path->setText(path);
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "file", QT_TO_UTF8(path));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+static inline QColor color_from_int(long long val)
+{
+	return QColor(val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, (val >> 24) & 0xff);
+}
+
+static inline long long color_to_int(QColor color)
+{
+	auto shift = [&](unsigned val, int shift) {
+		return ((val & 0xff) << shift);
+	};
+
+	return shift(color.red(), 0) | shift(color.green(), 8) | shift(color.blue(), 16) | shift(color.alpha(), 24);
+}
+
+ColorSourceToolbar::ColorSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ColorSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	unsigned int val = (unsigned int)obs_data_get_int(settings, "color");
+
+	color = color_from_int(val);
+	UpdateColor();
+}
+
+ColorSourceToolbar::~ColorSourceToolbar() {}
+
+void ColorSourceToolbar::UpdateColor()
+{
+	QPalette palette = QPalette(color);
+	ui->color->setFrameStyle(QFrame::Sunken | QFrame::Panel);
+	ui->color->setText(color.name(QColor::HexRgb));
+	ui->color->setPalette(palette);
+	ui->color->setStyleSheet(QString("background-color :%1; color: %2;")
+					 .arg(palette.color(QPalette::Window).name(QColor::HexRgb))
+					 .arg(palette.color(QPalette::WindowText).name(QColor::HexRgb)));
+	ui->color->setAutoFillBackground(true);
+	ui->color->setAlignment(Qt::AlignCenter);
+}
+
+void ColorSourceToolbar::on_choose_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "color");
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+	UpdateColor();
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_int(settings, "color", color_to_int(color));
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+extern void MakeQFont(obs_data_t *font_obj, QFont &font, bool limit = false);
+
+TextSourceToolbar::TextSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_TextSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+
+	const char *id = obs_source_get_unversioned_id(source);
+	bool ft2 = strcmp(id, "text_ft2_source") == 0;
+	bool read_from_file = obs_data_get_bool(settings, ft2 ? "from_file" : "read_from_file");
+
+	OBSDataAutoRelease font_obj = obs_data_get_obj(settings, "font");
+	MakeQFont(font_obj, font);
+
+	// Use "color1" if it's a freetype source and "color" elsewise
+	unsigned int val = (unsigned int)obs_data_get_int(
+		settings, (strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0) ? "color1" : "color");
+
+	color = color_from_int(val);
+
+	const char *text = obs_data_get_string(settings, "text");
+
+	bool single_line = !read_from_file && (!text || (strchr(text, '\n') == nullptr));
+	ui->emptySpace->setVisible(!single_line);
+	ui->text->setVisible(single_line);
+	if (single_line)
+		ui->text->setText(text);
+}
+
+TextSourceToolbar::~TextSourceToolbar() {}
+
+void TextSourceToolbar::on_selectFont_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	QFontDialog::FontDialogOptions options;
+	uint32_t flags;
+	bool success;
+
+#ifndef _WIN32
+	options = QFontDialog::DontUseNativeDialog;
+#endif
+
+	font = QFontDialog::getFont(&success, font, this, QTStr("Basic.PropertiesWindow.SelectFont.WindowTitle"),
+				    options);
+	if (!success) {
+		return;
+	}
+
+	OBSDataAutoRelease font_obj = obs_data_create();
+
+	obs_data_set_string(font_obj, "face", QT_TO_UTF8(font.family()));
+	obs_data_set_string(font_obj, "style", QT_TO_UTF8(font.styleName()));
+	obs_data_set_int(font_obj, "size", font.pointSize());
+	flags = font.bold() ? OBS_FONT_BOLD : 0;
+	flags |= font.italic() ? OBS_FONT_ITALIC : 0;
+	flags |= font.underline() ? OBS_FONT_UNDERLINE : 0;
+	flags |= font.strikeOut() ? OBS_FONT_STRIKEOUT : 0;
+	obs_data_set_int(font_obj, "flags", flags);
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+
+	obs_data_set_obj(settings, "font", font_obj);
+
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_selectColor_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	bool freetype = strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0;
+
+	obs_property_t *p = obs_properties_get(props.get(), freetype ? "color1" : "color");
+
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (freetype) {
+		obs_data_set_int(settings, "color1", color_to_int(color));
+		obs_data_set_int(settings, "color2", color_to_int(color));
+	} else {
+		obs_data_set_int(settings, "color", color_to_int(color));
+	}
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_text_textChanged()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+	std::string newText = QT_TO_UTF8(ui->text->text());
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (newText == obs_data_get_string(settings, "text")) {
+		return;
+	}
+	SaveOldProperties(source);
+
+	obs_data_set_string(settings, "text", newText.c_str());
+	obs_source_update(source, nullptr);
+
+	SetUndoProperties(source, true);
+}

+ 178 - 0
frontend/components/GameCaptureToolbar.hpp

@@ -0,0 +1,178 @@
+#pragma once
+
+#include <memory>
+#include <obs.hpp>
+#include <QWidget>
+
+class Ui_BrowserSourceToolbar;
+class Ui_DeviceSelectToolbar;
+class Ui_GameCaptureToolbar;
+class Ui_ImageSourceToolbar;
+class Ui_ColorSourceToolbar;
+class Ui_TextSourceToolbar;
+
+class SourceToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+protected:
+	using properties_delete_t = decltype(&obs_properties_destroy);
+	using properties_t = std::unique_ptr<obs_properties_t, properties_delete_t>;
+
+	properties_t props;
+	OBSDataAutoRelease oldData;
+
+	void SaveOldProperties(obs_source_t *source);
+	void SetUndoProperties(obs_source_t *source, bool repeatable = false);
+
+public:
+	SourceToolbar(QWidget *parent, OBSSource source);
+
+	OBSSource GetSource() { return OBSGetStrongRef(weakSource); }
+
+public slots:
+	virtual void Update() {}
+};
+
+class BrowserToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_BrowserSourceToolbar> ui;
+
+public:
+	BrowserToolbar(QWidget *parent, OBSSource source);
+	~BrowserToolbar();
+
+public slots:
+	void on_refresh_clicked();
+};
+
+class ComboSelectToolbar : public SourceToolbar {
+	Q_OBJECT
+
+protected:
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *prop_name;
+	bool is_int = false;
+
+public:
+	ComboSelectToolbar(QWidget *parent, OBSSource source);
+	~ComboSelectToolbar();
+	virtual void Init();
+
+public slots:
+	void on_device_currentIndexChanged(int idx);
+};
+
+class AudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	AudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class WindowCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	WindowCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class ApplicationAudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DisplayCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	DisplayCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DeviceCaptureToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *activateText;
+	const char *deactivateText;
+	bool active;
+
+public:
+	DeviceCaptureToolbar(QWidget *parent, OBSSource source);
+	~DeviceCaptureToolbar();
+
+public slots:
+	void on_activateButton_clicked();
+};
+
+class GameCaptureToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_GameCaptureToolbar> ui;
+
+	void UpdateWindowVisibility();
+
+public:
+	GameCaptureToolbar(QWidget *parent, OBSSource source);
+	~GameCaptureToolbar();
+
+public slots:
+	void on_mode_currentIndexChanged(int idx);
+	void on_window_currentIndexChanged(int idx);
+};
+
+class ImageSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ImageSourceToolbar> ui;
+
+public:
+	ImageSourceToolbar(QWidget *parent, OBSSource source);
+	~ImageSourceToolbar();
+
+public slots:
+	void on_browse_clicked();
+};
+
+class ColorSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ColorSourceToolbar> ui;
+	QColor color;
+
+	void UpdateColor();
+
+public:
+	ColorSourceToolbar(QWidget *parent, OBSSource source);
+	~ColorSourceToolbar();
+
+public slots:
+	void on_choose_clicked();
+};
+
+class TextSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_TextSourceToolbar> ui;
+	QFont font;
+	QColor color;
+
+public:
+	TextSourceToolbar(QWidget *parent, OBSSource source);
+	~TextSourceToolbar();
+
+public slots:
+	void on_selectFont_clicked();
+	void on_selectColor_clicked();
+	void on_text_textChanged();
+};

+ 707 - 0
frontend/components/ImageSourceToolbar.cpp

@@ -0,0 +1,707 @@
+#include "window-basic-main.hpp"
+#include "moc_context-bar-controls.cpp"
+#include "obs-app.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QStandardItemModel>
+#include <QColorDialog>
+#include <QFontDialog>
+
+#include "ui_browser-source-toolbar.h"
+#include "ui_device-select-toolbar.h"
+#include "ui_game-capture-toolbar.h"
+#include "ui_image-source-toolbar.h"
+#include "ui_color-source-toolbar.h"
+#include "ui_text-source-toolbar.h"
+
+#ifdef _WIN32
+#define get_os_module(win, mac, linux) obs_get_module(win)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, win)
+#elif __APPLE__
+#define get_os_module(win, mac, linux) obs_get_module(mac)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, mac)
+#else
+#define get_os_module(win, mac, linux) obs_get_module(linux)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, linux)
+#endif
+
+/* ========================================================================= */
+
+SourceToolbar::SourceToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  props(obs_source_properties(source), obs_properties_destroy)
+{
+}
+
+void SourceToolbar::SaveOldProperties(obs_source_t *source)
+{
+	oldData = obs_data_create();
+
+	OBSDataAutoRelease oldSettings = obs_source_get_settings(source);
+	obs_data_apply(oldData, oldSettings);
+	obs_data_set_string(oldData, "undo_suuid", obs_source_get_uuid(source));
+}
+
+void SourceToolbar::SetUndoProperties(obs_source_t *source, bool repeatable)
+{
+	if (!oldData) {
+		blog(LOG_ERROR, "%s: somehow oldData was null.", __FUNCTION__);
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+	OBSSource currentSceneSource = main->GetCurrentSceneSource();
+	if (!currentSceneSource)
+		return;
+	std::string scene_uuid = obs_source_get_uuid(currentSceneSource);
+	auto undo_redo = [scene_uuid = std::move(scene_uuid), main](const std::string &data) {
+		OBSDataAutoRelease settings = obs_data_create_from_json(data.c_str());
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(obs_data_get_string(settings, "undo_suuid"));
+		obs_source_reset_settings(source, settings);
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+
+		main->UpdateContextBar();
+	};
+
+	OBSDataAutoRelease new_settings = obs_data_create();
+	OBSDataAutoRelease curr_settings = obs_source_get_settings(source);
+	obs_data_apply(new_settings, curr_settings);
+	obs_data_set_string(new_settings, "undo_suuid", obs_source_get_uuid(source));
+
+	std::string undo_data(obs_data_get_json(oldData));
+	std::string redo_data(obs_data_get_json(new_settings));
+
+	if (undo_data.compare(redo_data) != 0)
+		main->undo_s.add_action(QTStr("Undo.Properties").arg(obs_source_get_name(source)), undo_redo, undo_redo,
+					undo_data, redo_data, repeatable);
+
+	oldData = nullptr;
+}
+
+/* ========================================================================= */
+
+BrowserToolbar::BrowserToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_BrowserSourceToolbar)
+{
+	ui->setupUi(this);
+}
+
+BrowserToolbar::~BrowserToolbar() {}
+
+void BrowserToolbar::on_refresh_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "refreshnocache");
+	obs_property_button_clicked(p, source.Get());
+}
+
+/* ========================================================================= */
+
+ComboSelectToolbar::ComboSelectToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+}
+
+ComboSelectToolbar::~ComboSelectToolbar() {}
+
+static int FillPropertyCombo(QComboBox *c, obs_property_t *p, const std::string &cur_id, bool is_int = false)
+{
+	size_t count = obs_property_list_item_count(p);
+	int cur_idx = -1;
+
+	for (size_t i = 0; i < count; i++) {
+		const char *name = obs_property_list_item_name(p, i);
+		std::string id;
+
+		if (is_int) {
+			id = std::to_string(obs_property_list_item_int(p, i));
+		} else {
+			const char *val = obs_property_list_item_string(p, i);
+			id = val ? val : "";
+		}
+
+		if (cur_id == id)
+			cur_idx = (int)i;
+
+		c->addItem(name, id.c_str());
+	}
+
+	return cur_idx;
+}
+
+void UpdateSourceComboToolbarProperties(QComboBox *combo, OBSSource source, obs_properties_t *props,
+					const char *prop_name, bool is_int)
+{
+	std::string cur_id;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (is_int) {
+		cur_id = std::to_string(obs_data_get_int(settings, prop_name));
+	} else {
+		cur_id = obs_data_get_string(settings, prop_name);
+	}
+
+	combo->blockSignals(true);
+
+	obs_property_t *p = obs_properties_get(props, prop_name);
+	int cur_idx = FillPropertyCombo(combo, p, cur_id, is_int);
+
+	if (cur_idx == -1 || obs_property_list_item_disabled(p, cur_idx)) {
+		if (cur_idx == -1) {
+			combo->insertItem(0, QTStr("Basic.Settings.Audio.UnknownAudioDevice"));
+			cur_idx = 0;
+		}
+
+		SetComboItemEnabled(combo, cur_idx, false);
+	}
+
+	combo->setCurrentIndex(cur_idx);
+	combo->blockSignals(false);
+}
+
+void ComboSelectToolbar::Init()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	UpdateSourceComboToolbarProperties(ui->device, source, props.get(), prop_name, is_int);
+}
+
+void UpdateSourceComboToolbarValue(QComboBox *combo, OBSSource source, int idx, const char *prop_name, bool is_int)
+{
+	QString id = combo->itemData(idx).toString();
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (is_int) {
+		obs_data_set_int(settings, prop_name, id.toInt());
+	} else {
+		obs_data_set_string(settings, prop_name, QT_TO_UTF8(id));
+	}
+	obs_source_update(source, settings);
+}
+
+void ComboSelectToolbar::on_device_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	SaveOldProperties(source);
+	UpdateSourceComboToolbarValue(ui->device, source, idx, prop_name, is_int);
+	SetUndoProperties(source);
+}
+
+AudioCaptureToolbar::AudioCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void AudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-wasapi", "mac-capture", "linux-pulseaudio");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Device", "CoreAudio.Device", "Device");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "device_id";
+
+	ComboSelectToolbar::Init();
+}
+
+WindowCaptureToolbar::WindowCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void WindowCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "WindowCapture.Window", "WindowUtils.Window", "Window");
+	ui->deviceLabel->setText(device_str);
+
+#if !defined(_WIN32) && !defined(__APPLE__) //linux
+	prop_name = "capture_window";
+#else
+	prop_name = "window";
+#endif
+
+#ifdef __APPLE__
+	is_int = true;
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+ApplicationAudioCaptureToolbar::ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source)
+	: ComboSelectToolbar(parent, source)
+{
+}
+
+void ApplicationAudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = obs_get_module("win-wasapi");
+	const char *device_str = obs_module_get_locale_text(mod, "Window");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "window";
+
+	ComboSelectToolbar::Init();
+}
+
+DisplayCaptureToolbar::DisplayCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void DisplayCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Monitor", "DisplayCapture.Display", "Screen");
+	ui->deviceLabel->setText(device_str);
+
+#ifdef _WIN32
+	prop_name = "monitor_id";
+#elif __APPLE__
+	prop_name = "display_uuid";
+#else
+	is_int = true;
+	prop_name = "screen";
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+/* ========================================================================= */
+
+DeviceCaptureToolbar::DeviceCaptureToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+
+	delete ui->deviceLabel;
+	delete ui->device;
+	ui->deviceLabel = nullptr;
+	ui->device = nullptr;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	active = obs_data_get_bool(settings, "active");
+
+	obs_module_t *mod = obs_get_module("win-dshow");
+	if (!mod)
+		return;
+
+	activateText = obs_module_get_locale_text(mod, "Activate");
+	deactivateText = obs_module_get_locale_text(mod, "Deactivate");
+
+	ui->activateButton->setText(active ? deactivateText : activateText);
+}
+
+DeviceCaptureToolbar::~DeviceCaptureToolbar() {}
+
+void DeviceCaptureToolbar::on_activateButton_clicked()
+{
+	OBSSource source = OBSGetStrongRef(weakSource);
+	if (!source) {
+		return;
+	}
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	bool now_active = obs_data_get_bool(settings, "active");
+
+	bool desyncedSetting = now_active != active;
+
+	active = !active;
+
+	const char *text = active ? deactivateText : activateText;
+	ui->activateButton->setText(text);
+
+	if (desyncedSetting) {
+		return;
+	}
+
+	calldata_t cd = {};
+	calldata_set_bool(&cd, "active", active);
+	proc_handler_t *ph = obs_source_get_proc_handler(source);
+	proc_handler_call(ph, "activate", &cd);
+	calldata_free(&cd);
+}
+
+/* ========================================================================= */
+
+GameCaptureToolbar::GameCaptureToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_GameCaptureToolbar)
+{
+	obs_property_t *p;
+	int cur_idx;
+
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("win-capture");
+	if (!mod)
+		return;
+
+	ui->modeLabel->setText(obs_module_get_locale_text(mod, "Mode"));
+	ui->windowLabel->setText(obs_module_get_locale_text(mod, "WindowCapture.Window"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string cur_mode = obs_data_get_string(settings, "capture_mode");
+	std::string cur_window = obs_data_get_string(settings, "window");
+
+	ui->mode->blockSignals(true);
+	p = obs_properties_get(props.get(), "capture_mode");
+	cur_idx = FillPropertyCombo(ui->mode, p, cur_mode);
+	ui->mode->setCurrentIndex(cur_idx);
+	ui->mode->blockSignals(false);
+
+	ui->window->blockSignals(true);
+	p = obs_properties_get(props.get(), "window");
+	cur_idx = FillPropertyCombo(ui->window, p, cur_window);
+	ui->window->setCurrentIndex(cur_idx);
+	ui->window->blockSignals(false);
+
+	if (cur_idx != -1 && obs_property_list_item_disabled(p, cur_idx)) {
+		SetComboItemEnabled(ui->window, cur_idx, false);
+	}
+
+	UpdateWindowVisibility();
+}
+
+GameCaptureToolbar::~GameCaptureToolbar() {}
+
+void GameCaptureToolbar::UpdateWindowVisibility()
+{
+	QString mode = ui->mode->currentData().toString();
+	bool is_window = (mode == "window");
+	ui->windowLabel->setVisible(is_window);
+	ui->window->setVisible(is_window);
+}
+
+void GameCaptureToolbar::on_mode_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->mode->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "capture_mode", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+
+	UpdateWindowVisibility();
+}
+
+void GameCaptureToolbar::on_window_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->window->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "window", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+ImageSourceToolbar::ImageSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ImageSourceToolbar)
+{
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("image-source");
+	ui->pathLabel->setText(obs_module_get_locale_text(mod, "File"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string file = obs_data_get_string(settings, "file");
+
+	ui->path->setText(file.c_str());
+}
+
+ImageSourceToolbar::~ImageSourceToolbar() {}
+
+void ImageSourceToolbar::on_browse_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "file");
+	const char *desc = obs_property_description(p);
+	const char *filter = obs_property_path_filter(p);
+	const char *default_path = obs_property_path_default_path(p);
+
+	QString startDir = ui->path->text();
+	if (startDir.isEmpty())
+		startDir = default_path;
+
+	QString path = OpenFile(this, desc, startDir, filter);
+	if (path.isEmpty()) {
+		return;
+	}
+
+	ui->path->setText(path);
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "file", QT_TO_UTF8(path));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+static inline QColor color_from_int(long long val)
+{
+	return QColor(val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, (val >> 24) & 0xff);
+}
+
+static inline long long color_to_int(QColor color)
+{
+	auto shift = [&](unsigned val, int shift) {
+		return ((val & 0xff) << shift);
+	};
+
+	return shift(color.red(), 0) | shift(color.green(), 8) | shift(color.blue(), 16) | shift(color.alpha(), 24);
+}
+
+ColorSourceToolbar::ColorSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ColorSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	unsigned int val = (unsigned int)obs_data_get_int(settings, "color");
+
+	color = color_from_int(val);
+	UpdateColor();
+}
+
+ColorSourceToolbar::~ColorSourceToolbar() {}
+
+void ColorSourceToolbar::UpdateColor()
+{
+	QPalette palette = QPalette(color);
+	ui->color->setFrameStyle(QFrame::Sunken | QFrame::Panel);
+	ui->color->setText(color.name(QColor::HexRgb));
+	ui->color->setPalette(palette);
+	ui->color->setStyleSheet(QString("background-color :%1; color: %2;")
+					 .arg(palette.color(QPalette::Window).name(QColor::HexRgb))
+					 .arg(palette.color(QPalette::WindowText).name(QColor::HexRgb)));
+	ui->color->setAutoFillBackground(true);
+	ui->color->setAlignment(Qt::AlignCenter);
+}
+
+void ColorSourceToolbar::on_choose_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "color");
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+	UpdateColor();
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_int(settings, "color", color_to_int(color));
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+extern void MakeQFont(obs_data_t *font_obj, QFont &font, bool limit = false);
+
+TextSourceToolbar::TextSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_TextSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+
+	const char *id = obs_source_get_unversioned_id(source);
+	bool ft2 = strcmp(id, "text_ft2_source") == 0;
+	bool read_from_file = obs_data_get_bool(settings, ft2 ? "from_file" : "read_from_file");
+
+	OBSDataAutoRelease font_obj = obs_data_get_obj(settings, "font");
+	MakeQFont(font_obj, font);
+
+	// Use "color1" if it's a freetype source and "color" elsewise
+	unsigned int val = (unsigned int)obs_data_get_int(
+		settings, (strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0) ? "color1" : "color");
+
+	color = color_from_int(val);
+
+	const char *text = obs_data_get_string(settings, "text");
+
+	bool single_line = !read_from_file && (!text || (strchr(text, '\n') == nullptr));
+	ui->emptySpace->setVisible(!single_line);
+	ui->text->setVisible(single_line);
+	if (single_line)
+		ui->text->setText(text);
+}
+
+TextSourceToolbar::~TextSourceToolbar() {}
+
+void TextSourceToolbar::on_selectFont_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	QFontDialog::FontDialogOptions options;
+	uint32_t flags;
+	bool success;
+
+#ifndef _WIN32
+	options = QFontDialog::DontUseNativeDialog;
+#endif
+
+	font = QFontDialog::getFont(&success, font, this, QTStr("Basic.PropertiesWindow.SelectFont.WindowTitle"),
+				    options);
+	if (!success) {
+		return;
+	}
+
+	OBSDataAutoRelease font_obj = obs_data_create();
+
+	obs_data_set_string(font_obj, "face", QT_TO_UTF8(font.family()));
+	obs_data_set_string(font_obj, "style", QT_TO_UTF8(font.styleName()));
+	obs_data_set_int(font_obj, "size", font.pointSize());
+	flags = font.bold() ? OBS_FONT_BOLD : 0;
+	flags |= font.italic() ? OBS_FONT_ITALIC : 0;
+	flags |= font.underline() ? OBS_FONT_UNDERLINE : 0;
+	flags |= font.strikeOut() ? OBS_FONT_STRIKEOUT : 0;
+	obs_data_set_int(font_obj, "flags", flags);
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+
+	obs_data_set_obj(settings, "font", font_obj);
+
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_selectColor_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	bool freetype = strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0;
+
+	obs_property_t *p = obs_properties_get(props.get(), freetype ? "color1" : "color");
+
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (freetype) {
+		obs_data_set_int(settings, "color1", color_to_int(color));
+		obs_data_set_int(settings, "color2", color_to_int(color));
+	} else {
+		obs_data_set_int(settings, "color", color_to_int(color));
+	}
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_text_textChanged()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+	std::string newText = QT_TO_UTF8(ui->text->text());
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (newText == obs_data_get_string(settings, "text")) {
+		return;
+	}
+	SaveOldProperties(source);
+
+	obs_data_set_string(settings, "text", newText.c_str());
+	obs_source_update(source, nullptr);
+
+	SetUndoProperties(source, true);
+}

+ 178 - 0
frontend/components/ImageSourceToolbar.hpp

@@ -0,0 +1,178 @@
+#pragma once
+
+#include <memory>
+#include <obs.hpp>
+#include <QWidget>
+
+class Ui_BrowserSourceToolbar;
+class Ui_DeviceSelectToolbar;
+class Ui_GameCaptureToolbar;
+class Ui_ImageSourceToolbar;
+class Ui_ColorSourceToolbar;
+class Ui_TextSourceToolbar;
+
+class SourceToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+protected:
+	using properties_delete_t = decltype(&obs_properties_destroy);
+	using properties_t = std::unique_ptr<obs_properties_t, properties_delete_t>;
+
+	properties_t props;
+	OBSDataAutoRelease oldData;
+
+	void SaveOldProperties(obs_source_t *source);
+	void SetUndoProperties(obs_source_t *source, bool repeatable = false);
+
+public:
+	SourceToolbar(QWidget *parent, OBSSource source);
+
+	OBSSource GetSource() { return OBSGetStrongRef(weakSource); }
+
+public slots:
+	virtual void Update() {}
+};
+
+class BrowserToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_BrowserSourceToolbar> ui;
+
+public:
+	BrowserToolbar(QWidget *parent, OBSSource source);
+	~BrowserToolbar();
+
+public slots:
+	void on_refresh_clicked();
+};
+
+class ComboSelectToolbar : public SourceToolbar {
+	Q_OBJECT
+
+protected:
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *prop_name;
+	bool is_int = false;
+
+public:
+	ComboSelectToolbar(QWidget *parent, OBSSource source);
+	~ComboSelectToolbar();
+	virtual void Init();
+
+public slots:
+	void on_device_currentIndexChanged(int idx);
+};
+
+class AudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	AudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class WindowCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	WindowCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class ApplicationAudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DisplayCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	DisplayCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DeviceCaptureToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *activateText;
+	const char *deactivateText;
+	bool active;
+
+public:
+	DeviceCaptureToolbar(QWidget *parent, OBSSource source);
+	~DeviceCaptureToolbar();
+
+public slots:
+	void on_activateButton_clicked();
+};
+
+class GameCaptureToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_GameCaptureToolbar> ui;
+
+	void UpdateWindowVisibility();
+
+public:
+	GameCaptureToolbar(QWidget *parent, OBSSource source);
+	~GameCaptureToolbar();
+
+public slots:
+	void on_mode_currentIndexChanged(int idx);
+	void on_window_currentIndexChanged(int idx);
+};
+
+class ImageSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ImageSourceToolbar> ui;
+
+public:
+	ImageSourceToolbar(QWidget *parent, OBSSource source);
+	~ImageSourceToolbar();
+
+public slots:
+	void on_browse_clicked();
+};
+
+class ColorSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ColorSourceToolbar> ui;
+	QColor color;
+
+	void UpdateColor();
+
+public:
+	ColorSourceToolbar(QWidget *parent, OBSSource source);
+	~ColorSourceToolbar();
+
+public slots:
+	void on_choose_clicked();
+};
+
+class TextSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_TextSourceToolbar> ui;
+	QFont font;
+	QColor color;
+
+public:
+	TextSourceToolbar(QWidget *parent, OBSSource source);
+	~TextSourceToolbar();
+
+public slots:
+	void on_selectFont_clicked();
+	void on_selectColor_clicked();
+	void on_text_textChanged();
+};

+ 0 - 0
UI/preview-controls.cpp → frontend/components/OBSPreviewScalingComboBox.cpp


+ 0 - 0
UI/preview-controls.hpp → frontend/components/OBSPreviewScalingComboBox.hpp


+ 133 - 0
frontend/components/OBSPreviewScalingLabel.cpp

@@ -0,0 +1,133 @@
+/******************************************************************************
+    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 "preview-controls.hpp"
+#include <obs-app.hpp>
+
+/* Preview Scale Label */
+void OBSPreviewScalingLabel::PreviewScaleChanged(float scale)
+{
+	previewScale = scale;
+	UpdateScaleLabel();
+}
+
+void OBSPreviewScalingLabel::UpdateScaleLabel()
+{
+	float previewScalePercent = floor(100.0f * previewScale);
+	setText(QString::number(previewScalePercent) + "%");
+}
+
+/* Preview Scaling ComboBox */
+void OBSPreviewScalingComboBox::PreviewFixedScalingChanged(bool fixed)
+{
+	if (fixedScaling == fixed)
+		return;
+
+	fixedScaling = fixed;
+	UpdateSelection();
+}
+
+void OBSPreviewScalingComboBox::CanvasResized(uint32_t width, uint32_t height)
+{
+	SetCanvasSize(width, height);
+	UpdateCanvasText();
+}
+
+void OBSPreviewScalingComboBox::OutputResized(uint32_t width, uint32_t height)
+{
+	SetOutputSize(width, height);
+
+	bool canvasMatchesOutput = output_width == canvas_width && output_height == canvas_height;
+
+	SetScaleOutputEnabled(!canvasMatchesOutput);
+	UpdateOutputText();
+}
+
+void OBSPreviewScalingComboBox::PreviewScaleChanged(float scale)
+{
+	previewScale = scale;
+
+	if (fixedScaling) {
+		UpdateSelection();
+		UpdateAllText();
+	} else {
+		UpdateScaledText();
+	}
+}
+
+void OBSPreviewScalingComboBox::SetScaleOutputEnabled(bool show)
+{
+	if (scaleOutputEnabled == show)
+		return;
+
+	scaleOutputEnabled = show;
+
+	if (scaleOutputEnabled) {
+		addItem(QTStr("Basic.MainMenu.Edit.Scale.Output"));
+	} else {
+		removeItem(2);
+	}
+}
+
+void OBSPreviewScalingComboBox::UpdateAllText()
+{
+	UpdateCanvasText();
+	UpdateOutputText();
+	UpdateScaledText();
+}
+
+void OBSPreviewScalingComboBox::UpdateCanvasText()
+{
+	QString text = QTStr("Basic.MainMenu.Edit.Scale.Canvas");
+	text = text.arg(QString::number(canvas_width), QString::number(canvas_height));
+	setItemText(1, text);
+}
+
+void OBSPreviewScalingComboBox::UpdateOutputText()
+{
+	if (scaleOutputEnabled) {
+		QString text = QTStr("Basic.MainMenu.Edit.Scale.Output");
+		text = text.arg(QString::number(output_width), QString::number(output_height));
+		setItemText(2, text);
+	}
+}
+
+void OBSPreviewScalingComboBox::UpdateScaledText()
+{
+	QString text = QTStr("Basic.MainMenu.Edit.Scale.Manual");
+	text = text.arg(QString::number(floor(canvas_width * previewScale)),
+			QString::number(floor(canvas_height * previewScale)));
+	setPlaceholderText(text);
+}
+
+void OBSPreviewScalingComboBox::UpdateSelection()
+{
+	QSignalBlocker sb(this);
+	float outputScale = float(output_width) / float(canvas_width);
+
+	if (!fixedScaling) {
+		setCurrentIndex(0);
+	} else {
+		if (previewScale == 1.0f) {
+			setCurrentIndex(1);
+		} else if (scaleOutputEnabled && (previewScale == outputScale)) {
+			setCurrentIndex(2);
+		} else {
+			setCurrentIndex(-1);
+		}
+	}
+}

+ 79 - 0
frontend/components/OBSPreviewScalingLabel.hpp

@@ -0,0 +1,79 @@
+/******************************************************************************
+    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 <QLabel>
+#include <QComboBox>
+
+class OBSPreviewScalingLabel : public QLabel {
+	Q_OBJECT
+
+public:
+	OBSPreviewScalingLabel(QWidget *parent = nullptr) : QLabel(parent) {}
+
+public slots:
+	void PreviewScaleChanged(float scale);
+
+private:
+	float previewScale = 0.0f;
+	void UpdateScaleLabel();
+};
+
+class OBSPreviewScalingComboBox : public QComboBox {
+	Q_OBJECT
+
+public:
+	OBSPreviewScalingComboBox(QWidget *parent = nullptr) : QComboBox(parent) {}
+
+	inline void SetCanvasSize(uint32_t width, uint32_t height)
+	{
+		canvas_width = width;
+		canvas_height = height;
+	};
+	inline void SetOutputSize(uint32_t width, uint32_t height)
+	{
+		output_width = width;
+		output_height = height;
+	};
+	void UpdateAllText();
+
+public slots:
+	void PreviewScaleChanged(float scale);
+	void PreviewFixedScalingChanged(bool fixed);
+	void CanvasResized(uint32_t width, uint32_t height);
+	void OutputResized(uint32_t width, uint32_t height);
+
+private:
+	uint32_t canvas_width = 0;
+	uint32_t canvas_height = 0;
+
+	uint32_t output_width = 0;
+	uint32_t output_height = 0;
+
+	float previewScale = 0.0f;
+
+	bool fixedScaling = false;
+
+	bool scaleOutputEnabled = false;
+	void SetScaleOutputEnabled(bool show);
+
+	void UpdateCanvasText();
+	void UpdateOutputText();
+	void UpdateScaledText();
+	void UpdateSelection();
+};

+ 707 - 0
frontend/components/SourceToolbar.cpp

@@ -0,0 +1,707 @@
+#include "window-basic-main.hpp"
+#include "moc_context-bar-controls.cpp"
+#include "obs-app.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QStandardItemModel>
+#include <QColorDialog>
+#include <QFontDialog>
+
+#include "ui_browser-source-toolbar.h"
+#include "ui_device-select-toolbar.h"
+#include "ui_game-capture-toolbar.h"
+#include "ui_image-source-toolbar.h"
+#include "ui_color-source-toolbar.h"
+#include "ui_text-source-toolbar.h"
+
+#ifdef _WIN32
+#define get_os_module(win, mac, linux) obs_get_module(win)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, win)
+#elif __APPLE__
+#define get_os_module(win, mac, linux) obs_get_module(mac)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, mac)
+#else
+#define get_os_module(win, mac, linux) obs_get_module(linux)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, linux)
+#endif
+
+/* ========================================================================= */
+
+SourceToolbar::SourceToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  props(obs_source_properties(source), obs_properties_destroy)
+{
+}
+
+void SourceToolbar::SaveOldProperties(obs_source_t *source)
+{
+	oldData = obs_data_create();
+
+	OBSDataAutoRelease oldSettings = obs_source_get_settings(source);
+	obs_data_apply(oldData, oldSettings);
+	obs_data_set_string(oldData, "undo_suuid", obs_source_get_uuid(source));
+}
+
+void SourceToolbar::SetUndoProperties(obs_source_t *source, bool repeatable)
+{
+	if (!oldData) {
+		blog(LOG_ERROR, "%s: somehow oldData was null.", __FUNCTION__);
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+	OBSSource currentSceneSource = main->GetCurrentSceneSource();
+	if (!currentSceneSource)
+		return;
+	std::string scene_uuid = obs_source_get_uuid(currentSceneSource);
+	auto undo_redo = [scene_uuid = std::move(scene_uuid), main](const std::string &data) {
+		OBSDataAutoRelease settings = obs_data_create_from_json(data.c_str());
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(obs_data_get_string(settings, "undo_suuid"));
+		obs_source_reset_settings(source, settings);
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+
+		main->UpdateContextBar();
+	};
+
+	OBSDataAutoRelease new_settings = obs_data_create();
+	OBSDataAutoRelease curr_settings = obs_source_get_settings(source);
+	obs_data_apply(new_settings, curr_settings);
+	obs_data_set_string(new_settings, "undo_suuid", obs_source_get_uuid(source));
+
+	std::string undo_data(obs_data_get_json(oldData));
+	std::string redo_data(obs_data_get_json(new_settings));
+
+	if (undo_data.compare(redo_data) != 0)
+		main->undo_s.add_action(QTStr("Undo.Properties").arg(obs_source_get_name(source)), undo_redo, undo_redo,
+					undo_data, redo_data, repeatable);
+
+	oldData = nullptr;
+}
+
+/* ========================================================================= */
+
+BrowserToolbar::BrowserToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_BrowserSourceToolbar)
+{
+	ui->setupUi(this);
+}
+
+BrowserToolbar::~BrowserToolbar() {}
+
+void BrowserToolbar::on_refresh_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "refreshnocache");
+	obs_property_button_clicked(p, source.Get());
+}
+
+/* ========================================================================= */
+
+ComboSelectToolbar::ComboSelectToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+}
+
+ComboSelectToolbar::~ComboSelectToolbar() {}
+
+static int FillPropertyCombo(QComboBox *c, obs_property_t *p, const std::string &cur_id, bool is_int = false)
+{
+	size_t count = obs_property_list_item_count(p);
+	int cur_idx = -1;
+
+	for (size_t i = 0; i < count; i++) {
+		const char *name = obs_property_list_item_name(p, i);
+		std::string id;
+
+		if (is_int) {
+			id = std::to_string(obs_property_list_item_int(p, i));
+		} else {
+			const char *val = obs_property_list_item_string(p, i);
+			id = val ? val : "";
+		}
+
+		if (cur_id == id)
+			cur_idx = (int)i;
+
+		c->addItem(name, id.c_str());
+	}
+
+	return cur_idx;
+}
+
+void UpdateSourceComboToolbarProperties(QComboBox *combo, OBSSource source, obs_properties_t *props,
+					const char *prop_name, bool is_int)
+{
+	std::string cur_id;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (is_int) {
+		cur_id = std::to_string(obs_data_get_int(settings, prop_name));
+	} else {
+		cur_id = obs_data_get_string(settings, prop_name);
+	}
+
+	combo->blockSignals(true);
+
+	obs_property_t *p = obs_properties_get(props, prop_name);
+	int cur_idx = FillPropertyCombo(combo, p, cur_id, is_int);
+
+	if (cur_idx == -1 || obs_property_list_item_disabled(p, cur_idx)) {
+		if (cur_idx == -1) {
+			combo->insertItem(0, QTStr("Basic.Settings.Audio.UnknownAudioDevice"));
+			cur_idx = 0;
+		}
+
+		SetComboItemEnabled(combo, cur_idx, false);
+	}
+
+	combo->setCurrentIndex(cur_idx);
+	combo->blockSignals(false);
+}
+
+void ComboSelectToolbar::Init()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	UpdateSourceComboToolbarProperties(ui->device, source, props.get(), prop_name, is_int);
+}
+
+void UpdateSourceComboToolbarValue(QComboBox *combo, OBSSource source, int idx, const char *prop_name, bool is_int)
+{
+	QString id = combo->itemData(idx).toString();
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (is_int) {
+		obs_data_set_int(settings, prop_name, id.toInt());
+	} else {
+		obs_data_set_string(settings, prop_name, QT_TO_UTF8(id));
+	}
+	obs_source_update(source, settings);
+}
+
+void ComboSelectToolbar::on_device_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	SaveOldProperties(source);
+	UpdateSourceComboToolbarValue(ui->device, source, idx, prop_name, is_int);
+	SetUndoProperties(source);
+}
+
+AudioCaptureToolbar::AudioCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void AudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-wasapi", "mac-capture", "linux-pulseaudio");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Device", "CoreAudio.Device", "Device");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "device_id";
+
+	ComboSelectToolbar::Init();
+}
+
+WindowCaptureToolbar::WindowCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void WindowCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "WindowCapture.Window", "WindowUtils.Window", "Window");
+	ui->deviceLabel->setText(device_str);
+
+#if !defined(_WIN32) && !defined(__APPLE__) //linux
+	prop_name = "capture_window";
+#else
+	prop_name = "window";
+#endif
+
+#ifdef __APPLE__
+	is_int = true;
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+ApplicationAudioCaptureToolbar::ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source)
+	: ComboSelectToolbar(parent, source)
+{
+}
+
+void ApplicationAudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = obs_get_module("win-wasapi");
+	const char *device_str = obs_module_get_locale_text(mod, "Window");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "window";
+
+	ComboSelectToolbar::Init();
+}
+
+DisplayCaptureToolbar::DisplayCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void DisplayCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Monitor", "DisplayCapture.Display", "Screen");
+	ui->deviceLabel->setText(device_str);
+
+#ifdef _WIN32
+	prop_name = "monitor_id";
+#elif __APPLE__
+	prop_name = "display_uuid";
+#else
+	is_int = true;
+	prop_name = "screen";
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+/* ========================================================================= */
+
+DeviceCaptureToolbar::DeviceCaptureToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+
+	delete ui->deviceLabel;
+	delete ui->device;
+	ui->deviceLabel = nullptr;
+	ui->device = nullptr;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	active = obs_data_get_bool(settings, "active");
+
+	obs_module_t *mod = obs_get_module("win-dshow");
+	if (!mod)
+		return;
+
+	activateText = obs_module_get_locale_text(mod, "Activate");
+	deactivateText = obs_module_get_locale_text(mod, "Deactivate");
+
+	ui->activateButton->setText(active ? deactivateText : activateText);
+}
+
+DeviceCaptureToolbar::~DeviceCaptureToolbar() {}
+
+void DeviceCaptureToolbar::on_activateButton_clicked()
+{
+	OBSSource source = OBSGetStrongRef(weakSource);
+	if (!source) {
+		return;
+	}
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	bool now_active = obs_data_get_bool(settings, "active");
+
+	bool desyncedSetting = now_active != active;
+
+	active = !active;
+
+	const char *text = active ? deactivateText : activateText;
+	ui->activateButton->setText(text);
+
+	if (desyncedSetting) {
+		return;
+	}
+
+	calldata_t cd = {};
+	calldata_set_bool(&cd, "active", active);
+	proc_handler_t *ph = obs_source_get_proc_handler(source);
+	proc_handler_call(ph, "activate", &cd);
+	calldata_free(&cd);
+}
+
+/* ========================================================================= */
+
+GameCaptureToolbar::GameCaptureToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_GameCaptureToolbar)
+{
+	obs_property_t *p;
+	int cur_idx;
+
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("win-capture");
+	if (!mod)
+		return;
+
+	ui->modeLabel->setText(obs_module_get_locale_text(mod, "Mode"));
+	ui->windowLabel->setText(obs_module_get_locale_text(mod, "WindowCapture.Window"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string cur_mode = obs_data_get_string(settings, "capture_mode");
+	std::string cur_window = obs_data_get_string(settings, "window");
+
+	ui->mode->blockSignals(true);
+	p = obs_properties_get(props.get(), "capture_mode");
+	cur_idx = FillPropertyCombo(ui->mode, p, cur_mode);
+	ui->mode->setCurrentIndex(cur_idx);
+	ui->mode->blockSignals(false);
+
+	ui->window->blockSignals(true);
+	p = obs_properties_get(props.get(), "window");
+	cur_idx = FillPropertyCombo(ui->window, p, cur_window);
+	ui->window->setCurrentIndex(cur_idx);
+	ui->window->blockSignals(false);
+
+	if (cur_idx != -1 && obs_property_list_item_disabled(p, cur_idx)) {
+		SetComboItemEnabled(ui->window, cur_idx, false);
+	}
+
+	UpdateWindowVisibility();
+}
+
+GameCaptureToolbar::~GameCaptureToolbar() {}
+
+void GameCaptureToolbar::UpdateWindowVisibility()
+{
+	QString mode = ui->mode->currentData().toString();
+	bool is_window = (mode == "window");
+	ui->windowLabel->setVisible(is_window);
+	ui->window->setVisible(is_window);
+}
+
+void GameCaptureToolbar::on_mode_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->mode->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "capture_mode", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+
+	UpdateWindowVisibility();
+}
+
+void GameCaptureToolbar::on_window_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->window->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "window", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+ImageSourceToolbar::ImageSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ImageSourceToolbar)
+{
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("image-source");
+	ui->pathLabel->setText(obs_module_get_locale_text(mod, "File"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string file = obs_data_get_string(settings, "file");
+
+	ui->path->setText(file.c_str());
+}
+
+ImageSourceToolbar::~ImageSourceToolbar() {}
+
+void ImageSourceToolbar::on_browse_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "file");
+	const char *desc = obs_property_description(p);
+	const char *filter = obs_property_path_filter(p);
+	const char *default_path = obs_property_path_default_path(p);
+
+	QString startDir = ui->path->text();
+	if (startDir.isEmpty())
+		startDir = default_path;
+
+	QString path = OpenFile(this, desc, startDir, filter);
+	if (path.isEmpty()) {
+		return;
+	}
+
+	ui->path->setText(path);
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "file", QT_TO_UTF8(path));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+static inline QColor color_from_int(long long val)
+{
+	return QColor(val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, (val >> 24) & 0xff);
+}
+
+static inline long long color_to_int(QColor color)
+{
+	auto shift = [&](unsigned val, int shift) {
+		return ((val & 0xff) << shift);
+	};
+
+	return shift(color.red(), 0) | shift(color.green(), 8) | shift(color.blue(), 16) | shift(color.alpha(), 24);
+}
+
+ColorSourceToolbar::ColorSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ColorSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	unsigned int val = (unsigned int)obs_data_get_int(settings, "color");
+
+	color = color_from_int(val);
+	UpdateColor();
+}
+
+ColorSourceToolbar::~ColorSourceToolbar() {}
+
+void ColorSourceToolbar::UpdateColor()
+{
+	QPalette palette = QPalette(color);
+	ui->color->setFrameStyle(QFrame::Sunken | QFrame::Panel);
+	ui->color->setText(color.name(QColor::HexRgb));
+	ui->color->setPalette(palette);
+	ui->color->setStyleSheet(QString("background-color :%1; color: %2;")
+					 .arg(palette.color(QPalette::Window).name(QColor::HexRgb))
+					 .arg(palette.color(QPalette::WindowText).name(QColor::HexRgb)));
+	ui->color->setAutoFillBackground(true);
+	ui->color->setAlignment(Qt::AlignCenter);
+}
+
+void ColorSourceToolbar::on_choose_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "color");
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+	UpdateColor();
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_int(settings, "color", color_to_int(color));
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+extern void MakeQFont(obs_data_t *font_obj, QFont &font, bool limit = false);
+
+TextSourceToolbar::TextSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_TextSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+
+	const char *id = obs_source_get_unversioned_id(source);
+	bool ft2 = strcmp(id, "text_ft2_source") == 0;
+	bool read_from_file = obs_data_get_bool(settings, ft2 ? "from_file" : "read_from_file");
+
+	OBSDataAutoRelease font_obj = obs_data_get_obj(settings, "font");
+	MakeQFont(font_obj, font);
+
+	// Use "color1" if it's a freetype source and "color" elsewise
+	unsigned int val = (unsigned int)obs_data_get_int(
+		settings, (strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0) ? "color1" : "color");
+
+	color = color_from_int(val);
+
+	const char *text = obs_data_get_string(settings, "text");
+
+	bool single_line = !read_from_file && (!text || (strchr(text, '\n') == nullptr));
+	ui->emptySpace->setVisible(!single_line);
+	ui->text->setVisible(single_line);
+	if (single_line)
+		ui->text->setText(text);
+}
+
+TextSourceToolbar::~TextSourceToolbar() {}
+
+void TextSourceToolbar::on_selectFont_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	QFontDialog::FontDialogOptions options;
+	uint32_t flags;
+	bool success;
+
+#ifndef _WIN32
+	options = QFontDialog::DontUseNativeDialog;
+#endif
+
+	font = QFontDialog::getFont(&success, font, this, QTStr("Basic.PropertiesWindow.SelectFont.WindowTitle"),
+				    options);
+	if (!success) {
+		return;
+	}
+
+	OBSDataAutoRelease font_obj = obs_data_create();
+
+	obs_data_set_string(font_obj, "face", QT_TO_UTF8(font.family()));
+	obs_data_set_string(font_obj, "style", QT_TO_UTF8(font.styleName()));
+	obs_data_set_int(font_obj, "size", font.pointSize());
+	flags = font.bold() ? OBS_FONT_BOLD : 0;
+	flags |= font.italic() ? OBS_FONT_ITALIC : 0;
+	flags |= font.underline() ? OBS_FONT_UNDERLINE : 0;
+	flags |= font.strikeOut() ? OBS_FONT_STRIKEOUT : 0;
+	obs_data_set_int(font_obj, "flags", flags);
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+
+	obs_data_set_obj(settings, "font", font_obj);
+
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_selectColor_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	bool freetype = strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0;
+
+	obs_property_t *p = obs_properties_get(props.get(), freetype ? "color1" : "color");
+
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (freetype) {
+		obs_data_set_int(settings, "color1", color_to_int(color));
+		obs_data_set_int(settings, "color2", color_to_int(color));
+	} else {
+		obs_data_set_int(settings, "color", color_to_int(color));
+	}
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_text_textChanged()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+	std::string newText = QT_TO_UTF8(ui->text->text());
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (newText == obs_data_get_string(settings, "text")) {
+		return;
+	}
+	SaveOldProperties(source);
+
+	obs_data_set_string(settings, "text", newText.c_str());
+	obs_source_update(source, nullptr);
+
+	SetUndoProperties(source, true);
+}

+ 178 - 0
frontend/components/SourceToolbar.hpp

@@ -0,0 +1,178 @@
+#pragma once
+
+#include <memory>
+#include <obs.hpp>
+#include <QWidget>
+
+class Ui_BrowserSourceToolbar;
+class Ui_DeviceSelectToolbar;
+class Ui_GameCaptureToolbar;
+class Ui_ImageSourceToolbar;
+class Ui_ColorSourceToolbar;
+class Ui_TextSourceToolbar;
+
+class SourceToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+protected:
+	using properties_delete_t = decltype(&obs_properties_destroy);
+	using properties_t = std::unique_ptr<obs_properties_t, properties_delete_t>;
+
+	properties_t props;
+	OBSDataAutoRelease oldData;
+
+	void SaveOldProperties(obs_source_t *source);
+	void SetUndoProperties(obs_source_t *source, bool repeatable = false);
+
+public:
+	SourceToolbar(QWidget *parent, OBSSource source);
+
+	OBSSource GetSource() { return OBSGetStrongRef(weakSource); }
+
+public slots:
+	virtual void Update() {}
+};
+
+class BrowserToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_BrowserSourceToolbar> ui;
+
+public:
+	BrowserToolbar(QWidget *parent, OBSSource source);
+	~BrowserToolbar();
+
+public slots:
+	void on_refresh_clicked();
+};
+
+class ComboSelectToolbar : public SourceToolbar {
+	Q_OBJECT
+
+protected:
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *prop_name;
+	bool is_int = false;
+
+public:
+	ComboSelectToolbar(QWidget *parent, OBSSource source);
+	~ComboSelectToolbar();
+	virtual void Init();
+
+public slots:
+	void on_device_currentIndexChanged(int idx);
+};
+
+class AudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	AudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class WindowCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	WindowCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class ApplicationAudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DisplayCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	DisplayCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DeviceCaptureToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *activateText;
+	const char *deactivateText;
+	bool active;
+
+public:
+	DeviceCaptureToolbar(QWidget *parent, OBSSource source);
+	~DeviceCaptureToolbar();
+
+public slots:
+	void on_activateButton_clicked();
+};
+
+class GameCaptureToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_GameCaptureToolbar> ui;
+
+	void UpdateWindowVisibility();
+
+public:
+	GameCaptureToolbar(QWidget *parent, OBSSource source);
+	~GameCaptureToolbar();
+
+public slots:
+	void on_mode_currentIndexChanged(int idx);
+	void on_window_currentIndexChanged(int idx);
+};
+
+class ImageSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ImageSourceToolbar> ui;
+
+public:
+	ImageSourceToolbar(QWidget *parent, OBSSource source);
+	~ImageSourceToolbar();
+
+public slots:
+	void on_browse_clicked();
+};
+
+class ColorSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ColorSourceToolbar> ui;
+	QColor color;
+
+	void UpdateColor();
+
+public:
+	ColorSourceToolbar(QWidget *parent, OBSSource source);
+	~ColorSourceToolbar();
+
+public slots:
+	void on_choose_clicked();
+};
+
+class TextSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_TextSourceToolbar> ui;
+	QFont font;
+	QColor color;
+
+public:
+	TextSourceToolbar(QWidget *parent, OBSSource source);
+	~TextSourceToolbar();
+
+public slots:
+	void on_selectFont_clicked();
+	void on_selectColor_clicked();
+	void on_text_textChanged();
+};

+ 0 - 0
UI/source-tree.cpp → frontend/components/SourceTree.cpp


+ 0 - 0
UI/source-tree.hpp → frontend/components/SourceTree.hpp


+ 1613 - 0
frontend/components/SourceTreeDelegate.cpp

@@ -0,0 +1,1613 @@
+#include "window-basic-main.hpp"
+#include "obs-app.hpp"
+#include "source-tree.hpp"
+#include "platform.hpp"
+#include "source-label.hpp"
+
+#include <qt-wrappers.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 <QAccessible>
+
+#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);
+	setMouseTracking(true);
+
+	obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+	const char *name = obs_source_get_name(source);
+
+	OBSDataAutoRelease privData = obs_sceneitem_get_private_settings(sceneitem);
+	int preset = obs_data_get_int(privData, "color-preset");
+
+	if (preset == 1) {
+		const char *color = obs_data_get_string(privData, "color");
+		std::string col = "background: ";
+		col += color;
+		setStyleSheet(col.c_str());
+	} else if (preset > 1) {
+		setStyleSheet("");
+		setProperty("bgColor", preset - 1);
+	} else {
+		setStyleSheet("background: none");
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+	const char *id = obs_source_get_id(source);
+
+	bool sourceVisible = obs_sceneitem_visible(sceneitem);
+
+	if (tree->iconsVisible) {
+		QIcon icon;
+
+		if (strcmp(id, "scene") == 0)
+			icon = main->GetSceneIcon();
+		else if (strcmp(id, "group") == 0)
+			icon = main->GetGroupIcon();
+		else
+			icon = main->GetSourceIcon(id);
+
+		QPixmap pixmap = icon.pixmap(QSize(16, 16));
+
+		iconLabel = new QLabel();
+		iconLabel->setPixmap(pixmap);
+		iconLabel->setEnabled(sourceVisible);
+		iconLabel->setStyleSheet("background: none");
+		iconLabel->setProperty("class", "source-icon");
+	}
+
+	vis = new QCheckBox();
+	vis->setProperty("class", "checkbox-icon indicator-visibility");
+	vis->setChecked(sourceVisible);
+	vis->setAccessibleName(QTStr("Basic.Main.Sources.Visibility"));
+	vis->setAccessibleDescription(QTStr("Basic.Main.Sources.VisibilityDescription").arg(name));
+
+	lock = new QCheckBox();
+	lock->setProperty("class", "checkbox-icon indicator-lock");
+	lock->setChecked(obs_sceneitem_locked(sceneitem));
+	lock->setAccessibleName(QTStr("Basic.Main.Sources.Lock"));
+	lock->setAccessibleDescription(QTStr("Basic.Main.Sources.LockDescription").arg(name));
+
+	label = new OBSSourceLabel(source);
+	label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+	label->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
+	label->setAttribute(Qt::WA_TranslucentBackground);
+	label->setEnabled(sourceVisible);
+
+#ifdef __APPLE__
+	vis->setAttribute(Qt::WA_LayoutUsesWidgetRect);
+	lock->setAttribute(Qt::WA_LayoutUsesWidgetRect);
+#endif
+
+	boxLayout = new QHBoxLayout();
+
+	boxLayout->setContentsMargins(0, 0, 0, 0);
+	boxLayout->setSpacing(0);
+	if (iconLabel) {
+		boxLayout->addWidget(iconLabel);
+		boxLayout->addSpacing(2);
+	}
+	boxLayout->addWidget(label);
+	boxLayout->addWidget(vis);
+	boxLayout->addWidget(lock);
+#ifdef __APPLE__
+	/* Hack: Fixes a bug where scrollbars would be above the lock icon */
+	boxLayout->addSpacing(16);
+#endif
+
+	Update(false);
+
+	setLayout(boxLayout);
+
+	/* --------------------------------------------------------- */
+
+	auto setItemVisible = [this](bool val) {
+		obs_scene_t *scene = obs_sceneitem_get_scene(sceneitem);
+		obs_source_t *scenesource = obs_scene_get_source(scene);
+		int64_t id = obs_sceneitem_get_id(sceneitem);
+		const char *name = obs_source_get_name(scenesource);
+		const char *uuid = obs_source_get_uuid(scenesource);
+		obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+
+		auto undo_redo = [](const std::string &uuid, int64_t id, bool val) {
+			OBSSourceAutoRelease s = obs_get_source_by_uuid(uuid.c_str());
+			obs_scene_t *sc = obs_group_or_scene_from_source(s);
+			obs_sceneitem_t *si = obs_scene_find_sceneitem_by_id(sc, id);
+			if (si)
+				obs_sceneitem_set_visible(si, val);
+		};
+
+		QString str = QTStr(val ? "Undo.ShowSceneItem" : "Undo.HideSceneItem");
+
+		OBSBasic *main = OBSBasic::Get();
+		main->undo_s.add_action(str.arg(obs_source_get_name(source), name),
+					std::bind(undo_redo, std::placeholders::_1, id, !val),
+					std::bind(undo_redo, std::placeholders::_1, id, val), uuid, uuid);
+
+		QSignalBlocker sourcesSignalBlocker(this);
+		obs_sceneitem_set_visible(sceneitem, val);
+	};
+
+	auto setItemLocked = [this](bool checked) {
+		QSignalBlocker sourcesSignalBlocker(this);
+		obs_sceneitem_set_locked(sceneitem, checked);
+	};
+
+	connect(vis, &QAbstractButton::clicked, setItemVisible);
+	connect(lock, &QAbstractButton::clicked, setItemLocked);
+}
+
+void SourceTreeItem::paintEvent(QPaintEvent *event)
+{
+	QStyleOption opt;
+	opt.initFrom(this);
+	QPainter p(this);
+	style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
+
+	QWidget::paintEvent(event);
+}
+
+void SourceTreeItem::DisconnectSignals()
+{
+	sigs.clear();
+}
+
+void SourceTreeItem::Clear()
+{
+	DisconnectSignals();
+	sceneitem = nullptr;
+}
+
+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");
+		obs_scene_t *curScene = (obs_scene_t *)calldata_ptr(cd, "scene");
+
+		if (curItem == this_->sceneitem) {
+			QMetaObject::invokeMethod(this_->tree, "Remove", Q_ARG(OBSSceneItem, curItem),
+						  Q_ARG(OBSScene, curScene));
+			curItem = nullptr;
+		}
+		if (!curItem)
+			QMetaObject::invokeMethod(this_, "Clear");
+	};
+
+	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));
+	};
+
+	auto itemLocked = [](void *data, calldata_t *cd) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		obs_sceneitem_t *curItem = (obs_sceneitem_t *)calldata_ptr(cd, "item");
+		bool locked = calldata_bool(cd, "locked");
+
+		if (curItem == this_->sceneitem)
+			QMetaObject::invokeMethod(this_, "LockedChanged", Q_ARG(bool, locked));
+	};
+
+	auto itemSelect = [](void *data, calldata_t *cd) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		obs_sceneitem_t *curItem = (obs_sceneitem_t *)calldata_ptr(cd, "item");
+
+		if (curItem == this_->sceneitem)
+			QMetaObject::invokeMethod(this_, "Select");
+	};
+
+	auto itemDeselect = [](void *data, calldata_t *cd) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		obs_sceneitem_t *curItem = (obs_sceneitem_t *)calldata_ptr(cd, "item");
+
+		if (curItem == this_->sceneitem)
+			QMetaObject::invokeMethod(this_, "Deselect");
+	};
+
+	auto reorderGroup = [](void *data, calldata_t *) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		QMetaObject::invokeMethod(this_->tree, "ReorderItems");
+	};
+
+	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);
+
+	sigs.emplace_back(signal, "remove", removeItem, this);
+	sigs.emplace_back(signal, "item_remove", removeItem, this);
+	sigs.emplace_back(signal, "item_visible", itemVisible, this);
+	sigs.emplace_back(signal, "item_locked", itemLocked, this);
+	sigs.emplace_back(signal, "item_select", itemSelect, this);
+	sigs.emplace_back(signal, "item_deselect", itemDeselect, this);
+
+	if (obs_sceneitem_is_group(sceneitem)) {
+		obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+		signal = obs_source_get_signal_handler(source);
+
+		sigs.emplace_back(signal, "reorder", reorderGroup, this);
+	}
+
+	/* --------------------------------------------------------- */
+
+	auto removeSource = [](void *data, calldata_t *) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		this_->DisconnectSignals();
+		this_->sceneitem = nullptr;
+		QMetaObject::invokeMethod(this_->tree, "RefreshItems");
+	};
+
+	obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+	signal = obs_source_get_signal_handler(source);
+	sigs.emplace_back(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 (obs_source_configurable(source)) {
+			main->CreatePropertiesWindow(source);
+		}
+	}
+}
+
+void SourceTreeItem::enterEvent(QEnterEvent *event)
+{
+	QWidget::enterEvent(event);
+
+	OBSBasicPreview *preview = OBSBasicPreview::Get();
+
+	std::lock_guard<std::mutex> lock(preview->selectMutex);
+	preview->hoveredPreviewItems.clear();
+	preview->hoveredPreviewItems.push_back(sceneitem);
+}
+
+void SourceTreeItem::leaveEvent(QEvent *event)
+{
+	QWidget::leaveEvent(event);
+
+	OBSBasicPreview *preview = OBSBasicPreview::Get();
+
+	std::lock_guard<std::mutex> lock(preview->selectMutex);
+	preview->hoveredPreviewItems.clear();
+}
+
+bool SourceTreeItem::IsEditing()
+{
+	return editor != nullptr;
+}
+
+void SourceTreeItem::EnterEditMode()
+{
+	setFocusPolicy(Qt::StrongFocus);
+	int index = boxLayout->indexOf(label);
+	boxLayout->removeWidget(label);
+	editor = new QLineEdit(label->text());
+	editor->setStyleSheet("background: none");
+	editor->selectAll();
+	editor->installEventFilter(this);
+	boxLayout->insertWidget(index, editor);
+	setFocusProxy(editor);
+}
+
+void SourceTreeItem::ExitEditMode(bool save)
+{
+	ExitEditModeInternal(save);
+
+	if (tree->undoSceneData) {
+		OBSBasic *main = OBSBasic::Get();
+		main->undo_s.pop_disabled();
+
+		OBSData redoSceneData = main->BackupScene(GetCurrentScene());
+
+		QString text = QTStr("Undo.GroupItems").arg(newName.c_str());
+		main->CreateSceneUndoRedoAction(text, tree->undoSceneData, redoSceneData);
+
+		tree->undoSceneData = nullptr;
+	}
+}
+
+void SourceTreeItem::ExitEditModeInternal(bool save)
+{
+	if (!editor) {
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+	OBSScene scene = main->GetCurrentScene();
+
+	newName = QT_TO_UTF8(editor->text());
+
+	setFocusProxy(nullptr);
+	int index = boxLayout->indexOf(editor);
+	boxLayout->removeWidget(editor);
+	delete editor;
+	editor = nullptr;
+	setFocusPolicy(Qt::NoFocus);
+	boxLayout->insertWidget(index, label);
+	setFocus();
+
+	/* ----------------------------------------- */
+	/* 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                 */
+
+	OBSSourceAutoRelease existingSource = obs_get_source_by_name(newName.c_str());
+	bool exists = !!existingSource;
+
+	if (exists) {
+		OBSMessageBox::information(main, QTStr("NameExists.Title"), QTStr("NameExists.Text"));
+		return;
+	}
+
+	/* ----------------------------------------- */
+	/* rename                                    */
+
+	QSignalBlocker sourcesSignalBlocker(this);
+	std::string prevName(obs_source_get_name(source));
+	std::string scene_uuid = obs_source_get_uuid(main->GetCurrentSceneSource());
+	auto undo = [scene_uuid, prevName, main](const std::string &data) {
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(data.c_str());
+		obs_source_set_name(source, prevName.c_str());
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+	};
+
+	std::string editedName = newName;
+
+	auto redo = [scene_uuid, main, editedName](const std::string &data) {
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(data.c_str());
+		obs_source_set_name(source, editedName.c_str());
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+	};
+
+	const char *uuid = obs_source_get_uuid(source);
+	main->undo_s.add_action(QTStr("Undo.Rename").arg(newName.c_str()), undo, redo, uuid, uuid);
+
+	obs_source_set_name(source, newName.c_str());
+}
+
+bool SourceTreeItem::eventFilter(QObject *object, QEvent *event)
+{
+	if (editor != object)
+		return false;
+
+	if (LineEditCanceled(event)) {
+		QMetaObject::invokeMethod(this, "ExitEditMode", Qt::QueuedConnection, Q_ARG(bool, false));
+		return true;
+	}
+	if (LineEditChanged(event)) {
+		QMetaObject::invokeMethod(this, "ExitEditMode", Qt::QueuedConnection, Q_ARG(bool, true));
+		return true;
+	}
+
+	return false;
+}
+
+void SourceTreeItem::VisibilityChanged(bool visible)
+{
+	if (iconLabel) {
+		iconLabel->setEnabled(visible);
+	}
+	label->setEnabled(visible);
+	vis->setChecked(visible);
+}
+
+void SourceTreeItem::LockedChanged(bool locked)
+{
+	lock->setChecked(locked);
+	OBSBasic::Get()->UpdateEditMenu();
+}
+
+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 QCheckBox();
+		expand->setProperty("class", "checkbox-icon indicator-expand");
+#ifdef __APPLE__
+		expand->setAttribute(Qt::WA_LayoutUsesWidgetRect);
+#endif
+		boxLayout->insertWidget(0, expand);
+
+		OBSDataAutoRelease data = obs_sceneitem_get_private_settings(sceneitem);
+		expand->blockSignals(true);
+		expand->setChecked(obs_data_get_bool(data, "collapsed"));
+		expand->blockSignals(false);
+
+		connect(expand, &QPushButton::toggled, this, &SourceTreeItem::ExpandClicked);
+
+	} else {
+		spacer = new QSpacerItem(3, 1);
+		boxLayout->insertItem(0, spacer);
+	}
+}
+
+void SourceTreeItem::ExpandClicked(bool checked)
+{
+	OBSDataAutoRelease data = obs_sceneitem_get_private_settings(sceneitem);
+
+	obs_data_set_bool(data, "collapsed", checked);
+
+	if (!checked)
+		tree->GetStm()->ExpandGroup(sceneitem);
+	else
+		tree->GetStm()->CollapseGroup(sceneitem);
+}
+
+void SourceTreeItem::Select()
+{
+	tree->SelectItem(sceneitem, true);
+	OBSBasic::Get()->UpdateContextBarDeferred();
+	OBSBasic::Get()->UpdateEditMenu();
+}
+
+void SourceTreeItem::Deselect()
+{
+	tree->SelectItem(sceneitem, false);
+	OBSBasic::Get()->UpdateContextBarDeferred();
+	OBSBasic::Get()->UpdateEditMenu();
+}
+
+/* ========================================================================= */
+
+void SourceTreeModel::OBSFrontendEvent(enum obs_frontend_event event, void *ptr)
+{
+	SourceTreeModel *stm = reinterpret_cast<SourceTreeModel *>(ptr);
+
+	switch (event) {
+	case OBS_FRONTEND_EVENT_PREVIEW_SCENE_CHANGED:
+		stm->SceneChanged();
+		break;
+	case OBS_FRONTEND_EVENT_EXIT:
+		stm->Clear();
+		obs_frontend_remove_event_callback(OBSFrontendEvent, stm);
+		break;
+	case OBS_FRONTEND_EVENT_SCENE_COLLECTION_CLEANUP:
+		stm->Clear();
+		break;
+	default:
+		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);
+
+	obs_source_t *src = obs_sceneitem_get_source(item);
+	if (obs_source_removed(src)) {
+		return true;
+	}
+
+	if (obs_sceneitem_is_group(item)) {
+		OBSDataAutoRelease 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);
+		}
+	}
+
+	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)
+{
+	if (obs_sceneitem_is_group(item)) {
+		SceneChanged();
+	} else {
+		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);
+
+	OBSBasic::Get()->UpdateContextBarDeferred();
+}
+
+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);
+}
+
+int SourceTreeModel::rowCount(const QModelIndex &parent) const
+{
+	return parent.isValid() ? 0 : items.count();
+}
+
+QVariant SourceTreeModel::data(const QModelIndex &index, int role) const
+{
+	if (role == Qt::AccessibleTextRole) {
+		OBSSceneItem item = items[index.row()];
+		obs_source_t *source = obs_sceneitem_get_source(item);
+		return QVariant(QT_UTF8(obs_source_get_name(source)));
+	}
+
+	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 = QTStr("Group");
+
+	int i = 2;
+	for (;;) {
+		OBSSourceAutoRelease group = obs_get_source_by_name(QT_TO_UTF8(name));
+		if (!group)
+			break;
+		name = QTStr("Basic.Main.Group").arg(QString::number(i++));
+	}
+
+	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;
+
+	OBSBasic *main = OBSBasic::Get();
+	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;
+	}
+
+	st->undoSceneData = main->BackupScene(scene);
+
+	obs_sceneitem_t *item = obs_scene_insert_group(scene, QT_TO_UTF8(name), item_order.data(), item_order.size());
+	if (!item) {
+		st->undoSceneData = nullptr;
+		return;
+	}
+
+	main->undo_s.push_disabled();
+
+	for (obs_sceneitem_t *item : item_order)
+		obs_sceneitem_select(item, false);
+
+	hasGroups = true;
+	st->UpdateWidgets(true);
+
+	obs_sceneitem_select(item, true);
+
+	/* ----------------------------------------------------------------- */
+	/* obs_scene_insert_group triggers a full refresh of scene items via */
+	/* the item_add signal. No need to insert a row, just edit the one   */
+	/* that's created automatically.                                     */
+
+	int newIdx = indices[0].row();
+	QMetaObject::invokeMethod(st, "NewGroupEdit", Qt::QueuedConnection, Q_ARG(int, newIdx));
+}
+
+void SourceTreeModel::UngroupSelectedGroups(QModelIndexList &indices)
+{
+	OBSBasic *main = OBSBasic::Get();
+	if (indices.count() == 0)
+		return;
+
+	OBSScene scene = main->GetCurrentScene();
+	OBSData undoData = main->BackupScene(scene);
+
+	for (int i = indices.count() - 1; i >= 0; i--) {
+		obs_sceneitem_t *item = items[indices[i].row()];
+		obs_sceneitem_group_ungroup(item);
+	}
+
+	SceneChanged();
+
+	OBSData redoData = main->BackupScene(scene);
+	main->CreateSceneUndoRedoAction(QTStr("Basic.Main.Ungroup"), undoData, redoData);
+}
+
+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_);
+	setStyleSheet(QString("*[bgColor=\"1\"]{background-color:rgba(255,68,68,33%);}"
+			      "*[bgColor=\"2\"]{background-color:rgba(255,255,68,33%);}"
+			      "*[bgColor=\"3\"]{background-color:rgba(68,255,68,33%);}"
+			      "*[bgColor=\"4\"]{background-color:rgba(68,255,255,33%);}"
+			      "*[bgColor=\"5\"]{background-color:rgba(68,68,255,33%);}"
+			      "*[bgColor=\"6\"]{background-color:rgba(255,68,255,33%);}"
+			      "*[bgColor=\"7\"]{background-color:rgba(68,68,68,33%);}"
+			      "*[bgColor=\"8\"]{background-color:rgba(255,255,255,33%);}"));
+
+	UpdateNoSourcesMessage();
+	connect(App(), &OBSApp::StyleChanged, this, &SourceTree::UpdateNoSourcesMessage);
+	connect(App(), &OBSApp::StyleChanged, this, &SourceTree::UpdateIcons);
+
+	setItemDelegate(new SourceTreeDelegate(this));
+}
+
+void SourceTree::UpdateIcons()
+{
+	SourceTreeModel *stm = GetStm();
+	stm->SceneChanged();
+}
+
+void SourceTree::SetIconsVisible(bool visible)
+{
+	SourceTreeModel *stm = GetStm();
+
+	iconsVisible = visible;
+	stm->SceneChanged();
+}
+
+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() && select != selectionModel()->isSelected(index))
+		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;
+	}
+
+	OBSBasic *main = OBSBasic::Get();
+
+	OBSScene scene = GetCurrentScene();
+	obs_source_t *scenesource = obs_scene_get_source(scene);
+	SourceTreeModel *stm = GetStm();
+	auto &items = stm->items;
+	QModelIndexList indices = selectedIndexes();
+
+	DropIndicatorPosition indicator = dropIndicatorPosition();
+	int row = indexAt(event->position().toPoint()).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(scene, 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 ||
+	    indicator == QAbstractItemView::OnViewport)
+		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;
+	if (row == stm->items.count())
+		itemBelow = nullptr;
+	else
+		itemBelow = stm->items[row];
+
+	if (hasGroups) {
+		if (!itemBelow || obs_sceneitem_get_group(scene, itemBelow) != dropGroup) {
+			dropGroup = nullptr;
+			dropOnCollapsed = false;
+		}
+	}
+
+	/* --------------------------------------- */
+	/* if dropping groups on other groups,     */
+	/* disregard as invalid drag/drop          */
+
+	if (dropGroup && hasGroups) {
+		QListView::dropEvent(event);
+		return;
+	}
+
+	/* --------------------------------------- */
+	/* save undo data                          */
+	std::vector<obs_source_t *> sources;
+	for (int i = 0; i < indices.size(); i++) {
+		obs_sceneitem_t *item = items[indices[i].row()];
+		if (obs_sceneitem_get_scene(item) != scene)
+			sources.push_back(obs_scene_get_source(obs_sceneitem_get_scene(item)));
+	}
+	if (dropGroup)
+		sources.push_back(obs_sceneitem_get_source(dropGroup));
+	OBSData undo_data = main->BackupScene(scene, &sources);
+
+	/* --------------------------------------- */
+	/* 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(scene, 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 = [&]() {
+		OBSDataAutoRelease data = obs_sceneitem_get_private_settings(lastGroup);
+		bool collapsed = obs_data_get_bool(data, "collapsed");
+
+		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(scene, 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;
+
+	/* --------------------------------------- */
+	/* save redo data                          */
+
+	OBSData redo_data = main->BackupScene(scene, &sources);
+
+	/* --------------------------------------- */
+	/* add undo/redo action                    */
+
+	const char *scene_name = obs_source_get_name(scenesource);
+	QString action_name = QTStr("Undo.ReorderSources").arg(scene_name);
+	main->CreateSceneUndoRedoAction(action_name, undo_data, redo_data);
+
+	/* --------------------------------------- */
+	/* 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)
+{
+	{
+		QSignalBlocker 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::NewGroupEdit(int row)
+{
+	if (!Edit(row)) {
+		OBSBasic *main = OBSBasic::Get();
+		main->undo_s.pop_disabled();
+
+		blog(LOG_WARNING, "Uh, somehow the edit didn't process, this "
+				  "code should never be reached.\nAnd by "
+				  "\"never be reached\", I mean that "
+				  "theoretically, it should be\nimpossible "
+				  "for this code to be reached. But if this "
+				  "code is reached,\nfeel free to laugh at "
+				  "Lain, because apparently it is, in fact, "
+				  "actually\npossible for this code to be "
+				  "reached. But I mean, again, theoretically\n"
+				  "it should be impossible. So if you see "
+				  "this in your log, just know that\nit's "
+				  "really dumb, and depressing. But at least "
+				  "the undo/redo action is\nstill covered, so "
+				  "in theory things *should* be fine. But "
+				  "it's entirely\npossible that they might "
+				  "not be exactly. But again, yea. This "
+				  "really\nshould not be possible.");
+
+		OBSData redoSceneData = main->BackupScene(GetCurrentScene());
+
+		QString text = QTStr("Undo.GroupItems").arg("Unknown");
+		main->CreateSceneUndoRedoAction(text, undoSceneData, redoSceneData);
+
+		undoSceneData = nullptr;
+	}
+}
+
+bool SourceTree::Edit(int row)
+{
+	SourceTreeModel *stm = GetStm();
+	if (row < 0 || row >= stm->items.count())
+		return false;
+
+	QModelIndex index = stm->createIndex(row, 0);
+	QWidget *widget = indexWidget(index);
+	SourceTreeItem *itemWidget = reinterpret_cast<SourceTreeItem *>(widget);
+	if (itemWidget->IsEditing()) {
+#ifdef __APPLE__
+		itemWidget->ExitEditMode(true);
+#endif
+		return false;
+	}
+
+	itemWidget->EnterEditMode();
+	edit(index);
+	return true;
+}
+
+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::Remove(OBSSceneItem item, OBSScene scene)
+{
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+	GetStm()->Remove(item);
+	main->SaveProject();
+
+	if (!main->SavingDisabled()) {
+		obs_source_t *sceneSource = obs_scene_get_source(scene);
+		obs_source_t *itemSource = obs_sceneitem_get_source(item);
+		blog(LOG_INFO, "User Removed source '%s' (%s) from scene '%s'", obs_source_get_name(itemSource),
+		     obs_source_get_id(itemSource), obs_source_get_name(sceneSource));
+	}
+}
+
+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();
+}
+
+void SourceTree::UpdateNoSourcesMessage()
+{
+	QString file = !App()->IsThemeDark() ? ":res/images/no_sources.svg" : "theme:Dark/no_sources.svg";
+	iconNoSources.load(file);
+
+	QTextOption opt(Qt::AlignHCenter);
+	opt.setWrapMode(QTextOption::WordWrap);
+	textNoSources.setTextOption(opt);
+	textNoSources.setText(QTStr("NoSources.Label").replace("\n", "<br/>"));
+
+	textPrepared = false;
+}
+
+void SourceTree::paintEvent(QPaintEvent *event)
+{
+	SourceTreeModel *stm = GetStm();
+	if (stm && !stm->items.count()) {
+		QPainter p(viewport());
+
+		if (!textPrepared) {
+			textNoSources.prepare(QTransform(), p.font());
+			textPrepared = true;
+		}
+
+		QRectF iconRect = iconNoSources.viewBoxF();
+		iconRect.setSize(QSizeF(32.0, 32.0));
+
+		QSizeF iconSize = iconRect.size();
+		QSizeF textSize = textNoSources.size();
+		QSizeF thisSize = size();
+		const qreal spacing = 16.0;
+
+		qreal totalHeight = iconSize.height() + spacing + textSize.height();
+
+		qreal x = thisSize.width() / 2.0 - iconSize.width() / 2.0;
+		qreal y = thisSize.height() / 2.0 - totalHeight / 2.0;
+		iconRect.moveTo(std::round(x), std::round(y));
+		iconNoSources.render(&p, iconRect);
+
+		x = thisSize.width() / 2.0 - textSize.width() / 2.0;
+		y += spacing + iconSize.height();
+		p.drawStaticText(x, y, textNoSources);
+	} else {
+		QListView::paintEvent(event);
+	}
+}
+
+SourceTreeDelegate::SourceTreeDelegate(QObject *parent) : QStyledItemDelegate(parent) {}
+
+QSize SourceTreeDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
+{
+	SourceTree *tree = qobject_cast<SourceTree *>(parent());
+	QWidget *item = tree->indexWidget(index);
+
+	if (!item)
+		return (QSize(0, 0));
+
+	return (QSize(option.widget->minimumWidth(), item->height()));
+}

+ 202 - 0
frontend/components/SourceTreeDelegate.hpp

@@ -0,0 +1,202 @@
+#pragma once
+
+#include <QList>
+#include <QVector>
+#include <QPointer>
+#include <QListView>
+#include <QCheckBox>
+#include <QStaticText>
+#include <QSvgRenderer>
+#include <QAbstractListModel>
+#include <QStyledItemDelegate>
+#include <obs.hpp>
+#include <obs-frontend-api.h>
+
+class QLabel;
+class OBSSourceLabel;
+class QCheckBox;
+class QLineEdit;
+class SourceTree;
+class QSpacerItem;
+class QHBoxLayout;
+class VisibilityItemWidget;
+
+class SourceTreeItem : public QFrame {
+	Q_OBJECT
+
+	friend class SourceTree;
+	friend class SourceTreeModel;
+
+	void mouseDoubleClickEvent(QMouseEvent *event) override;
+	void enterEvent(QEnterEvent *event) override;
+	void leaveEvent(QEvent *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);
+	bool IsEditing();
+
+private:
+	QSpacerItem *spacer = nullptr;
+	QCheckBox *expand = nullptr;
+	QLabel *iconLabel = nullptr;
+	QCheckBox *vis = nullptr;
+	QCheckBox *lock = nullptr;
+	QHBoxLayout *boxLayout = nullptr;
+	OBSSourceLabel *label = nullptr;
+
+	QLineEdit *editor = nullptr;
+
+	std::string newName;
+
+	SourceTree *tree;
+	OBSSceneItem sceneitem;
+	std::vector<OBSSignal> sigs;
+
+	virtual void paintEvent(QPaintEvent *event) override;
+
+	void ExitEditModeInternal(bool save);
+
+private slots:
+	void Clear();
+
+	void EnterEditMode();
+	void ExitEditMode(bool save);
+
+	void VisibilityChanged(bool visible);
+	void LockedChanged(bool locked);
+
+	void ExpandClicked(bool checked);
+
+	void Select();
+	void Deselect();
+};
+
+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);
+
+	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;
+
+	bool textPrepared = false;
+	QStaticText textNoSources;
+	QSvgRenderer iconNoSources;
+
+	OBSData undoSceneData;
+
+	bool iconsVisible = true;
+
+	void UpdateNoSourcesMessage();
+
+	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()); }
+
+public:
+	inline SourceTreeItem *GetItemWidget(int idx)
+	{
+		QWidget *widget = indexWidget(GetStm()->createIndex(idx, 0));
+		return reinterpret_cast<SourceTreeItem *>(widget);
+	}
+
+	explicit SourceTree(QWidget *parent = nullptr);
+
+	inline bool IgnoreReorder() const { return ignoreReorder; }
+	inline void Clear() { GetStm()->Clear(); }
+
+	inline void Add(obs_sceneitem_t *item) { GetStm()->Add(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;
+
+	void UpdateIcons();
+	void SetIconsVisible(bool visible);
+
+public slots:
+	inline void ReorderItems() { GetStm()->ReorderItems(); }
+	inline void RefreshItems() { GetStm()->SceneChanged(); }
+	void Remove(OBSSceneItem item, OBSScene scene);
+	void GroupSelectedItems();
+	void UngroupSelectedGroups();
+	void AddGroup();
+	bool Edit(int idx);
+	void NewGroupEdit(int idx);
+
+protected:
+	virtual void mouseDoubleClickEvent(QMouseEvent *event) override;
+	virtual void dropEvent(QDropEvent *event) override;
+	virtual void paintEvent(QPaintEvent *event) override;
+
+	virtual void selectionChanged(const QItemSelection &selected, const QItemSelection &deselected) override;
+};
+
+class SourceTreeDelegate : public QStyledItemDelegate {
+	Q_OBJECT
+
+public:
+	SourceTreeDelegate(QObject *parent);
+	virtual QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
+};

+ 1613 - 0
frontend/components/SourceTreeItem.cpp

@@ -0,0 +1,1613 @@
+#include "window-basic-main.hpp"
+#include "obs-app.hpp"
+#include "source-tree.hpp"
+#include "platform.hpp"
+#include "source-label.hpp"
+
+#include <qt-wrappers.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 <QAccessible>
+
+#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);
+	setMouseTracking(true);
+
+	obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+	const char *name = obs_source_get_name(source);
+
+	OBSDataAutoRelease privData = obs_sceneitem_get_private_settings(sceneitem);
+	int preset = obs_data_get_int(privData, "color-preset");
+
+	if (preset == 1) {
+		const char *color = obs_data_get_string(privData, "color");
+		std::string col = "background: ";
+		col += color;
+		setStyleSheet(col.c_str());
+	} else if (preset > 1) {
+		setStyleSheet("");
+		setProperty("bgColor", preset - 1);
+	} else {
+		setStyleSheet("background: none");
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+	const char *id = obs_source_get_id(source);
+
+	bool sourceVisible = obs_sceneitem_visible(sceneitem);
+
+	if (tree->iconsVisible) {
+		QIcon icon;
+
+		if (strcmp(id, "scene") == 0)
+			icon = main->GetSceneIcon();
+		else if (strcmp(id, "group") == 0)
+			icon = main->GetGroupIcon();
+		else
+			icon = main->GetSourceIcon(id);
+
+		QPixmap pixmap = icon.pixmap(QSize(16, 16));
+
+		iconLabel = new QLabel();
+		iconLabel->setPixmap(pixmap);
+		iconLabel->setEnabled(sourceVisible);
+		iconLabel->setStyleSheet("background: none");
+		iconLabel->setProperty("class", "source-icon");
+	}
+
+	vis = new QCheckBox();
+	vis->setProperty("class", "checkbox-icon indicator-visibility");
+	vis->setChecked(sourceVisible);
+	vis->setAccessibleName(QTStr("Basic.Main.Sources.Visibility"));
+	vis->setAccessibleDescription(QTStr("Basic.Main.Sources.VisibilityDescription").arg(name));
+
+	lock = new QCheckBox();
+	lock->setProperty("class", "checkbox-icon indicator-lock");
+	lock->setChecked(obs_sceneitem_locked(sceneitem));
+	lock->setAccessibleName(QTStr("Basic.Main.Sources.Lock"));
+	lock->setAccessibleDescription(QTStr("Basic.Main.Sources.LockDescription").arg(name));
+
+	label = new OBSSourceLabel(source);
+	label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+	label->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
+	label->setAttribute(Qt::WA_TranslucentBackground);
+	label->setEnabled(sourceVisible);
+
+#ifdef __APPLE__
+	vis->setAttribute(Qt::WA_LayoutUsesWidgetRect);
+	lock->setAttribute(Qt::WA_LayoutUsesWidgetRect);
+#endif
+
+	boxLayout = new QHBoxLayout();
+
+	boxLayout->setContentsMargins(0, 0, 0, 0);
+	boxLayout->setSpacing(0);
+	if (iconLabel) {
+		boxLayout->addWidget(iconLabel);
+		boxLayout->addSpacing(2);
+	}
+	boxLayout->addWidget(label);
+	boxLayout->addWidget(vis);
+	boxLayout->addWidget(lock);
+#ifdef __APPLE__
+	/* Hack: Fixes a bug where scrollbars would be above the lock icon */
+	boxLayout->addSpacing(16);
+#endif
+
+	Update(false);
+
+	setLayout(boxLayout);
+
+	/* --------------------------------------------------------- */
+
+	auto setItemVisible = [this](bool val) {
+		obs_scene_t *scene = obs_sceneitem_get_scene(sceneitem);
+		obs_source_t *scenesource = obs_scene_get_source(scene);
+		int64_t id = obs_sceneitem_get_id(sceneitem);
+		const char *name = obs_source_get_name(scenesource);
+		const char *uuid = obs_source_get_uuid(scenesource);
+		obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+
+		auto undo_redo = [](const std::string &uuid, int64_t id, bool val) {
+			OBSSourceAutoRelease s = obs_get_source_by_uuid(uuid.c_str());
+			obs_scene_t *sc = obs_group_or_scene_from_source(s);
+			obs_sceneitem_t *si = obs_scene_find_sceneitem_by_id(sc, id);
+			if (si)
+				obs_sceneitem_set_visible(si, val);
+		};
+
+		QString str = QTStr(val ? "Undo.ShowSceneItem" : "Undo.HideSceneItem");
+
+		OBSBasic *main = OBSBasic::Get();
+		main->undo_s.add_action(str.arg(obs_source_get_name(source), name),
+					std::bind(undo_redo, std::placeholders::_1, id, !val),
+					std::bind(undo_redo, std::placeholders::_1, id, val), uuid, uuid);
+
+		QSignalBlocker sourcesSignalBlocker(this);
+		obs_sceneitem_set_visible(sceneitem, val);
+	};
+
+	auto setItemLocked = [this](bool checked) {
+		QSignalBlocker sourcesSignalBlocker(this);
+		obs_sceneitem_set_locked(sceneitem, checked);
+	};
+
+	connect(vis, &QAbstractButton::clicked, setItemVisible);
+	connect(lock, &QAbstractButton::clicked, setItemLocked);
+}
+
+void SourceTreeItem::paintEvent(QPaintEvent *event)
+{
+	QStyleOption opt;
+	opt.initFrom(this);
+	QPainter p(this);
+	style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
+
+	QWidget::paintEvent(event);
+}
+
+void SourceTreeItem::DisconnectSignals()
+{
+	sigs.clear();
+}
+
+void SourceTreeItem::Clear()
+{
+	DisconnectSignals();
+	sceneitem = nullptr;
+}
+
+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");
+		obs_scene_t *curScene = (obs_scene_t *)calldata_ptr(cd, "scene");
+
+		if (curItem == this_->sceneitem) {
+			QMetaObject::invokeMethod(this_->tree, "Remove", Q_ARG(OBSSceneItem, curItem),
+						  Q_ARG(OBSScene, curScene));
+			curItem = nullptr;
+		}
+		if (!curItem)
+			QMetaObject::invokeMethod(this_, "Clear");
+	};
+
+	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));
+	};
+
+	auto itemLocked = [](void *data, calldata_t *cd) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		obs_sceneitem_t *curItem = (obs_sceneitem_t *)calldata_ptr(cd, "item");
+		bool locked = calldata_bool(cd, "locked");
+
+		if (curItem == this_->sceneitem)
+			QMetaObject::invokeMethod(this_, "LockedChanged", Q_ARG(bool, locked));
+	};
+
+	auto itemSelect = [](void *data, calldata_t *cd) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		obs_sceneitem_t *curItem = (obs_sceneitem_t *)calldata_ptr(cd, "item");
+
+		if (curItem == this_->sceneitem)
+			QMetaObject::invokeMethod(this_, "Select");
+	};
+
+	auto itemDeselect = [](void *data, calldata_t *cd) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		obs_sceneitem_t *curItem = (obs_sceneitem_t *)calldata_ptr(cd, "item");
+
+		if (curItem == this_->sceneitem)
+			QMetaObject::invokeMethod(this_, "Deselect");
+	};
+
+	auto reorderGroup = [](void *data, calldata_t *) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		QMetaObject::invokeMethod(this_->tree, "ReorderItems");
+	};
+
+	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);
+
+	sigs.emplace_back(signal, "remove", removeItem, this);
+	sigs.emplace_back(signal, "item_remove", removeItem, this);
+	sigs.emplace_back(signal, "item_visible", itemVisible, this);
+	sigs.emplace_back(signal, "item_locked", itemLocked, this);
+	sigs.emplace_back(signal, "item_select", itemSelect, this);
+	sigs.emplace_back(signal, "item_deselect", itemDeselect, this);
+
+	if (obs_sceneitem_is_group(sceneitem)) {
+		obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+		signal = obs_source_get_signal_handler(source);
+
+		sigs.emplace_back(signal, "reorder", reorderGroup, this);
+	}
+
+	/* --------------------------------------------------------- */
+
+	auto removeSource = [](void *data, calldata_t *) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		this_->DisconnectSignals();
+		this_->sceneitem = nullptr;
+		QMetaObject::invokeMethod(this_->tree, "RefreshItems");
+	};
+
+	obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+	signal = obs_source_get_signal_handler(source);
+	sigs.emplace_back(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 (obs_source_configurable(source)) {
+			main->CreatePropertiesWindow(source);
+		}
+	}
+}
+
+void SourceTreeItem::enterEvent(QEnterEvent *event)
+{
+	QWidget::enterEvent(event);
+
+	OBSBasicPreview *preview = OBSBasicPreview::Get();
+
+	std::lock_guard<std::mutex> lock(preview->selectMutex);
+	preview->hoveredPreviewItems.clear();
+	preview->hoveredPreviewItems.push_back(sceneitem);
+}
+
+void SourceTreeItem::leaveEvent(QEvent *event)
+{
+	QWidget::leaveEvent(event);
+
+	OBSBasicPreview *preview = OBSBasicPreview::Get();
+
+	std::lock_guard<std::mutex> lock(preview->selectMutex);
+	preview->hoveredPreviewItems.clear();
+}
+
+bool SourceTreeItem::IsEditing()
+{
+	return editor != nullptr;
+}
+
+void SourceTreeItem::EnterEditMode()
+{
+	setFocusPolicy(Qt::StrongFocus);
+	int index = boxLayout->indexOf(label);
+	boxLayout->removeWidget(label);
+	editor = new QLineEdit(label->text());
+	editor->setStyleSheet("background: none");
+	editor->selectAll();
+	editor->installEventFilter(this);
+	boxLayout->insertWidget(index, editor);
+	setFocusProxy(editor);
+}
+
+void SourceTreeItem::ExitEditMode(bool save)
+{
+	ExitEditModeInternal(save);
+
+	if (tree->undoSceneData) {
+		OBSBasic *main = OBSBasic::Get();
+		main->undo_s.pop_disabled();
+
+		OBSData redoSceneData = main->BackupScene(GetCurrentScene());
+
+		QString text = QTStr("Undo.GroupItems").arg(newName.c_str());
+		main->CreateSceneUndoRedoAction(text, tree->undoSceneData, redoSceneData);
+
+		tree->undoSceneData = nullptr;
+	}
+}
+
+void SourceTreeItem::ExitEditModeInternal(bool save)
+{
+	if (!editor) {
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+	OBSScene scene = main->GetCurrentScene();
+
+	newName = QT_TO_UTF8(editor->text());
+
+	setFocusProxy(nullptr);
+	int index = boxLayout->indexOf(editor);
+	boxLayout->removeWidget(editor);
+	delete editor;
+	editor = nullptr;
+	setFocusPolicy(Qt::NoFocus);
+	boxLayout->insertWidget(index, label);
+	setFocus();
+
+	/* ----------------------------------------- */
+	/* 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                 */
+
+	OBSSourceAutoRelease existingSource = obs_get_source_by_name(newName.c_str());
+	bool exists = !!existingSource;
+
+	if (exists) {
+		OBSMessageBox::information(main, QTStr("NameExists.Title"), QTStr("NameExists.Text"));
+		return;
+	}
+
+	/* ----------------------------------------- */
+	/* rename                                    */
+
+	QSignalBlocker sourcesSignalBlocker(this);
+	std::string prevName(obs_source_get_name(source));
+	std::string scene_uuid = obs_source_get_uuid(main->GetCurrentSceneSource());
+	auto undo = [scene_uuid, prevName, main](const std::string &data) {
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(data.c_str());
+		obs_source_set_name(source, prevName.c_str());
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+	};
+
+	std::string editedName = newName;
+
+	auto redo = [scene_uuid, main, editedName](const std::string &data) {
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(data.c_str());
+		obs_source_set_name(source, editedName.c_str());
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+	};
+
+	const char *uuid = obs_source_get_uuid(source);
+	main->undo_s.add_action(QTStr("Undo.Rename").arg(newName.c_str()), undo, redo, uuid, uuid);
+
+	obs_source_set_name(source, newName.c_str());
+}
+
+bool SourceTreeItem::eventFilter(QObject *object, QEvent *event)
+{
+	if (editor != object)
+		return false;
+
+	if (LineEditCanceled(event)) {
+		QMetaObject::invokeMethod(this, "ExitEditMode", Qt::QueuedConnection, Q_ARG(bool, false));
+		return true;
+	}
+	if (LineEditChanged(event)) {
+		QMetaObject::invokeMethod(this, "ExitEditMode", Qt::QueuedConnection, Q_ARG(bool, true));
+		return true;
+	}
+
+	return false;
+}
+
+void SourceTreeItem::VisibilityChanged(bool visible)
+{
+	if (iconLabel) {
+		iconLabel->setEnabled(visible);
+	}
+	label->setEnabled(visible);
+	vis->setChecked(visible);
+}
+
+void SourceTreeItem::LockedChanged(bool locked)
+{
+	lock->setChecked(locked);
+	OBSBasic::Get()->UpdateEditMenu();
+}
+
+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 QCheckBox();
+		expand->setProperty("class", "checkbox-icon indicator-expand");
+#ifdef __APPLE__
+		expand->setAttribute(Qt::WA_LayoutUsesWidgetRect);
+#endif
+		boxLayout->insertWidget(0, expand);
+
+		OBSDataAutoRelease data = obs_sceneitem_get_private_settings(sceneitem);
+		expand->blockSignals(true);
+		expand->setChecked(obs_data_get_bool(data, "collapsed"));
+		expand->blockSignals(false);
+
+		connect(expand, &QPushButton::toggled, this, &SourceTreeItem::ExpandClicked);
+
+	} else {
+		spacer = new QSpacerItem(3, 1);
+		boxLayout->insertItem(0, spacer);
+	}
+}
+
+void SourceTreeItem::ExpandClicked(bool checked)
+{
+	OBSDataAutoRelease data = obs_sceneitem_get_private_settings(sceneitem);
+
+	obs_data_set_bool(data, "collapsed", checked);
+
+	if (!checked)
+		tree->GetStm()->ExpandGroup(sceneitem);
+	else
+		tree->GetStm()->CollapseGroup(sceneitem);
+}
+
+void SourceTreeItem::Select()
+{
+	tree->SelectItem(sceneitem, true);
+	OBSBasic::Get()->UpdateContextBarDeferred();
+	OBSBasic::Get()->UpdateEditMenu();
+}
+
+void SourceTreeItem::Deselect()
+{
+	tree->SelectItem(sceneitem, false);
+	OBSBasic::Get()->UpdateContextBarDeferred();
+	OBSBasic::Get()->UpdateEditMenu();
+}
+
+/* ========================================================================= */
+
+void SourceTreeModel::OBSFrontendEvent(enum obs_frontend_event event, void *ptr)
+{
+	SourceTreeModel *stm = reinterpret_cast<SourceTreeModel *>(ptr);
+
+	switch (event) {
+	case OBS_FRONTEND_EVENT_PREVIEW_SCENE_CHANGED:
+		stm->SceneChanged();
+		break;
+	case OBS_FRONTEND_EVENT_EXIT:
+		stm->Clear();
+		obs_frontend_remove_event_callback(OBSFrontendEvent, stm);
+		break;
+	case OBS_FRONTEND_EVENT_SCENE_COLLECTION_CLEANUP:
+		stm->Clear();
+		break;
+	default:
+		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);
+
+	obs_source_t *src = obs_sceneitem_get_source(item);
+	if (obs_source_removed(src)) {
+		return true;
+	}
+
+	if (obs_sceneitem_is_group(item)) {
+		OBSDataAutoRelease 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);
+		}
+	}
+
+	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)
+{
+	if (obs_sceneitem_is_group(item)) {
+		SceneChanged();
+	} else {
+		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);
+
+	OBSBasic::Get()->UpdateContextBarDeferred();
+}
+
+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);
+}
+
+int SourceTreeModel::rowCount(const QModelIndex &parent) const
+{
+	return parent.isValid() ? 0 : items.count();
+}
+
+QVariant SourceTreeModel::data(const QModelIndex &index, int role) const
+{
+	if (role == Qt::AccessibleTextRole) {
+		OBSSceneItem item = items[index.row()];
+		obs_source_t *source = obs_sceneitem_get_source(item);
+		return QVariant(QT_UTF8(obs_source_get_name(source)));
+	}
+
+	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 = QTStr("Group");
+
+	int i = 2;
+	for (;;) {
+		OBSSourceAutoRelease group = obs_get_source_by_name(QT_TO_UTF8(name));
+		if (!group)
+			break;
+		name = QTStr("Basic.Main.Group").arg(QString::number(i++));
+	}
+
+	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;
+
+	OBSBasic *main = OBSBasic::Get();
+	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;
+	}
+
+	st->undoSceneData = main->BackupScene(scene);
+
+	obs_sceneitem_t *item = obs_scene_insert_group(scene, QT_TO_UTF8(name), item_order.data(), item_order.size());
+	if (!item) {
+		st->undoSceneData = nullptr;
+		return;
+	}
+
+	main->undo_s.push_disabled();
+
+	for (obs_sceneitem_t *item : item_order)
+		obs_sceneitem_select(item, false);
+
+	hasGroups = true;
+	st->UpdateWidgets(true);
+
+	obs_sceneitem_select(item, true);
+
+	/* ----------------------------------------------------------------- */
+	/* obs_scene_insert_group triggers a full refresh of scene items via */
+	/* the item_add signal. No need to insert a row, just edit the one   */
+	/* that's created automatically.                                     */
+
+	int newIdx = indices[0].row();
+	QMetaObject::invokeMethod(st, "NewGroupEdit", Qt::QueuedConnection, Q_ARG(int, newIdx));
+}
+
+void SourceTreeModel::UngroupSelectedGroups(QModelIndexList &indices)
+{
+	OBSBasic *main = OBSBasic::Get();
+	if (indices.count() == 0)
+		return;
+
+	OBSScene scene = main->GetCurrentScene();
+	OBSData undoData = main->BackupScene(scene);
+
+	for (int i = indices.count() - 1; i >= 0; i--) {
+		obs_sceneitem_t *item = items[indices[i].row()];
+		obs_sceneitem_group_ungroup(item);
+	}
+
+	SceneChanged();
+
+	OBSData redoData = main->BackupScene(scene);
+	main->CreateSceneUndoRedoAction(QTStr("Basic.Main.Ungroup"), undoData, redoData);
+}
+
+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_);
+	setStyleSheet(QString("*[bgColor=\"1\"]{background-color:rgba(255,68,68,33%);}"
+			      "*[bgColor=\"2\"]{background-color:rgba(255,255,68,33%);}"
+			      "*[bgColor=\"3\"]{background-color:rgba(68,255,68,33%);}"
+			      "*[bgColor=\"4\"]{background-color:rgba(68,255,255,33%);}"
+			      "*[bgColor=\"5\"]{background-color:rgba(68,68,255,33%);}"
+			      "*[bgColor=\"6\"]{background-color:rgba(255,68,255,33%);}"
+			      "*[bgColor=\"7\"]{background-color:rgba(68,68,68,33%);}"
+			      "*[bgColor=\"8\"]{background-color:rgba(255,255,255,33%);}"));
+
+	UpdateNoSourcesMessage();
+	connect(App(), &OBSApp::StyleChanged, this, &SourceTree::UpdateNoSourcesMessage);
+	connect(App(), &OBSApp::StyleChanged, this, &SourceTree::UpdateIcons);
+
+	setItemDelegate(new SourceTreeDelegate(this));
+}
+
+void SourceTree::UpdateIcons()
+{
+	SourceTreeModel *stm = GetStm();
+	stm->SceneChanged();
+}
+
+void SourceTree::SetIconsVisible(bool visible)
+{
+	SourceTreeModel *stm = GetStm();
+
+	iconsVisible = visible;
+	stm->SceneChanged();
+}
+
+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() && select != selectionModel()->isSelected(index))
+		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;
+	}
+
+	OBSBasic *main = OBSBasic::Get();
+
+	OBSScene scene = GetCurrentScene();
+	obs_source_t *scenesource = obs_scene_get_source(scene);
+	SourceTreeModel *stm = GetStm();
+	auto &items = stm->items;
+	QModelIndexList indices = selectedIndexes();
+
+	DropIndicatorPosition indicator = dropIndicatorPosition();
+	int row = indexAt(event->position().toPoint()).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(scene, 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 ||
+	    indicator == QAbstractItemView::OnViewport)
+		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;
+	if (row == stm->items.count())
+		itemBelow = nullptr;
+	else
+		itemBelow = stm->items[row];
+
+	if (hasGroups) {
+		if (!itemBelow || obs_sceneitem_get_group(scene, itemBelow) != dropGroup) {
+			dropGroup = nullptr;
+			dropOnCollapsed = false;
+		}
+	}
+
+	/* --------------------------------------- */
+	/* if dropping groups on other groups,     */
+	/* disregard as invalid drag/drop          */
+
+	if (dropGroup && hasGroups) {
+		QListView::dropEvent(event);
+		return;
+	}
+
+	/* --------------------------------------- */
+	/* save undo data                          */
+	std::vector<obs_source_t *> sources;
+	for (int i = 0; i < indices.size(); i++) {
+		obs_sceneitem_t *item = items[indices[i].row()];
+		if (obs_sceneitem_get_scene(item) != scene)
+			sources.push_back(obs_scene_get_source(obs_sceneitem_get_scene(item)));
+	}
+	if (dropGroup)
+		sources.push_back(obs_sceneitem_get_source(dropGroup));
+	OBSData undo_data = main->BackupScene(scene, &sources);
+
+	/* --------------------------------------- */
+	/* 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(scene, 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 = [&]() {
+		OBSDataAutoRelease data = obs_sceneitem_get_private_settings(lastGroup);
+		bool collapsed = obs_data_get_bool(data, "collapsed");
+
+		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(scene, 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;
+
+	/* --------------------------------------- */
+	/* save redo data                          */
+
+	OBSData redo_data = main->BackupScene(scene, &sources);
+
+	/* --------------------------------------- */
+	/* add undo/redo action                    */
+
+	const char *scene_name = obs_source_get_name(scenesource);
+	QString action_name = QTStr("Undo.ReorderSources").arg(scene_name);
+	main->CreateSceneUndoRedoAction(action_name, undo_data, redo_data);
+
+	/* --------------------------------------- */
+	/* 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)
+{
+	{
+		QSignalBlocker 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::NewGroupEdit(int row)
+{
+	if (!Edit(row)) {
+		OBSBasic *main = OBSBasic::Get();
+		main->undo_s.pop_disabled();
+
+		blog(LOG_WARNING, "Uh, somehow the edit didn't process, this "
+				  "code should never be reached.\nAnd by "
+				  "\"never be reached\", I mean that "
+				  "theoretically, it should be\nimpossible "
+				  "for this code to be reached. But if this "
+				  "code is reached,\nfeel free to laugh at "
+				  "Lain, because apparently it is, in fact, "
+				  "actually\npossible for this code to be "
+				  "reached. But I mean, again, theoretically\n"
+				  "it should be impossible. So if you see "
+				  "this in your log, just know that\nit's "
+				  "really dumb, and depressing. But at least "
+				  "the undo/redo action is\nstill covered, so "
+				  "in theory things *should* be fine. But "
+				  "it's entirely\npossible that they might "
+				  "not be exactly. But again, yea. This "
+				  "really\nshould not be possible.");
+
+		OBSData redoSceneData = main->BackupScene(GetCurrentScene());
+
+		QString text = QTStr("Undo.GroupItems").arg("Unknown");
+		main->CreateSceneUndoRedoAction(text, undoSceneData, redoSceneData);
+
+		undoSceneData = nullptr;
+	}
+}
+
+bool SourceTree::Edit(int row)
+{
+	SourceTreeModel *stm = GetStm();
+	if (row < 0 || row >= stm->items.count())
+		return false;
+
+	QModelIndex index = stm->createIndex(row, 0);
+	QWidget *widget = indexWidget(index);
+	SourceTreeItem *itemWidget = reinterpret_cast<SourceTreeItem *>(widget);
+	if (itemWidget->IsEditing()) {
+#ifdef __APPLE__
+		itemWidget->ExitEditMode(true);
+#endif
+		return false;
+	}
+
+	itemWidget->EnterEditMode();
+	edit(index);
+	return true;
+}
+
+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::Remove(OBSSceneItem item, OBSScene scene)
+{
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+	GetStm()->Remove(item);
+	main->SaveProject();
+
+	if (!main->SavingDisabled()) {
+		obs_source_t *sceneSource = obs_scene_get_source(scene);
+		obs_source_t *itemSource = obs_sceneitem_get_source(item);
+		blog(LOG_INFO, "User Removed source '%s' (%s) from scene '%s'", obs_source_get_name(itemSource),
+		     obs_source_get_id(itemSource), obs_source_get_name(sceneSource));
+	}
+}
+
+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();
+}
+
+void SourceTree::UpdateNoSourcesMessage()
+{
+	QString file = !App()->IsThemeDark() ? ":res/images/no_sources.svg" : "theme:Dark/no_sources.svg";
+	iconNoSources.load(file);
+
+	QTextOption opt(Qt::AlignHCenter);
+	opt.setWrapMode(QTextOption::WordWrap);
+	textNoSources.setTextOption(opt);
+	textNoSources.setText(QTStr("NoSources.Label").replace("\n", "<br/>"));
+
+	textPrepared = false;
+}
+
+void SourceTree::paintEvent(QPaintEvent *event)
+{
+	SourceTreeModel *stm = GetStm();
+	if (stm && !stm->items.count()) {
+		QPainter p(viewport());
+
+		if (!textPrepared) {
+			textNoSources.prepare(QTransform(), p.font());
+			textPrepared = true;
+		}
+
+		QRectF iconRect = iconNoSources.viewBoxF();
+		iconRect.setSize(QSizeF(32.0, 32.0));
+
+		QSizeF iconSize = iconRect.size();
+		QSizeF textSize = textNoSources.size();
+		QSizeF thisSize = size();
+		const qreal spacing = 16.0;
+
+		qreal totalHeight = iconSize.height() + spacing + textSize.height();
+
+		qreal x = thisSize.width() / 2.0 - iconSize.width() / 2.0;
+		qreal y = thisSize.height() / 2.0 - totalHeight / 2.0;
+		iconRect.moveTo(std::round(x), std::round(y));
+		iconNoSources.render(&p, iconRect);
+
+		x = thisSize.width() / 2.0 - textSize.width() / 2.0;
+		y += spacing + iconSize.height();
+		p.drawStaticText(x, y, textNoSources);
+	} else {
+		QListView::paintEvent(event);
+	}
+}
+
+SourceTreeDelegate::SourceTreeDelegate(QObject *parent) : QStyledItemDelegate(parent) {}
+
+QSize SourceTreeDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
+{
+	SourceTree *tree = qobject_cast<SourceTree *>(parent());
+	QWidget *item = tree->indexWidget(index);
+
+	if (!item)
+		return (QSize(0, 0));
+
+	return (QSize(option.widget->minimumWidth(), item->height()));
+}

+ 202 - 0
frontend/components/SourceTreeItem.hpp

@@ -0,0 +1,202 @@
+#pragma once
+
+#include <QList>
+#include <QVector>
+#include <QPointer>
+#include <QListView>
+#include <QCheckBox>
+#include <QStaticText>
+#include <QSvgRenderer>
+#include <QAbstractListModel>
+#include <QStyledItemDelegate>
+#include <obs.hpp>
+#include <obs-frontend-api.h>
+
+class QLabel;
+class OBSSourceLabel;
+class QCheckBox;
+class QLineEdit;
+class SourceTree;
+class QSpacerItem;
+class QHBoxLayout;
+class VisibilityItemWidget;
+
+class SourceTreeItem : public QFrame {
+	Q_OBJECT
+
+	friend class SourceTree;
+	friend class SourceTreeModel;
+
+	void mouseDoubleClickEvent(QMouseEvent *event) override;
+	void enterEvent(QEnterEvent *event) override;
+	void leaveEvent(QEvent *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);
+	bool IsEditing();
+
+private:
+	QSpacerItem *spacer = nullptr;
+	QCheckBox *expand = nullptr;
+	QLabel *iconLabel = nullptr;
+	QCheckBox *vis = nullptr;
+	QCheckBox *lock = nullptr;
+	QHBoxLayout *boxLayout = nullptr;
+	OBSSourceLabel *label = nullptr;
+
+	QLineEdit *editor = nullptr;
+
+	std::string newName;
+
+	SourceTree *tree;
+	OBSSceneItem sceneitem;
+	std::vector<OBSSignal> sigs;
+
+	virtual void paintEvent(QPaintEvent *event) override;
+
+	void ExitEditModeInternal(bool save);
+
+private slots:
+	void Clear();
+
+	void EnterEditMode();
+	void ExitEditMode(bool save);
+
+	void VisibilityChanged(bool visible);
+	void LockedChanged(bool locked);
+
+	void ExpandClicked(bool checked);
+
+	void Select();
+	void Deselect();
+};
+
+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);
+
+	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;
+
+	bool textPrepared = false;
+	QStaticText textNoSources;
+	QSvgRenderer iconNoSources;
+
+	OBSData undoSceneData;
+
+	bool iconsVisible = true;
+
+	void UpdateNoSourcesMessage();
+
+	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()); }
+
+public:
+	inline SourceTreeItem *GetItemWidget(int idx)
+	{
+		QWidget *widget = indexWidget(GetStm()->createIndex(idx, 0));
+		return reinterpret_cast<SourceTreeItem *>(widget);
+	}
+
+	explicit SourceTree(QWidget *parent = nullptr);
+
+	inline bool IgnoreReorder() const { return ignoreReorder; }
+	inline void Clear() { GetStm()->Clear(); }
+
+	inline void Add(obs_sceneitem_t *item) { GetStm()->Add(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;
+
+	void UpdateIcons();
+	void SetIconsVisible(bool visible);
+
+public slots:
+	inline void ReorderItems() { GetStm()->ReorderItems(); }
+	inline void RefreshItems() { GetStm()->SceneChanged(); }
+	void Remove(OBSSceneItem item, OBSScene scene);
+	void GroupSelectedItems();
+	void UngroupSelectedGroups();
+	void AddGroup();
+	bool Edit(int idx);
+	void NewGroupEdit(int idx);
+
+protected:
+	virtual void mouseDoubleClickEvent(QMouseEvent *event) override;
+	virtual void dropEvent(QDropEvent *event) override;
+	virtual void paintEvent(QPaintEvent *event) override;
+
+	virtual void selectionChanged(const QItemSelection &selected, const QItemSelection &deselected) override;
+};
+
+class SourceTreeDelegate : public QStyledItemDelegate {
+	Q_OBJECT
+
+public:
+	SourceTreeDelegate(QObject *parent);
+	virtual QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
+};

+ 1613 - 0
frontend/components/SourceTreeModel.cpp

@@ -0,0 +1,1613 @@
+#include "window-basic-main.hpp"
+#include "obs-app.hpp"
+#include "source-tree.hpp"
+#include "platform.hpp"
+#include "source-label.hpp"
+
+#include <qt-wrappers.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 <QAccessible>
+
+#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);
+	setMouseTracking(true);
+
+	obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+	const char *name = obs_source_get_name(source);
+
+	OBSDataAutoRelease privData = obs_sceneitem_get_private_settings(sceneitem);
+	int preset = obs_data_get_int(privData, "color-preset");
+
+	if (preset == 1) {
+		const char *color = obs_data_get_string(privData, "color");
+		std::string col = "background: ";
+		col += color;
+		setStyleSheet(col.c_str());
+	} else if (preset > 1) {
+		setStyleSheet("");
+		setProperty("bgColor", preset - 1);
+	} else {
+		setStyleSheet("background: none");
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+	const char *id = obs_source_get_id(source);
+
+	bool sourceVisible = obs_sceneitem_visible(sceneitem);
+
+	if (tree->iconsVisible) {
+		QIcon icon;
+
+		if (strcmp(id, "scene") == 0)
+			icon = main->GetSceneIcon();
+		else if (strcmp(id, "group") == 0)
+			icon = main->GetGroupIcon();
+		else
+			icon = main->GetSourceIcon(id);
+
+		QPixmap pixmap = icon.pixmap(QSize(16, 16));
+
+		iconLabel = new QLabel();
+		iconLabel->setPixmap(pixmap);
+		iconLabel->setEnabled(sourceVisible);
+		iconLabel->setStyleSheet("background: none");
+		iconLabel->setProperty("class", "source-icon");
+	}
+
+	vis = new QCheckBox();
+	vis->setProperty("class", "checkbox-icon indicator-visibility");
+	vis->setChecked(sourceVisible);
+	vis->setAccessibleName(QTStr("Basic.Main.Sources.Visibility"));
+	vis->setAccessibleDescription(QTStr("Basic.Main.Sources.VisibilityDescription").arg(name));
+
+	lock = new QCheckBox();
+	lock->setProperty("class", "checkbox-icon indicator-lock");
+	lock->setChecked(obs_sceneitem_locked(sceneitem));
+	lock->setAccessibleName(QTStr("Basic.Main.Sources.Lock"));
+	lock->setAccessibleDescription(QTStr("Basic.Main.Sources.LockDescription").arg(name));
+
+	label = new OBSSourceLabel(source);
+	label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+	label->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
+	label->setAttribute(Qt::WA_TranslucentBackground);
+	label->setEnabled(sourceVisible);
+
+#ifdef __APPLE__
+	vis->setAttribute(Qt::WA_LayoutUsesWidgetRect);
+	lock->setAttribute(Qt::WA_LayoutUsesWidgetRect);
+#endif
+
+	boxLayout = new QHBoxLayout();
+
+	boxLayout->setContentsMargins(0, 0, 0, 0);
+	boxLayout->setSpacing(0);
+	if (iconLabel) {
+		boxLayout->addWidget(iconLabel);
+		boxLayout->addSpacing(2);
+	}
+	boxLayout->addWidget(label);
+	boxLayout->addWidget(vis);
+	boxLayout->addWidget(lock);
+#ifdef __APPLE__
+	/* Hack: Fixes a bug where scrollbars would be above the lock icon */
+	boxLayout->addSpacing(16);
+#endif
+
+	Update(false);
+
+	setLayout(boxLayout);
+
+	/* --------------------------------------------------------- */
+
+	auto setItemVisible = [this](bool val) {
+		obs_scene_t *scene = obs_sceneitem_get_scene(sceneitem);
+		obs_source_t *scenesource = obs_scene_get_source(scene);
+		int64_t id = obs_sceneitem_get_id(sceneitem);
+		const char *name = obs_source_get_name(scenesource);
+		const char *uuid = obs_source_get_uuid(scenesource);
+		obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+
+		auto undo_redo = [](const std::string &uuid, int64_t id, bool val) {
+			OBSSourceAutoRelease s = obs_get_source_by_uuid(uuid.c_str());
+			obs_scene_t *sc = obs_group_or_scene_from_source(s);
+			obs_sceneitem_t *si = obs_scene_find_sceneitem_by_id(sc, id);
+			if (si)
+				obs_sceneitem_set_visible(si, val);
+		};
+
+		QString str = QTStr(val ? "Undo.ShowSceneItem" : "Undo.HideSceneItem");
+
+		OBSBasic *main = OBSBasic::Get();
+		main->undo_s.add_action(str.arg(obs_source_get_name(source), name),
+					std::bind(undo_redo, std::placeholders::_1, id, !val),
+					std::bind(undo_redo, std::placeholders::_1, id, val), uuid, uuid);
+
+		QSignalBlocker sourcesSignalBlocker(this);
+		obs_sceneitem_set_visible(sceneitem, val);
+	};
+
+	auto setItemLocked = [this](bool checked) {
+		QSignalBlocker sourcesSignalBlocker(this);
+		obs_sceneitem_set_locked(sceneitem, checked);
+	};
+
+	connect(vis, &QAbstractButton::clicked, setItemVisible);
+	connect(lock, &QAbstractButton::clicked, setItemLocked);
+}
+
+void SourceTreeItem::paintEvent(QPaintEvent *event)
+{
+	QStyleOption opt;
+	opt.initFrom(this);
+	QPainter p(this);
+	style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
+
+	QWidget::paintEvent(event);
+}
+
+void SourceTreeItem::DisconnectSignals()
+{
+	sigs.clear();
+}
+
+void SourceTreeItem::Clear()
+{
+	DisconnectSignals();
+	sceneitem = nullptr;
+}
+
+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");
+		obs_scene_t *curScene = (obs_scene_t *)calldata_ptr(cd, "scene");
+
+		if (curItem == this_->sceneitem) {
+			QMetaObject::invokeMethod(this_->tree, "Remove", Q_ARG(OBSSceneItem, curItem),
+						  Q_ARG(OBSScene, curScene));
+			curItem = nullptr;
+		}
+		if (!curItem)
+			QMetaObject::invokeMethod(this_, "Clear");
+	};
+
+	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));
+	};
+
+	auto itemLocked = [](void *data, calldata_t *cd) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		obs_sceneitem_t *curItem = (obs_sceneitem_t *)calldata_ptr(cd, "item");
+		bool locked = calldata_bool(cd, "locked");
+
+		if (curItem == this_->sceneitem)
+			QMetaObject::invokeMethod(this_, "LockedChanged", Q_ARG(bool, locked));
+	};
+
+	auto itemSelect = [](void *data, calldata_t *cd) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		obs_sceneitem_t *curItem = (obs_sceneitem_t *)calldata_ptr(cd, "item");
+
+		if (curItem == this_->sceneitem)
+			QMetaObject::invokeMethod(this_, "Select");
+	};
+
+	auto itemDeselect = [](void *data, calldata_t *cd) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		obs_sceneitem_t *curItem = (obs_sceneitem_t *)calldata_ptr(cd, "item");
+
+		if (curItem == this_->sceneitem)
+			QMetaObject::invokeMethod(this_, "Deselect");
+	};
+
+	auto reorderGroup = [](void *data, calldata_t *) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		QMetaObject::invokeMethod(this_->tree, "ReorderItems");
+	};
+
+	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);
+
+	sigs.emplace_back(signal, "remove", removeItem, this);
+	sigs.emplace_back(signal, "item_remove", removeItem, this);
+	sigs.emplace_back(signal, "item_visible", itemVisible, this);
+	sigs.emplace_back(signal, "item_locked", itemLocked, this);
+	sigs.emplace_back(signal, "item_select", itemSelect, this);
+	sigs.emplace_back(signal, "item_deselect", itemDeselect, this);
+
+	if (obs_sceneitem_is_group(sceneitem)) {
+		obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+		signal = obs_source_get_signal_handler(source);
+
+		sigs.emplace_back(signal, "reorder", reorderGroup, this);
+	}
+
+	/* --------------------------------------------------------- */
+
+	auto removeSource = [](void *data, calldata_t *) {
+		SourceTreeItem *this_ = reinterpret_cast<SourceTreeItem *>(data);
+		this_->DisconnectSignals();
+		this_->sceneitem = nullptr;
+		QMetaObject::invokeMethod(this_->tree, "RefreshItems");
+	};
+
+	obs_source_t *source = obs_sceneitem_get_source(sceneitem);
+	signal = obs_source_get_signal_handler(source);
+	sigs.emplace_back(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 (obs_source_configurable(source)) {
+			main->CreatePropertiesWindow(source);
+		}
+	}
+}
+
+void SourceTreeItem::enterEvent(QEnterEvent *event)
+{
+	QWidget::enterEvent(event);
+
+	OBSBasicPreview *preview = OBSBasicPreview::Get();
+
+	std::lock_guard<std::mutex> lock(preview->selectMutex);
+	preview->hoveredPreviewItems.clear();
+	preview->hoveredPreviewItems.push_back(sceneitem);
+}
+
+void SourceTreeItem::leaveEvent(QEvent *event)
+{
+	QWidget::leaveEvent(event);
+
+	OBSBasicPreview *preview = OBSBasicPreview::Get();
+
+	std::lock_guard<std::mutex> lock(preview->selectMutex);
+	preview->hoveredPreviewItems.clear();
+}
+
+bool SourceTreeItem::IsEditing()
+{
+	return editor != nullptr;
+}
+
+void SourceTreeItem::EnterEditMode()
+{
+	setFocusPolicy(Qt::StrongFocus);
+	int index = boxLayout->indexOf(label);
+	boxLayout->removeWidget(label);
+	editor = new QLineEdit(label->text());
+	editor->setStyleSheet("background: none");
+	editor->selectAll();
+	editor->installEventFilter(this);
+	boxLayout->insertWidget(index, editor);
+	setFocusProxy(editor);
+}
+
+void SourceTreeItem::ExitEditMode(bool save)
+{
+	ExitEditModeInternal(save);
+
+	if (tree->undoSceneData) {
+		OBSBasic *main = OBSBasic::Get();
+		main->undo_s.pop_disabled();
+
+		OBSData redoSceneData = main->BackupScene(GetCurrentScene());
+
+		QString text = QTStr("Undo.GroupItems").arg(newName.c_str());
+		main->CreateSceneUndoRedoAction(text, tree->undoSceneData, redoSceneData);
+
+		tree->undoSceneData = nullptr;
+	}
+}
+
+void SourceTreeItem::ExitEditModeInternal(bool save)
+{
+	if (!editor) {
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+	OBSScene scene = main->GetCurrentScene();
+
+	newName = QT_TO_UTF8(editor->text());
+
+	setFocusProxy(nullptr);
+	int index = boxLayout->indexOf(editor);
+	boxLayout->removeWidget(editor);
+	delete editor;
+	editor = nullptr;
+	setFocusPolicy(Qt::NoFocus);
+	boxLayout->insertWidget(index, label);
+	setFocus();
+
+	/* ----------------------------------------- */
+	/* 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                 */
+
+	OBSSourceAutoRelease existingSource = obs_get_source_by_name(newName.c_str());
+	bool exists = !!existingSource;
+
+	if (exists) {
+		OBSMessageBox::information(main, QTStr("NameExists.Title"), QTStr("NameExists.Text"));
+		return;
+	}
+
+	/* ----------------------------------------- */
+	/* rename                                    */
+
+	QSignalBlocker sourcesSignalBlocker(this);
+	std::string prevName(obs_source_get_name(source));
+	std::string scene_uuid = obs_source_get_uuid(main->GetCurrentSceneSource());
+	auto undo = [scene_uuid, prevName, main](const std::string &data) {
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(data.c_str());
+		obs_source_set_name(source, prevName.c_str());
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+	};
+
+	std::string editedName = newName;
+
+	auto redo = [scene_uuid, main, editedName](const std::string &data) {
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(data.c_str());
+		obs_source_set_name(source, editedName.c_str());
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+	};
+
+	const char *uuid = obs_source_get_uuid(source);
+	main->undo_s.add_action(QTStr("Undo.Rename").arg(newName.c_str()), undo, redo, uuid, uuid);
+
+	obs_source_set_name(source, newName.c_str());
+}
+
+bool SourceTreeItem::eventFilter(QObject *object, QEvent *event)
+{
+	if (editor != object)
+		return false;
+
+	if (LineEditCanceled(event)) {
+		QMetaObject::invokeMethod(this, "ExitEditMode", Qt::QueuedConnection, Q_ARG(bool, false));
+		return true;
+	}
+	if (LineEditChanged(event)) {
+		QMetaObject::invokeMethod(this, "ExitEditMode", Qt::QueuedConnection, Q_ARG(bool, true));
+		return true;
+	}
+
+	return false;
+}
+
+void SourceTreeItem::VisibilityChanged(bool visible)
+{
+	if (iconLabel) {
+		iconLabel->setEnabled(visible);
+	}
+	label->setEnabled(visible);
+	vis->setChecked(visible);
+}
+
+void SourceTreeItem::LockedChanged(bool locked)
+{
+	lock->setChecked(locked);
+	OBSBasic::Get()->UpdateEditMenu();
+}
+
+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 QCheckBox();
+		expand->setProperty("class", "checkbox-icon indicator-expand");
+#ifdef __APPLE__
+		expand->setAttribute(Qt::WA_LayoutUsesWidgetRect);
+#endif
+		boxLayout->insertWidget(0, expand);
+
+		OBSDataAutoRelease data = obs_sceneitem_get_private_settings(sceneitem);
+		expand->blockSignals(true);
+		expand->setChecked(obs_data_get_bool(data, "collapsed"));
+		expand->blockSignals(false);
+
+		connect(expand, &QPushButton::toggled, this, &SourceTreeItem::ExpandClicked);
+
+	} else {
+		spacer = new QSpacerItem(3, 1);
+		boxLayout->insertItem(0, spacer);
+	}
+}
+
+void SourceTreeItem::ExpandClicked(bool checked)
+{
+	OBSDataAutoRelease data = obs_sceneitem_get_private_settings(sceneitem);
+
+	obs_data_set_bool(data, "collapsed", checked);
+
+	if (!checked)
+		tree->GetStm()->ExpandGroup(sceneitem);
+	else
+		tree->GetStm()->CollapseGroup(sceneitem);
+}
+
+void SourceTreeItem::Select()
+{
+	tree->SelectItem(sceneitem, true);
+	OBSBasic::Get()->UpdateContextBarDeferred();
+	OBSBasic::Get()->UpdateEditMenu();
+}
+
+void SourceTreeItem::Deselect()
+{
+	tree->SelectItem(sceneitem, false);
+	OBSBasic::Get()->UpdateContextBarDeferred();
+	OBSBasic::Get()->UpdateEditMenu();
+}
+
+/* ========================================================================= */
+
+void SourceTreeModel::OBSFrontendEvent(enum obs_frontend_event event, void *ptr)
+{
+	SourceTreeModel *stm = reinterpret_cast<SourceTreeModel *>(ptr);
+
+	switch (event) {
+	case OBS_FRONTEND_EVENT_PREVIEW_SCENE_CHANGED:
+		stm->SceneChanged();
+		break;
+	case OBS_FRONTEND_EVENT_EXIT:
+		stm->Clear();
+		obs_frontend_remove_event_callback(OBSFrontendEvent, stm);
+		break;
+	case OBS_FRONTEND_EVENT_SCENE_COLLECTION_CLEANUP:
+		stm->Clear();
+		break;
+	default:
+		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);
+
+	obs_source_t *src = obs_sceneitem_get_source(item);
+	if (obs_source_removed(src)) {
+		return true;
+	}
+
+	if (obs_sceneitem_is_group(item)) {
+		OBSDataAutoRelease 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);
+		}
+	}
+
+	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)
+{
+	if (obs_sceneitem_is_group(item)) {
+		SceneChanged();
+	} else {
+		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);
+
+	OBSBasic::Get()->UpdateContextBarDeferred();
+}
+
+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);
+}
+
+int SourceTreeModel::rowCount(const QModelIndex &parent) const
+{
+	return parent.isValid() ? 0 : items.count();
+}
+
+QVariant SourceTreeModel::data(const QModelIndex &index, int role) const
+{
+	if (role == Qt::AccessibleTextRole) {
+		OBSSceneItem item = items[index.row()];
+		obs_source_t *source = obs_sceneitem_get_source(item);
+		return QVariant(QT_UTF8(obs_source_get_name(source)));
+	}
+
+	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 = QTStr("Group");
+
+	int i = 2;
+	for (;;) {
+		OBSSourceAutoRelease group = obs_get_source_by_name(QT_TO_UTF8(name));
+		if (!group)
+			break;
+		name = QTStr("Basic.Main.Group").arg(QString::number(i++));
+	}
+
+	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;
+
+	OBSBasic *main = OBSBasic::Get();
+	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;
+	}
+
+	st->undoSceneData = main->BackupScene(scene);
+
+	obs_sceneitem_t *item = obs_scene_insert_group(scene, QT_TO_UTF8(name), item_order.data(), item_order.size());
+	if (!item) {
+		st->undoSceneData = nullptr;
+		return;
+	}
+
+	main->undo_s.push_disabled();
+
+	for (obs_sceneitem_t *item : item_order)
+		obs_sceneitem_select(item, false);
+
+	hasGroups = true;
+	st->UpdateWidgets(true);
+
+	obs_sceneitem_select(item, true);
+
+	/* ----------------------------------------------------------------- */
+	/* obs_scene_insert_group triggers a full refresh of scene items via */
+	/* the item_add signal. No need to insert a row, just edit the one   */
+	/* that's created automatically.                                     */
+
+	int newIdx = indices[0].row();
+	QMetaObject::invokeMethod(st, "NewGroupEdit", Qt::QueuedConnection, Q_ARG(int, newIdx));
+}
+
+void SourceTreeModel::UngroupSelectedGroups(QModelIndexList &indices)
+{
+	OBSBasic *main = OBSBasic::Get();
+	if (indices.count() == 0)
+		return;
+
+	OBSScene scene = main->GetCurrentScene();
+	OBSData undoData = main->BackupScene(scene);
+
+	for (int i = indices.count() - 1; i >= 0; i--) {
+		obs_sceneitem_t *item = items[indices[i].row()];
+		obs_sceneitem_group_ungroup(item);
+	}
+
+	SceneChanged();
+
+	OBSData redoData = main->BackupScene(scene);
+	main->CreateSceneUndoRedoAction(QTStr("Basic.Main.Ungroup"), undoData, redoData);
+}
+
+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_);
+	setStyleSheet(QString("*[bgColor=\"1\"]{background-color:rgba(255,68,68,33%);}"
+			      "*[bgColor=\"2\"]{background-color:rgba(255,255,68,33%);}"
+			      "*[bgColor=\"3\"]{background-color:rgba(68,255,68,33%);}"
+			      "*[bgColor=\"4\"]{background-color:rgba(68,255,255,33%);}"
+			      "*[bgColor=\"5\"]{background-color:rgba(68,68,255,33%);}"
+			      "*[bgColor=\"6\"]{background-color:rgba(255,68,255,33%);}"
+			      "*[bgColor=\"7\"]{background-color:rgba(68,68,68,33%);}"
+			      "*[bgColor=\"8\"]{background-color:rgba(255,255,255,33%);}"));
+
+	UpdateNoSourcesMessage();
+	connect(App(), &OBSApp::StyleChanged, this, &SourceTree::UpdateNoSourcesMessage);
+	connect(App(), &OBSApp::StyleChanged, this, &SourceTree::UpdateIcons);
+
+	setItemDelegate(new SourceTreeDelegate(this));
+}
+
+void SourceTree::UpdateIcons()
+{
+	SourceTreeModel *stm = GetStm();
+	stm->SceneChanged();
+}
+
+void SourceTree::SetIconsVisible(bool visible)
+{
+	SourceTreeModel *stm = GetStm();
+
+	iconsVisible = visible;
+	stm->SceneChanged();
+}
+
+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() && select != selectionModel()->isSelected(index))
+		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;
+	}
+
+	OBSBasic *main = OBSBasic::Get();
+
+	OBSScene scene = GetCurrentScene();
+	obs_source_t *scenesource = obs_scene_get_source(scene);
+	SourceTreeModel *stm = GetStm();
+	auto &items = stm->items;
+	QModelIndexList indices = selectedIndexes();
+
+	DropIndicatorPosition indicator = dropIndicatorPosition();
+	int row = indexAt(event->position().toPoint()).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(scene, 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 ||
+	    indicator == QAbstractItemView::OnViewport)
+		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;
+	if (row == stm->items.count())
+		itemBelow = nullptr;
+	else
+		itemBelow = stm->items[row];
+
+	if (hasGroups) {
+		if (!itemBelow || obs_sceneitem_get_group(scene, itemBelow) != dropGroup) {
+			dropGroup = nullptr;
+			dropOnCollapsed = false;
+		}
+	}
+
+	/* --------------------------------------- */
+	/* if dropping groups on other groups,     */
+	/* disregard as invalid drag/drop          */
+
+	if (dropGroup && hasGroups) {
+		QListView::dropEvent(event);
+		return;
+	}
+
+	/* --------------------------------------- */
+	/* save undo data                          */
+	std::vector<obs_source_t *> sources;
+	for (int i = 0; i < indices.size(); i++) {
+		obs_sceneitem_t *item = items[indices[i].row()];
+		if (obs_sceneitem_get_scene(item) != scene)
+			sources.push_back(obs_scene_get_source(obs_sceneitem_get_scene(item)));
+	}
+	if (dropGroup)
+		sources.push_back(obs_sceneitem_get_source(dropGroup));
+	OBSData undo_data = main->BackupScene(scene, &sources);
+
+	/* --------------------------------------- */
+	/* 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(scene, 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 = [&]() {
+		OBSDataAutoRelease data = obs_sceneitem_get_private_settings(lastGroup);
+		bool collapsed = obs_data_get_bool(data, "collapsed");
+
+		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(scene, 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;
+
+	/* --------------------------------------- */
+	/* save redo data                          */
+
+	OBSData redo_data = main->BackupScene(scene, &sources);
+
+	/* --------------------------------------- */
+	/* add undo/redo action                    */
+
+	const char *scene_name = obs_source_get_name(scenesource);
+	QString action_name = QTStr("Undo.ReorderSources").arg(scene_name);
+	main->CreateSceneUndoRedoAction(action_name, undo_data, redo_data);
+
+	/* --------------------------------------- */
+	/* 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)
+{
+	{
+		QSignalBlocker 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::NewGroupEdit(int row)
+{
+	if (!Edit(row)) {
+		OBSBasic *main = OBSBasic::Get();
+		main->undo_s.pop_disabled();
+
+		blog(LOG_WARNING, "Uh, somehow the edit didn't process, this "
+				  "code should never be reached.\nAnd by "
+				  "\"never be reached\", I mean that "
+				  "theoretically, it should be\nimpossible "
+				  "for this code to be reached. But if this "
+				  "code is reached,\nfeel free to laugh at "
+				  "Lain, because apparently it is, in fact, "
+				  "actually\npossible for this code to be "
+				  "reached. But I mean, again, theoretically\n"
+				  "it should be impossible. So if you see "
+				  "this in your log, just know that\nit's "
+				  "really dumb, and depressing. But at least "
+				  "the undo/redo action is\nstill covered, so "
+				  "in theory things *should* be fine. But "
+				  "it's entirely\npossible that they might "
+				  "not be exactly. But again, yea. This "
+				  "really\nshould not be possible.");
+
+		OBSData redoSceneData = main->BackupScene(GetCurrentScene());
+
+		QString text = QTStr("Undo.GroupItems").arg("Unknown");
+		main->CreateSceneUndoRedoAction(text, undoSceneData, redoSceneData);
+
+		undoSceneData = nullptr;
+	}
+}
+
+bool SourceTree::Edit(int row)
+{
+	SourceTreeModel *stm = GetStm();
+	if (row < 0 || row >= stm->items.count())
+		return false;
+
+	QModelIndex index = stm->createIndex(row, 0);
+	QWidget *widget = indexWidget(index);
+	SourceTreeItem *itemWidget = reinterpret_cast<SourceTreeItem *>(widget);
+	if (itemWidget->IsEditing()) {
+#ifdef __APPLE__
+		itemWidget->ExitEditMode(true);
+#endif
+		return false;
+	}
+
+	itemWidget->EnterEditMode();
+	edit(index);
+	return true;
+}
+
+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::Remove(OBSSceneItem item, OBSScene scene)
+{
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+	GetStm()->Remove(item);
+	main->SaveProject();
+
+	if (!main->SavingDisabled()) {
+		obs_source_t *sceneSource = obs_scene_get_source(scene);
+		obs_source_t *itemSource = obs_sceneitem_get_source(item);
+		blog(LOG_INFO, "User Removed source '%s' (%s) from scene '%s'", obs_source_get_name(itemSource),
+		     obs_source_get_id(itemSource), obs_source_get_name(sceneSource));
+	}
+}
+
+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();
+}
+
+void SourceTree::UpdateNoSourcesMessage()
+{
+	QString file = !App()->IsThemeDark() ? ":res/images/no_sources.svg" : "theme:Dark/no_sources.svg";
+	iconNoSources.load(file);
+
+	QTextOption opt(Qt::AlignHCenter);
+	opt.setWrapMode(QTextOption::WordWrap);
+	textNoSources.setTextOption(opt);
+	textNoSources.setText(QTStr("NoSources.Label").replace("\n", "<br/>"));
+
+	textPrepared = false;
+}
+
+void SourceTree::paintEvent(QPaintEvent *event)
+{
+	SourceTreeModel *stm = GetStm();
+	if (stm && !stm->items.count()) {
+		QPainter p(viewport());
+
+		if (!textPrepared) {
+			textNoSources.prepare(QTransform(), p.font());
+			textPrepared = true;
+		}
+
+		QRectF iconRect = iconNoSources.viewBoxF();
+		iconRect.setSize(QSizeF(32.0, 32.0));
+
+		QSizeF iconSize = iconRect.size();
+		QSizeF textSize = textNoSources.size();
+		QSizeF thisSize = size();
+		const qreal spacing = 16.0;
+
+		qreal totalHeight = iconSize.height() + spacing + textSize.height();
+
+		qreal x = thisSize.width() / 2.0 - iconSize.width() / 2.0;
+		qreal y = thisSize.height() / 2.0 - totalHeight / 2.0;
+		iconRect.moveTo(std::round(x), std::round(y));
+		iconNoSources.render(&p, iconRect);
+
+		x = thisSize.width() / 2.0 - textSize.width() / 2.0;
+		y += spacing + iconSize.height();
+		p.drawStaticText(x, y, textNoSources);
+	} else {
+		QListView::paintEvent(event);
+	}
+}
+
+SourceTreeDelegate::SourceTreeDelegate(QObject *parent) : QStyledItemDelegate(parent) {}
+
+QSize SourceTreeDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
+{
+	SourceTree *tree = qobject_cast<SourceTree *>(parent());
+	QWidget *item = tree->indexWidget(index);
+
+	if (!item)
+		return (QSize(0, 0));
+
+	return (QSize(option.widget->minimumWidth(), item->height()));
+}

+ 202 - 0
frontend/components/SourceTreeModel.hpp

@@ -0,0 +1,202 @@
+#pragma once
+
+#include <QList>
+#include <QVector>
+#include <QPointer>
+#include <QListView>
+#include <QCheckBox>
+#include <QStaticText>
+#include <QSvgRenderer>
+#include <QAbstractListModel>
+#include <QStyledItemDelegate>
+#include <obs.hpp>
+#include <obs-frontend-api.h>
+
+class QLabel;
+class OBSSourceLabel;
+class QCheckBox;
+class QLineEdit;
+class SourceTree;
+class QSpacerItem;
+class QHBoxLayout;
+class VisibilityItemWidget;
+
+class SourceTreeItem : public QFrame {
+	Q_OBJECT
+
+	friend class SourceTree;
+	friend class SourceTreeModel;
+
+	void mouseDoubleClickEvent(QMouseEvent *event) override;
+	void enterEvent(QEnterEvent *event) override;
+	void leaveEvent(QEvent *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);
+	bool IsEditing();
+
+private:
+	QSpacerItem *spacer = nullptr;
+	QCheckBox *expand = nullptr;
+	QLabel *iconLabel = nullptr;
+	QCheckBox *vis = nullptr;
+	QCheckBox *lock = nullptr;
+	QHBoxLayout *boxLayout = nullptr;
+	OBSSourceLabel *label = nullptr;
+
+	QLineEdit *editor = nullptr;
+
+	std::string newName;
+
+	SourceTree *tree;
+	OBSSceneItem sceneitem;
+	std::vector<OBSSignal> sigs;
+
+	virtual void paintEvent(QPaintEvent *event) override;
+
+	void ExitEditModeInternal(bool save);
+
+private slots:
+	void Clear();
+
+	void EnterEditMode();
+	void ExitEditMode(bool save);
+
+	void VisibilityChanged(bool visible);
+	void LockedChanged(bool locked);
+
+	void ExpandClicked(bool checked);
+
+	void Select();
+	void Deselect();
+};
+
+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);
+
+	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;
+
+	bool textPrepared = false;
+	QStaticText textNoSources;
+	QSvgRenderer iconNoSources;
+
+	OBSData undoSceneData;
+
+	bool iconsVisible = true;
+
+	void UpdateNoSourcesMessage();
+
+	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()); }
+
+public:
+	inline SourceTreeItem *GetItemWidget(int idx)
+	{
+		QWidget *widget = indexWidget(GetStm()->createIndex(idx, 0));
+		return reinterpret_cast<SourceTreeItem *>(widget);
+	}
+
+	explicit SourceTree(QWidget *parent = nullptr);
+
+	inline bool IgnoreReorder() const { return ignoreReorder; }
+	inline void Clear() { GetStm()->Clear(); }
+
+	inline void Add(obs_sceneitem_t *item) { GetStm()->Add(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;
+
+	void UpdateIcons();
+	void SetIconsVisible(bool visible);
+
+public slots:
+	inline void ReorderItems() { GetStm()->ReorderItems(); }
+	inline void RefreshItems() { GetStm()->SceneChanged(); }
+	void Remove(OBSSceneItem item, OBSScene scene);
+	void GroupSelectedItems();
+	void UngroupSelectedGroups();
+	void AddGroup();
+	bool Edit(int idx);
+	void NewGroupEdit(int idx);
+
+protected:
+	virtual void mouseDoubleClickEvent(QMouseEvent *event) override;
+	virtual void dropEvent(QDropEvent *event) override;
+	virtual void paintEvent(QPaintEvent *event) override;
+
+	virtual void selectionChanged(const QItemSelection &selected, const QItemSelection &deselected) override;
+};
+
+class SourceTreeDelegate : public QStyledItemDelegate {
+	Q_OBJECT
+
+public:
+	SourceTreeDelegate(QObject *parent);
+	virtual QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
+};

+ 707 - 0
frontend/components/TextSourceToolbar.cpp

@@ -0,0 +1,707 @@
+#include "window-basic-main.hpp"
+#include "moc_context-bar-controls.cpp"
+#include "obs-app.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QStandardItemModel>
+#include <QColorDialog>
+#include <QFontDialog>
+
+#include "ui_browser-source-toolbar.h"
+#include "ui_device-select-toolbar.h"
+#include "ui_game-capture-toolbar.h"
+#include "ui_image-source-toolbar.h"
+#include "ui_color-source-toolbar.h"
+#include "ui_text-source-toolbar.h"
+
+#ifdef _WIN32
+#define get_os_module(win, mac, linux) obs_get_module(win)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, win)
+#elif __APPLE__
+#define get_os_module(win, mac, linux) obs_get_module(mac)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, mac)
+#else
+#define get_os_module(win, mac, linux) obs_get_module(linux)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, linux)
+#endif
+
+/* ========================================================================= */
+
+SourceToolbar::SourceToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  props(obs_source_properties(source), obs_properties_destroy)
+{
+}
+
+void SourceToolbar::SaveOldProperties(obs_source_t *source)
+{
+	oldData = obs_data_create();
+
+	OBSDataAutoRelease oldSettings = obs_source_get_settings(source);
+	obs_data_apply(oldData, oldSettings);
+	obs_data_set_string(oldData, "undo_suuid", obs_source_get_uuid(source));
+}
+
+void SourceToolbar::SetUndoProperties(obs_source_t *source, bool repeatable)
+{
+	if (!oldData) {
+		blog(LOG_ERROR, "%s: somehow oldData was null.", __FUNCTION__);
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+	OBSSource currentSceneSource = main->GetCurrentSceneSource();
+	if (!currentSceneSource)
+		return;
+	std::string scene_uuid = obs_source_get_uuid(currentSceneSource);
+	auto undo_redo = [scene_uuid = std::move(scene_uuid), main](const std::string &data) {
+		OBSDataAutoRelease settings = obs_data_create_from_json(data.c_str());
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(obs_data_get_string(settings, "undo_suuid"));
+		obs_source_reset_settings(source, settings);
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+
+		main->UpdateContextBar();
+	};
+
+	OBSDataAutoRelease new_settings = obs_data_create();
+	OBSDataAutoRelease curr_settings = obs_source_get_settings(source);
+	obs_data_apply(new_settings, curr_settings);
+	obs_data_set_string(new_settings, "undo_suuid", obs_source_get_uuid(source));
+
+	std::string undo_data(obs_data_get_json(oldData));
+	std::string redo_data(obs_data_get_json(new_settings));
+
+	if (undo_data.compare(redo_data) != 0)
+		main->undo_s.add_action(QTStr("Undo.Properties").arg(obs_source_get_name(source)), undo_redo, undo_redo,
+					undo_data, redo_data, repeatable);
+
+	oldData = nullptr;
+}
+
+/* ========================================================================= */
+
+BrowserToolbar::BrowserToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_BrowserSourceToolbar)
+{
+	ui->setupUi(this);
+}
+
+BrowserToolbar::~BrowserToolbar() {}
+
+void BrowserToolbar::on_refresh_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "refreshnocache");
+	obs_property_button_clicked(p, source.Get());
+}
+
+/* ========================================================================= */
+
+ComboSelectToolbar::ComboSelectToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+}
+
+ComboSelectToolbar::~ComboSelectToolbar() {}
+
+static int FillPropertyCombo(QComboBox *c, obs_property_t *p, const std::string &cur_id, bool is_int = false)
+{
+	size_t count = obs_property_list_item_count(p);
+	int cur_idx = -1;
+
+	for (size_t i = 0; i < count; i++) {
+		const char *name = obs_property_list_item_name(p, i);
+		std::string id;
+
+		if (is_int) {
+			id = std::to_string(obs_property_list_item_int(p, i));
+		} else {
+			const char *val = obs_property_list_item_string(p, i);
+			id = val ? val : "";
+		}
+
+		if (cur_id == id)
+			cur_idx = (int)i;
+
+		c->addItem(name, id.c_str());
+	}
+
+	return cur_idx;
+}
+
+void UpdateSourceComboToolbarProperties(QComboBox *combo, OBSSource source, obs_properties_t *props,
+					const char *prop_name, bool is_int)
+{
+	std::string cur_id;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (is_int) {
+		cur_id = std::to_string(obs_data_get_int(settings, prop_name));
+	} else {
+		cur_id = obs_data_get_string(settings, prop_name);
+	}
+
+	combo->blockSignals(true);
+
+	obs_property_t *p = obs_properties_get(props, prop_name);
+	int cur_idx = FillPropertyCombo(combo, p, cur_id, is_int);
+
+	if (cur_idx == -1 || obs_property_list_item_disabled(p, cur_idx)) {
+		if (cur_idx == -1) {
+			combo->insertItem(0, QTStr("Basic.Settings.Audio.UnknownAudioDevice"));
+			cur_idx = 0;
+		}
+
+		SetComboItemEnabled(combo, cur_idx, false);
+	}
+
+	combo->setCurrentIndex(cur_idx);
+	combo->blockSignals(false);
+}
+
+void ComboSelectToolbar::Init()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	UpdateSourceComboToolbarProperties(ui->device, source, props.get(), prop_name, is_int);
+}
+
+void UpdateSourceComboToolbarValue(QComboBox *combo, OBSSource source, int idx, const char *prop_name, bool is_int)
+{
+	QString id = combo->itemData(idx).toString();
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (is_int) {
+		obs_data_set_int(settings, prop_name, id.toInt());
+	} else {
+		obs_data_set_string(settings, prop_name, QT_TO_UTF8(id));
+	}
+	obs_source_update(source, settings);
+}
+
+void ComboSelectToolbar::on_device_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	SaveOldProperties(source);
+	UpdateSourceComboToolbarValue(ui->device, source, idx, prop_name, is_int);
+	SetUndoProperties(source);
+}
+
+AudioCaptureToolbar::AudioCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void AudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-wasapi", "mac-capture", "linux-pulseaudio");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Device", "CoreAudio.Device", "Device");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "device_id";
+
+	ComboSelectToolbar::Init();
+}
+
+WindowCaptureToolbar::WindowCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void WindowCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "WindowCapture.Window", "WindowUtils.Window", "Window");
+	ui->deviceLabel->setText(device_str);
+
+#if !defined(_WIN32) && !defined(__APPLE__) //linux
+	prop_name = "capture_window";
+#else
+	prop_name = "window";
+#endif
+
+#ifdef __APPLE__
+	is_int = true;
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+ApplicationAudioCaptureToolbar::ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source)
+	: ComboSelectToolbar(parent, source)
+{
+}
+
+void ApplicationAudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = obs_get_module("win-wasapi");
+	const char *device_str = obs_module_get_locale_text(mod, "Window");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "window";
+
+	ComboSelectToolbar::Init();
+}
+
+DisplayCaptureToolbar::DisplayCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void DisplayCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Monitor", "DisplayCapture.Display", "Screen");
+	ui->deviceLabel->setText(device_str);
+
+#ifdef _WIN32
+	prop_name = "monitor_id";
+#elif __APPLE__
+	prop_name = "display_uuid";
+#else
+	is_int = true;
+	prop_name = "screen";
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+/* ========================================================================= */
+
+DeviceCaptureToolbar::DeviceCaptureToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+
+	delete ui->deviceLabel;
+	delete ui->device;
+	ui->deviceLabel = nullptr;
+	ui->device = nullptr;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	active = obs_data_get_bool(settings, "active");
+
+	obs_module_t *mod = obs_get_module("win-dshow");
+	if (!mod)
+		return;
+
+	activateText = obs_module_get_locale_text(mod, "Activate");
+	deactivateText = obs_module_get_locale_text(mod, "Deactivate");
+
+	ui->activateButton->setText(active ? deactivateText : activateText);
+}
+
+DeviceCaptureToolbar::~DeviceCaptureToolbar() {}
+
+void DeviceCaptureToolbar::on_activateButton_clicked()
+{
+	OBSSource source = OBSGetStrongRef(weakSource);
+	if (!source) {
+		return;
+	}
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	bool now_active = obs_data_get_bool(settings, "active");
+
+	bool desyncedSetting = now_active != active;
+
+	active = !active;
+
+	const char *text = active ? deactivateText : activateText;
+	ui->activateButton->setText(text);
+
+	if (desyncedSetting) {
+		return;
+	}
+
+	calldata_t cd = {};
+	calldata_set_bool(&cd, "active", active);
+	proc_handler_t *ph = obs_source_get_proc_handler(source);
+	proc_handler_call(ph, "activate", &cd);
+	calldata_free(&cd);
+}
+
+/* ========================================================================= */
+
+GameCaptureToolbar::GameCaptureToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_GameCaptureToolbar)
+{
+	obs_property_t *p;
+	int cur_idx;
+
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("win-capture");
+	if (!mod)
+		return;
+
+	ui->modeLabel->setText(obs_module_get_locale_text(mod, "Mode"));
+	ui->windowLabel->setText(obs_module_get_locale_text(mod, "WindowCapture.Window"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string cur_mode = obs_data_get_string(settings, "capture_mode");
+	std::string cur_window = obs_data_get_string(settings, "window");
+
+	ui->mode->blockSignals(true);
+	p = obs_properties_get(props.get(), "capture_mode");
+	cur_idx = FillPropertyCombo(ui->mode, p, cur_mode);
+	ui->mode->setCurrentIndex(cur_idx);
+	ui->mode->blockSignals(false);
+
+	ui->window->blockSignals(true);
+	p = obs_properties_get(props.get(), "window");
+	cur_idx = FillPropertyCombo(ui->window, p, cur_window);
+	ui->window->setCurrentIndex(cur_idx);
+	ui->window->blockSignals(false);
+
+	if (cur_idx != -1 && obs_property_list_item_disabled(p, cur_idx)) {
+		SetComboItemEnabled(ui->window, cur_idx, false);
+	}
+
+	UpdateWindowVisibility();
+}
+
+GameCaptureToolbar::~GameCaptureToolbar() {}
+
+void GameCaptureToolbar::UpdateWindowVisibility()
+{
+	QString mode = ui->mode->currentData().toString();
+	bool is_window = (mode == "window");
+	ui->windowLabel->setVisible(is_window);
+	ui->window->setVisible(is_window);
+}
+
+void GameCaptureToolbar::on_mode_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->mode->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "capture_mode", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+
+	UpdateWindowVisibility();
+}
+
+void GameCaptureToolbar::on_window_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->window->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "window", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+ImageSourceToolbar::ImageSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ImageSourceToolbar)
+{
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("image-source");
+	ui->pathLabel->setText(obs_module_get_locale_text(mod, "File"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string file = obs_data_get_string(settings, "file");
+
+	ui->path->setText(file.c_str());
+}
+
+ImageSourceToolbar::~ImageSourceToolbar() {}
+
+void ImageSourceToolbar::on_browse_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "file");
+	const char *desc = obs_property_description(p);
+	const char *filter = obs_property_path_filter(p);
+	const char *default_path = obs_property_path_default_path(p);
+
+	QString startDir = ui->path->text();
+	if (startDir.isEmpty())
+		startDir = default_path;
+
+	QString path = OpenFile(this, desc, startDir, filter);
+	if (path.isEmpty()) {
+		return;
+	}
+
+	ui->path->setText(path);
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "file", QT_TO_UTF8(path));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+static inline QColor color_from_int(long long val)
+{
+	return QColor(val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, (val >> 24) & 0xff);
+}
+
+static inline long long color_to_int(QColor color)
+{
+	auto shift = [&](unsigned val, int shift) {
+		return ((val & 0xff) << shift);
+	};
+
+	return shift(color.red(), 0) | shift(color.green(), 8) | shift(color.blue(), 16) | shift(color.alpha(), 24);
+}
+
+ColorSourceToolbar::ColorSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ColorSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	unsigned int val = (unsigned int)obs_data_get_int(settings, "color");
+
+	color = color_from_int(val);
+	UpdateColor();
+}
+
+ColorSourceToolbar::~ColorSourceToolbar() {}
+
+void ColorSourceToolbar::UpdateColor()
+{
+	QPalette palette = QPalette(color);
+	ui->color->setFrameStyle(QFrame::Sunken | QFrame::Panel);
+	ui->color->setText(color.name(QColor::HexRgb));
+	ui->color->setPalette(palette);
+	ui->color->setStyleSheet(QString("background-color :%1; color: %2;")
+					 .arg(palette.color(QPalette::Window).name(QColor::HexRgb))
+					 .arg(palette.color(QPalette::WindowText).name(QColor::HexRgb)));
+	ui->color->setAutoFillBackground(true);
+	ui->color->setAlignment(Qt::AlignCenter);
+}
+
+void ColorSourceToolbar::on_choose_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "color");
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+	UpdateColor();
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_int(settings, "color", color_to_int(color));
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+extern void MakeQFont(obs_data_t *font_obj, QFont &font, bool limit = false);
+
+TextSourceToolbar::TextSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_TextSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+
+	const char *id = obs_source_get_unversioned_id(source);
+	bool ft2 = strcmp(id, "text_ft2_source") == 0;
+	bool read_from_file = obs_data_get_bool(settings, ft2 ? "from_file" : "read_from_file");
+
+	OBSDataAutoRelease font_obj = obs_data_get_obj(settings, "font");
+	MakeQFont(font_obj, font);
+
+	// Use "color1" if it's a freetype source and "color" elsewise
+	unsigned int val = (unsigned int)obs_data_get_int(
+		settings, (strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0) ? "color1" : "color");
+
+	color = color_from_int(val);
+
+	const char *text = obs_data_get_string(settings, "text");
+
+	bool single_line = !read_from_file && (!text || (strchr(text, '\n') == nullptr));
+	ui->emptySpace->setVisible(!single_line);
+	ui->text->setVisible(single_line);
+	if (single_line)
+		ui->text->setText(text);
+}
+
+TextSourceToolbar::~TextSourceToolbar() {}
+
+void TextSourceToolbar::on_selectFont_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	QFontDialog::FontDialogOptions options;
+	uint32_t flags;
+	bool success;
+
+#ifndef _WIN32
+	options = QFontDialog::DontUseNativeDialog;
+#endif
+
+	font = QFontDialog::getFont(&success, font, this, QTStr("Basic.PropertiesWindow.SelectFont.WindowTitle"),
+				    options);
+	if (!success) {
+		return;
+	}
+
+	OBSDataAutoRelease font_obj = obs_data_create();
+
+	obs_data_set_string(font_obj, "face", QT_TO_UTF8(font.family()));
+	obs_data_set_string(font_obj, "style", QT_TO_UTF8(font.styleName()));
+	obs_data_set_int(font_obj, "size", font.pointSize());
+	flags = font.bold() ? OBS_FONT_BOLD : 0;
+	flags |= font.italic() ? OBS_FONT_ITALIC : 0;
+	flags |= font.underline() ? OBS_FONT_UNDERLINE : 0;
+	flags |= font.strikeOut() ? OBS_FONT_STRIKEOUT : 0;
+	obs_data_set_int(font_obj, "flags", flags);
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+
+	obs_data_set_obj(settings, "font", font_obj);
+
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_selectColor_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	bool freetype = strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0;
+
+	obs_property_t *p = obs_properties_get(props.get(), freetype ? "color1" : "color");
+
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (freetype) {
+		obs_data_set_int(settings, "color1", color_to_int(color));
+		obs_data_set_int(settings, "color2", color_to_int(color));
+	} else {
+		obs_data_set_int(settings, "color", color_to_int(color));
+	}
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_text_textChanged()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+	std::string newText = QT_TO_UTF8(ui->text->text());
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (newText == obs_data_get_string(settings, "text")) {
+		return;
+	}
+	SaveOldProperties(source);
+
+	obs_data_set_string(settings, "text", newText.c_str());
+	obs_source_update(source, nullptr);
+
+	SetUndoProperties(source, true);
+}

+ 178 - 0
frontend/components/TextSourceToolbar.hpp

@@ -0,0 +1,178 @@
+#pragma once
+
+#include <memory>
+#include <obs.hpp>
+#include <QWidget>
+
+class Ui_BrowserSourceToolbar;
+class Ui_DeviceSelectToolbar;
+class Ui_GameCaptureToolbar;
+class Ui_ImageSourceToolbar;
+class Ui_ColorSourceToolbar;
+class Ui_TextSourceToolbar;
+
+class SourceToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+protected:
+	using properties_delete_t = decltype(&obs_properties_destroy);
+	using properties_t = std::unique_ptr<obs_properties_t, properties_delete_t>;
+
+	properties_t props;
+	OBSDataAutoRelease oldData;
+
+	void SaveOldProperties(obs_source_t *source);
+	void SetUndoProperties(obs_source_t *source, bool repeatable = false);
+
+public:
+	SourceToolbar(QWidget *parent, OBSSource source);
+
+	OBSSource GetSource() { return OBSGetStrongRef(weakSource); }
+
+public slots:
+	virtual void Update() {}
+};
+
+class BrowserToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_BrowserSourceToolbar> ui;
+
+public:
+	BrowserToolbar(QWidget *parent, OBSSource source);
+	~BrowserToolbar();
+
+public slots:
+	void on_refresh_clicked();
+};
+
+class ComboSelectToolbar : public SourceToolbar {
+	Q_OBJECT
+
+protected:
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *prop_name;
+	bool is_int = false;
+
+public:
+	ComboSelectToolbar(QWidget *parent, OBSSource source);
+	~ComboSelectToolbar();
+	virtual void Init();
+
+public slots:
+	void on_device_currentIndexChanged(int idx);
+};
+
+class AudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	AudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class WindowCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	WindowCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class ApplicationAudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DisplayCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	DisplayCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DeviceCaptureToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *activateText;
+	const char *deactivateText;
+	bool active;
+
+public:
+	DeviceCaptureToolbar(QWidget *parent, OBSSource source);
+	~DeviceCaptureToolbar();
+
+public slots:
+	void on_activateButton_clicked();
+};
+
+class GameCaptureToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_GameCaptureToolbar> ui;
+
+	void UpdateWindowVisibility();
+
+public:
+	GameCaptureToolbar(QWidget *parent, OBSSource source);
+	~GameCaptureToolbar();
+
+public slots:
+	void on_mode_currentIndexChanged(int idx);
+	void on_window_currentIndexChanged(int idx);
+};
+
+class ImageSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ImageSourceToolbar> ui;
+
+public:
+	ImageSourceToolbar(QWidget *parent, OBSSource source);
+	~ImageSourceToolbar();
+
+public slots:
+	void on_browse_clicked();
+};
+
+class ColorSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ColorSourceToolbar> ui;
+	QColor color;
+
+	void UpdateColor();
+
+public:
+	ColorSourceToolbar(QWidget *parent, OBSSource source);
+	~ColorSourceToolbar();
+
+public slots:
+	void on_choose_clicked();
+};
+
+class TextSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_TextSourceToolbar> ui;
+	QFont font;
+	QColor color;
+
+public:
+	TextSourceToolbar(QWidget *parent, OBSSource source);
+	~TextSourceToolbar();
+
+public slots:
+	void on_selectFont_clicked();
+	void on_selectColor_clicked();
+	void on_text_textChanged();
+};

+ 0 - 0
UI/visibility-item-widget.cpp → frontend/components/VisibilityItemDelegate.cpp


+ 0 - 0
UI/visibility-item-widget.hpp → frontend/components/VisibilityItemDelegate.hpp


+ 133 - 0
frontend/components/VisibilityItemWidget.cpp

@@ -0,0 +1,133 @@
+#include "moc_visibility-item-widget.cpp"
+#include "obs-app.hpp"
+#include "source-label.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QListWidget>
+#include <QLineEdit>
+#include <QHBoxLayout>
+#include <QMessageBox>
+#include <QLabel>
+#include <QKeyEvent>
+#include <QCheckBox>
+
+VisibilityItemWidget::VisibilityItemWidget(obs_source_t *source_)
+	: source(source_),
+	  enabledSignal(obs_source_get_signal_handler(source), "enable", OBSSourceEnabled, this)
+{
+	bool enabled = obs_source_enabled(source);
+
+	vis = new QCheckBox();
+	vis->setProperty("class", "checkbox-icon indicator-visibility");
+	vis->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
+	vis->setChecked(enabled);
+
+	label = new OBSSourceLabel(source);
+	label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+
+	QHBoxLayout *itemLayout = new QHBoxLayout();
+	itemLayout->addWidget(vis);
+	itemLayout->addWidget(label);
+	itemLayout->setContentsMargins(0, 0, 0, 0);
+
+	setLayout(itemLayout);
+
+	connect(vis, &QCheckBox::clicked, [this](bool visible) { obs_source_set_enabled(source, visible); });
+}
+
+void VisibilityItemWidget::OBSSourceEnabled(void *param, calldata_t *data)
+{
+	VisibilityItemWidget *window = reinterpret_cast<VisibilityItemWidget *>(param);
+	bool enabled = calldata_bool(data, "enabled");
+
+	QMetaObject::invokeMethod(window, "SourceEnabled", Q_ARG(bool, enabled));
+}
+
+void VisibilityItemWidget::SourceEnabled(bool enabled)
+{
+	if (vis->isChecked() != enabled)
+		vis->setChecked(enabled);
+}
+
+void VisibilityItemWidget::SetColor(const QColor &color, bool active_, bool selected_)
+{
+	/* Do not update unless the state has actually changed */
+	if (active_ == active && selected_ == selected)
+		return;
+
+	QPalette pal = vis->palette();
+	pal.setColor(QPalette::WindowText, color);
+	vis->setPalette(pal);
+
+	label->setStyleSheet(QString("color: %1;").arg(color.name()));
+
+	active = active_;
+	selected = selected_;
+}
+
+VisibilityItemDelegate::VisibilityItemDelegate(QObject *parent) : QStyledItemDelegate(parent) {}
+
+void VisibilityItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
+				   const QModelIndex &index) const
+{
+	QStyledItemDelegate::paint(painter, option, index);
+
+	QObject *parentObj = parent();
+	QListWidget *list = qobject_cast<QListWidget *>(parentObj);
+	if (!list)
+		return;
+
+	QListWidgetItem *item = list->item(index.row());
+	VisibilityItemWidget *widget = qobject_cast<VisibilityItemWidget *>(list->itemWidget(item));
+	if (!widget)
+		return;
+
+	bool selected = option.state.testFlag(QStyle::State_Selected);
+	bool active = option.state.testFlag(QStyle::State_Active);
+
+	QPalette palette = list->palette();
+#if defined(_WIN32) || defined(__APPLE__)
+	QPalette::ColorGroup group = active ? QPalette::Active : QPalette::Inactive;
+#else
+	QPalette::ColorGroup group = QPalette::Active;
+#endif
+
+#ifdef _WIN32
+	QPalette::ColorRole highlightRole = QPalette::WindowText;
+#else
+	QPalette::ColorRole highlightRole = QPalette::HighlightedText;
+#endif
+
+	QPalette::ColorRole role;
+
+	if (selected && active)
+		role = highlightRole;
+	else
+		role = QPalette::WindowText;
+
+	widget->SetColor(palette.color(group, role), active, selected);
+}
+
+bool VisibilityItemDelegate::eventFilter(QObject *object, QEvent *event)
+{
+	QWidget *editor = qobject_cast<QWidget *>(object);
+	if (!editor)
+		return false;
+
+	if (event->type() == QEvent::KeyPress) {
+		QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
+
+		if (keyEvent->key() == Qt::Key_Tab || keyEvent->key() == Qt::Key_Backtab) {
+			return false;
+		}
+	}
+
+	return QStyledItemDelegate::eventFilter(object, event);
+}
+
+void SetupVisibilityItem(QListWidget *list, QListWidgetItem *item, obs_source_t *source)
+{
+	VisibilityItemWidget *baseWidget = new VisibilityItemWidget(source);
+
+	list->setItemWidget(item, baseWidget);
+}

+ 50 - 0
frontend/components/VisibilityItemWidget.hpp

@@ -0,0 +1,50 @@
+#pragma once
+
+#include <QWidget>
+#include <QStyledItemDelegate>
+#include <obs.hpp>
+
+class QLabel;
+class QLineEdit;
+class QListWidget;
+class QListWidgetItem;
+class QCheckBox;
+class OBSSourceLabel;
+
+class VisibilityItemWidget : public QWidget {
+	Q_OBJECT
+
+private:
+	OBSSource source;
+	OBSSourceLabel *label = nullptr;
+	QCheckBox *vis = nullptr;
+
+	OBSSignal enabledSignal;
+
+	bool active = false;
+	bool selected = false;
+
+	static void OBSSourceEnabled(void *param, calldata_t *data);
+
+private slots:
+	void SourceEnabled(bool enabled);
+
+public:
+	VisibilityItemWidget(obs_source_t *source);
+
+	void SetColor(const QColor &color, bool active, bool selected);
+};
+
+class VisibilityItemDelegate : public QStyledItemDelegate {
+	Q_OBJECT
+
+public:
+	VisibilityItemDelegate(QObject *parent = nullptr);
+
+	void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
+
+protected:
+	bool eventFilter(QObject *object, QEvent *event) override;
+};
+
+void SetupVisibilityItem(QListWidget *list, QListWidgetItem *item, obs_source_t *source);

+ 707 - 0
frontend/components/WindowCaptureToolbar.cpp

@@ -0,0 +1,707 @@
+#include "window-basic-main.hpp"
+#include "moc_context-bar-controls.cpp"
+#include "obs-app.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QStandardItemModel>
+#include <QColorDialog>
+#include <QFontDialog>
+
+#include "ui_browser-source-toolbar.h"
+#include "ui_device-select-toolbar.h"
+#include "ui_game-capture-toolbar.h"
+#include "ui_image-source-toolbar.h"
+#include "ui_color-source-toolbar.h"
+#include "ui_text-source-toolbar.h"
+
+#ifdef _WIN32
+#define get_os_module(win, mac, linux) obs_get_module(win)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, win)
+#elif __APPLE__
+#define get_os_module(win, mac, linux) obs_get_module(mac)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, mac)
+#else
+#define get_os_module(win, mac, linux) obs_get_module(linux)
+#define get_os_text(mod, win, mac, linux) obs_module_get_locale_text(mod, linux)
+#endif
+
+/* ========================================================================= */
+
+SourceToolbar::SourceToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  props(obs_source_properties(source), obs_properties_destroy)
+{
+}
+
+void SourceToolbar::SaveOldProperties(obs_source_t *source)
+{
+	oldData = obs_data_create();
+
+	OBSDataAutoRelease oldSettings = obs_source_get_settings(source);
+	obs_data_apply(oldData, oldSettings);
+	obs_data_set_string(oldData, "undo_suuid", obs_source_get_uuid(source));
+}
+
+void SourceToolbar::SetUndoProperties(obs_source_t *source, bool repeatable)
+{
+	if (!oldData) {
+		blog(LOG_ERROR, "%s: somehow oldData was null.", __FUNCTION__);
+		return;
+	}
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+	OBSSource currentSceneSource = main->GetCurrentSceneSource();
+	if (!currentSceneSource)
+		return;
+	std::string scene_uuid = obs_source_get_uuid(currentSceneSource);
+	auto undo_redo = [scene_uuid = std::move(scene_uuid), main](const std::string &data) {
+		OBSDataAutoRelease settings = obs_data_create_from_json(data.c_str());
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(obs_data_get_string(settings, "undo_suuid"));
+		obs_source_reset_settings(source, settings);
+
+		OBSSourceAutoRelease scene_source = obs_get_source_by_uuid(scene_uuid.c_str());
+		main->SetCurrentScene(scene_source.Get(), true);
+
+		main->UpdateContextBar();
+	};
+
+	OBSDataAutoRelease new_settings = obs_data_create();
+	OBSDataAutoRelease curr_settings = obs_source_get_settings(source);
+	obs_data_apply(new_settings, curr_settings);
+	obs_data_set_string(new_settings, "undo_suuid", obs_source_get_uuid(source));
+
+	std::string undo_data(obs_data_get_json(oldData));
+	std::string redo_data(obs_data_get_json(new_settings));
+
+	if (undo_data.compare(redo_data) != 0)
+		main->undo_s.add_action(QTStr("Undo.Properties").arg(obs_source_get_name(source)), undo_redo, undo_redo,
+					undo_data, redo_data, repeatable);
+
+	oldData = nullptr;
+}
+
+/* ========================================================================= */
+
+BrowserToolbar::BrowserToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_BrowserSourceToolbar)
+{
+	ui->setupUi(this);
+}
+
+BrowserToolbar::~BrowserToolbar() {}
+
+void BrowserToolbar::on_refresh_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "refreshnocache");
+	obs_property_button_clicked(p, source.Get());
+}
+
+/* ========================================================================= */
+
+ComboSelectToolbar::ComboSelectToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+}
+
+ComboSelectToolbar::~ComboSelectToolbar() {}
+
+static int FillPropertyCombo(QComboBox *c, obs_property_t *p, const std::string &cur_id, bool is_int = false)
+{
+	size_t count = obs_property_list_item_count(p);
+	int cur_idx = -1;
+
+	for (size_t i = 0; i < count; i++) {
+		const char *name = obs_property_list_item_name(p, i);
+		std::string id;
+
+		if (is_int) {
+			id = std::to_string(obs_property_list_item_int(p, i));
+		} else {
+			const char *val = obs_property_list_item_string(p, i);
+			id = val ? val : "";
+		}
+
+		if (cur_id == id)
+			cur_idx = (int)i;
+
+		c->addItem(name, id.c_str());
+	}
+
+	return cur_idx;
+}
+
+void UpdateSourceComboToolbarProperties(QComboBox *combo, OBSSource source, obs_properties_t *props,
+					const char *prop_name, bool is_int)
+{
+	std::string cur_id;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (is_int) {
+		cur_id = std::to_string(obs_data_get_int(settings, prop_name));
+	} else {
+		cur_id = obs_data_get_string(settings, prop_name);
+	}
+
+	combo->blockSignals(true);
+
+	obs_property_t *p = obs_properties_get(props, prop_name);
+	int cur_idx = FillPropertyCombo(combo, p, cur_id, is_int);
+
+	if (cur_idx == -1 || obs_property_list_item_disabled(p, cur_idx)) {
+		if (cur_idx == -1) {
+			combo->insertItem(0, QTStr("Basic.Settings.Audio.UnknownAudioDevice"));
+			cur_idx = 0;
+		}
+
+		SetComboItemEnabled(combo, cur_idx, false);
+	}
+
+	combo->setCurrentIndex(cur_idx);
+	combo->blockSignals(false);
+}
+
+void ComboSelectToolbar::Init()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	UpdateSourceComboToolbarProperties(ui->device, source, props.get(), prop_name, is_int);
+}
+
+void UpdateSourceComboToolbarValue(QComboBox *combo, OBSSource source, int idx, const char *prop_name, bool is_int)
+{
+	QString id = combo->itemData(idx).toString();
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (is_int) {
+		obs_data_set_int(settings, prop_name, id.toInt());
+	} else {
+		obs_data_set_string(settings, prop_name, QT_TO_UTF8(id));
+	}
+	obs_source_update(source, settings);
+}
+
+void ComboSelectToolbar::on_device_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	SaveOldProperties(source);
+	UpdateSourceComboToolbarValue(ui->device, source, idx, prop_name, is_int);
+	SetUndoProperties(source);
+}
+
+AudioCaptureToolbar::AudioCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void AudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-wasapi", "mac-capture", "linux-pulseaudio");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Device", "CoreAudio.Device", "Device");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "device_id";
+
+	ComboSelectToolbar::Init();
+}
+
+WindowCaptureToolbar::WindowCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void WindowCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "WindowCapture.Window", "WindowUtils.Window", "Window");
+	ui->deviceLabel->setText(device_str);
+
+#if !defined(_WIN32) && !defined(__APPLE__) //linux
+	prop_name = "capture_window";
+#else
+	prop_name = "window";
+#endif
+
+#ifdef __APPLE__
+	is_int = true;
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+ApplicationAudioCaptureToolbar::ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source)
+	: ComboSelectToolbar(parent, source)
+{
+}
+
+void ApplicationAudioCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = obs_get_module("win-wasapi");
+	const char *device_str = obs_module_get_locale_text(mod, "Window");
+	ui->deviceLabel->setText(device_str);
+
+	prop_name = "window";
+
+	ComboSelectToolbar::Init();
+}
+
+DisplayCaptureToolbar::DisplayCaptureToolbar(QWidget *parent, OBSSource source) : ComboSelectToolbar(parent, source) {}
+
+void DisplayCaptureToolbar::Init()
+{
+	delete ui->activateButton;
+	ui->activateButton = nullptr;
+
+	obs_module_t *mod = get_os_module("win-capture", "mac-capture", "linux-capture");
+	if (!mod)
+		return;
+
+	const char *device_str = get_os_text(mod, "Monitor", "DisplayCapture.Display", "Screen");
+	ui->deviceLabel->setText(device_str);
+
+#ifdef _WIN32
+	prop_name = "monitor_id";
+#elif __APPLE__
+	prop_name = "display_uuid";
+#else
+	is_int = true;
+	prop_name = "screen";
+#endif
+
+	ComboSelectToolbar::Init();
+}
+
+/* ========================================================================= */
+
+DeviceCaptureToolbar::DeviceCaptureToolbar(QWidget *parent, OBSSource source)
+	: QWidget(parent),
+	  weakSource(OBSGetWeakRef(source)),
+	  ui(new Ui_DeviceSelectToolbar)
+{
+	ui->setupUi(this);
+
+	delete ui->deviceLabel;
+	delete ui->device;
+	ui->deviceLabel = nullptr;
+	ui->device = nullptr;
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	active = obs_data_get_bool(settings, "active");
+
+	obs_module_t *mod = obs_get_module("win-dshow");
+	if (!mod)
+		return;
+
+	activateText = obs_module_get_locale_text(mod, "Activate");
+	deactivateText = obs_module_get_locale_text(mod, "Deactivate");
+
+	ui->activateButton->setText(active ? deactivateText : activateText);
+}
+
+DeviceCaptureToolbar::~DeviceCaptureToolbar() {}
+
+void DeviceCaptureToolbar::on_activateButton_clicked()
+{
+	OBSSource source = OBSGetStrongRef(weakSource);
+	if (!source) {
+		return;
+	}
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	bool now_active = obs_data_get_bool(settings, "active");
+
+	bool desyncedSetting = now_active != active;
+
+	active = !active;
+
+	const char *text = active ? deactivateText : activateText;
+	ui->activateButton->setText(text);
+
+	if (desyncedSetting) {
+		return;
+	}
+
+	calldata_t cd = {};
+	calldata_set_bool(&cd, "active", active);
+	proc_handler_t *ph = obs_source_get_proc_handler(source);
+	proc_handler_call(ph, "activate", &cd);
+	calldata_free(&cd);
+}
+
+/* ========================================================================= */
+
+GameCaptureToolbar::GameCaptureToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_GameCaptureToolbar)
+{
+	obs_property_t *p;
+	int cur_idx;
+
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("win-capture");
+	if (!mod)
+		return;
+
+	ui->modeLabel->setText(obs_module_get_locale_text(mod, "Mode"));
+	ui->windowLabel->setText(obs_module_get_locale_text(mod, "WindowCapture.Window"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string cur_mode = obs_data_get_string(settings, "capture_mode");
+	std::string cur_window = obs_data_get_string(settings, "window");
+
+	ui->mode->blockSignals(true);
+	p = obs_properties_get(props.get(), "capture_mode");
+	cur_idx = FillPropertyCombo(ui->mode, p, cur_mode);
+	ui->mode->setCurrentIndex(cur_idx);
+	ui->mode->blockSignals(false);
+
+	ui->window->blockSignals(true);
+	p = obs_properties_get(props.get(), "window");
+	cur_idx = FillPropertyCombo(ui->window, p, cur_window);
+	ui->window->setCurrentIndex(cur_idx);
+	ui->window->blockSignals(false);
+
+	if (cur_idx != -1 && obs_property_list_item_disabled(p, cur_idx)) {
+		SetComboItemEnabled(ui->window, cur_idx, false);
+	}
+
+	UpdateWindowVisibility();
+}
+
+GameCaptureToolbar::~GameCaptureToolbar() {}
+
+void GameCaptureToolbar::UpdateWindowVisibility()
+{
+	QString mode = ui->mode->currentData().toString();
+	bool is_window = (mode == "window");
+	ui->windowLabel->setVisible(is_window);
+	ui->window->setVisible(is_window);
+}
+
+void GameCaptureToolbar::on_mode_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->mode->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "capture_mode", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+
+	UpdateWindowVisibility();
+}
+
+void GameCaptureToolbar::on_window_currentIndexChanged(int idx)
+{
+	OBSSource source = GetSource();
+	if (idx == -1 || !source) {
+		return;
+	}
+
+	QString id = ui->window->itemData(idx).toString();
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "window", QT_TO_UTF8(id));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+ImageSourceToolbar::ImageSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ImageSourceToolbar)
+{
+	ui->setupUi(this);
+
+	obs_module_t *mod = obs_get_module("image-source");
+	ui->pathLabel->setText(obs_module_get_locale_text(mod, "File"));
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	std::string file = obs_data_get_string(settings, "file");
+
+	ui->path->setText(file.c_str());
+}
+
+ImageSourceToolbar::~ImageSourceToolbar() {}
+
+void ImageSourceToolbar::on_browse_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "file");
+	const char *desc = obs_property_description(p);
+	const char *filter = obs_property_path_filter(p);
+	const char *default_path = obs_property_path_default_path(p);
+
+	QString startDir = ui->path->text();
+	if (startDir.isEmpty())
+		startDir = default_path;
+
+	QString path = OpenFile(this, desc, startDir, filter);
+	if (path.isEmpty()) {
+		return;
+	}
+
+	ui->path->setText(path);
+
+	SaveOldProperties(source);
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "file", QT_TO_UTF8(path));
+	obs_source_update(source, settings);
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+static inline QColor color_from_int(long long val)
+{
+	return QColor(val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, (val >> 24) & 0xff);
+}
+
+static inline long long color_to_int(QColor color)
+{
+	auto shift = [&](unsigned val, int shift) {
+		return ((val & 0xff) << shift);
+	};
+
+	return shift(color.red(), 0) | shift(color.green(), 8) | shift(color.blue(), 16) | shift(color.alpha(), 24);
+}
+
+ColorSourceToolbar::ColorSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_ColorSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	unsigned int val = (unsigned int)obs_data_get_int(settings, "color");
+
+	color = color_from_int(val);
+	UpdateColor();
+}
+
+ColorSourceToolbar::~ColorSourceToolbar() {}
+
+void ColorSourceToolbar::UpdateColor()
+{
+	QPalette palette = QPalette(color);
+	ui->color->setFrameStyle(QFrame::Sunken | QFrame::Panel);
+	ui->color->setText(color.name(QColor::HexRgb));
+	ui->color->setPalette(palette);
+	ui->color->setStyleSheet(QString("background-color :%1; color: %2;")
+					 .arg(palette.color(QPalette::Window).name(QColor::HexRgb))
+					 .arg(palette.color(QPalette::WindowText).name(QColor::HexRgb)));
+	ui->color->setAutoFillBackground(true);
+	ui->color->setAlignment(Qt::AlignCenter);
+}
+
+void ColorSourceToolbar::on_choose_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	obs_property_t *p = obs_properties_get(props.get(), "color");
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+	UpdateColor();
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_int(settings, "color", color_to_int(color));
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+/* ========================================================================= */
+
+extern void MakeQFont(obs_data_t *font_obj, QFont &font, bool limit = false);
+
+TextSourceToolbar::TextSourceToolbar(QWidget *parent, OBSSource source)
+	: SourceToolbar(parent, source),
+	  ui(new Ui_TextSourceToolbar)
+{
+	ui->setupUi(this);
+
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+
+	const char *id = obs_source_get_unversioned_id(source);
+	bool ft2 = strcmp(id, "text_ft2_source") == 0;
+	bool read_from_file = obs_data_get_bool(settings, ft2 ? "from_file" : "read_from_file");
+
+	OBSDataAutoRelease font_obj = obs_data_get_obj(settings, "font");
+	MakeQFont(font_obj, font);
+
+	// Use "color1" if it's a freetype source and "color" elsewise
+	unsigned int val = (unsigned int)obs_data_get_int(
+		settings, (strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0) ? "color1" : "color");
+
+	color = color_from_int(val);
+
+	const char *text = obs_data_get_string(settings, "text");
+
+	bool single_line = !read_from_file && (!text || (strchr(text, '\n') == nullptr));
+	ui->emptySpace->setVisible(!single_line);
+	ui->text->setVisible(single_line);
+	if (single_line)
+		ui->text->setText(text);
+}
+
+TextSourceToolbar::~TextSourceToolbar() {}
+
+void TextSourceToolbar::on_selectFont_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	QFontDialog::FontDialogOptions options;
+	uint32_t flags;
+	bool success;
+
+#ifndef _WIN32
+	options = QFontDialog::DontUseNativeDialog;
+#endif
+
+	font = QFontDialog::getFont(&success, font, this, QTStr("Basic.PropertiesWindow.SelectFont.WindowTitle"),
+				    options);
+	if (!success) {
+		return;
+	}
+
+	OBSDataAutoRelease font_obj = obs_data_create();
+
+	obs_data_set_string(font_obj, "face", QT_TO_UTF8(font.family()));
+	obs_data_set_string(font_obj, "style", QT_TO_UTF8(font.styleName()));
+	obs_data_set_int(font_obj, "size", font.pointSize());
+	flags = font.bold() ? OBS_FONT_BOLD : 0;
+	flags |= font.italic() ? OBS_FONT_ITALIC : 0;
+	flags |= font.underline() ? OBS_FONT_UNDERLINE : 0;
+	flags |= font.strikeOut() ? OBS_FONT_STRIKEOUT : 0;
+	obs_data_set_int(font_obj, "flags", flags);
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+
+	obs_data_set_obj(settings, "font", font_obj);
+
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_selectColor_clicked()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+
+	bool freetype = strncmp(obs_source_get_id(source), "text_ft2_source", 15) == 0;
+
+	obs_property_t *p = obs_properties_get(props.get(), freetype ? "color1" : "color");
+
+	const char *desc = obs_property_description(p);
+
+	QColorDialog::ColorDialogOptions options;
+
+	options |= QColorDialog::ShowAlphaChannel;
+#ifdef __linux__
+	// TODO: Revisit hang on Ubuntu with native dialog
+	options |= QColorDialog::DontUseNativeDialog;
+#endif
+
+	QColor newColor = QColorDialog::getColor(color, this, desc, options);
+	if (!newColor.isValid()) {
+		return;
+	}
+
+	color = newColor;
+
+	SaveOldProperties(source);
+
+	OBSDataAutoRelease settings = obs_data_create();
+	if (freetype) {
+		obs_data_set_int(settings, "color1", color_to_int(color));
+		obs_data_set_int(settings, "color2", color_to_int(color));
+	} else {
+		obs_data_set_int(settings, "color", color_to_int(color));
+	}
+	obs_source_update(source, settings);
+
+	SetUndoProperties(source);
+}
+
+void TextSourceToolbar::on_text_textChanged()
+{
+	OBSSource source = GetSource();
+	if (!source) {
+		return;
+	}
+	std::string newText = QT_TO_UTF8(ui->text->text());
+	OBSDataAutoRelease settings = obs_source_get_settings(source);
+	if (newText == obs_data_get_string(settings, "text")) {
+		return;
+	}
+	SaveOldProperties(source);
+
+	obs_data_set_string(settings, "text", newText.c_str());
+	obs_source_update(source, nullptr);
+
+	SetUndoProperties(source, true);
+}

+ 178 - 0
frontend/components/WindowCaptureToolbar.hpp

@@ -0,0 +1,178 @@
+#pragma once
+
+#include <memory>
+#include <obs.hpp>
+#include <QWidget>
+
+class Ui_BrowserSourceToolbar;
+class Ui_DeviceSelectToolbar;
+class Ui_GameCaptureToolbar;
+class Ui_ImageSourceToolbar;
+class Ui_ColorSourceToolbar;
+class Ui_TextSourceToolbar;
+
+class SourceToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+protected:
+	using properties_delete_t = decltype(&obs_properties_destroy);
+	using properties_t = std::unique_ptr<obs_properties_t, properties_delete_t>;
+
+	properties_t props;
+	OBSDataAutoRelease oldData;
+
+	void SaveOldProperties(obs_source_t *source);
+	void SetUndoProperties(obs_source_t *source, bool repeatable = false);
+
+public:
+	SourceToolbar(QWidget *parent, OBSSource source);
+
+	OBSSource GetSource() { return OBSGetStrongRef(weakSource); }
+
+public slots:
+	virtual void Update() {}
+};
+
+class BrowserToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_BrowserSourceToolbar> ui;
+
+public:
+	BrowserToolbar(QWidget *parent, OBSSource source);
+	~BrowserToolbar();
+
+public slots:
+	void on_refresh_clicked();
+};
+
+class ComboSelectToolbar : public SourceToolbar {
+	Q_OBJECT
+
+protected:
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *prop_name;
+	bool is_int = false;
+
+public:
+	ComboSelectToolbar(QWidget *parent, OBSSource source);
+	~ComboSelectToolbar();
+	virtual void Init();
+
+public slots:
+	void on_device_currentIndexChanged(int idx);
+};
+
+class AudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	AudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class WindowCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	WindowCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class ApplicationAudioCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	ApplicationAudioCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DisplayCaptureToolbar : public ComboSelectToolbar {
+	Q_OBJECT
+
+public:
+	DisplayCaptureToolbar(QWidget *parent, OBSSource source);
+	void Init() override;
+};
+
+class DeviceCaptureToolbar : public QWidget {
+	Q_OBJECT
+
+	OBSWeakSource weakSource;
+
+	std::unique_ptr<Ui_DeviceSelectToolbar> ui;
+	const char *activateText;
+	const char *deactivateText;
+	bool active;
+
+public:
+	DeviceCaptureToolbar(QWidget *parent, OBSSource source);
+	~DeviceCaptureToolbar();
+
+public slots:
+	void on_activateButton_clicked();
+};
+
+class GameCaptureToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_GameCaptureToolbar> ui;
+
+	void UpdateWindowVisibility();
+
+public:
+	GameCaptureToolbar(QWidget *parent, OBSSource source);
+	~GameCaptureToolbar();
+
+public slots:
+	void on_mode_currentIndexChanged(int idx);
+	void on_window_currentIndexChanged(int idx);
+};
+
+class ImageSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ImageSourceToolbar> ui;
+
+public:
+	ImageSourceToolbar(QWidget *parent, OBSSource source);
+	~ImageSourceToolbar();
+
+public slots:
+	void on_browse_clicked();
+};
+
+class ColorSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_ColorSourceToolbar> ui;
+	QColor color;
+
+	void UpdateColor();
+
+public:
+	ColorSourceToolbar(QWidget *parent, OBSSource source);
+	~ColorSourceToolbar();
+
+public slots:
+	void on_choose_clicked();
+};
+
+class TextSourceToolbar : public SourceToolbar {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_TextSourceToolbar> ui;
+	QFont font;
+	QColor color;
+
+public:
+	TextSourceToolbar(QWidget *parent, OBSSource source);
+	~TextSourceToolbar();
+
+public slots:
+	void on_selectFont_clicked();
+	void on_selectColor_clicked();
+	void on_text_textChanged();
+};