2240 lines
78 KiB
C++
2240 lines
78 KiB
C++
#include "AudioLevelMeter.h"
|
|
#include "GraphEditorWidget.h"
|
|
#include "PresetManager.h"
|
|
#include "SquareConnectionPainter.h"
|
|
#include "VolumeWidgets.h"
|
|
#include "WarpGraphModel.h"
|
|
#include "ZoomGraphicsView.h"
|
|
|
|
#include <QtNodes/BasicGraphicsScene>
|
|
#include <QtNodes/ConnectionStyle>
|
|
#include <QtNodes/GraphicsView>
|
|
#include <QtNodes/internal/DefaultConnectionPainter.hpp>
|
|
#include <QtNodes/internal/NodeGraphicsObject.hpp>
|
|
#include <QtNodes/internal/ConnectionGraphicsObject.hpp>
|
|
#include <QtNodes/internal/UndoCommands.hpp>
|
|
|
|
#include <QAction>
|
|
#include <QCheckBox>
|
|
#include <QClipboard>
|
|
#include <QContextMenuEvent>
|
|
#include <QComboBox>
|
|
#include <QCoreApplication>
|
|
#include <QDateTime>
|
|
#include <QDialog>
|
|
#include <QDialogButtonBox>
|
|
#include <QDir>
|
|
#include <QFileDialog>
|
|
#include <QFormLayout>
|
|
#include <QGraphicsItem>
|
|
#include <QGuiApplication>
|
|
#include <QInputDialog>
|
|
#include <QLabel>
|
|
#include <QMouseEvent>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QMenu>
|
|
#include <QListWidget>
|
|
#include <QMainWindow>
|
|
#include <QMessageBox>
|
|
#include <QMimeData>
|
|
#include <QPixmap>
|
|
#include <QPushButton>
|
|
#include <QScrollArea>
|
|
#include <QSplitter>
|
|
#include <QStandardPaths>
|
|
#include <QStatusBar>
|
|
#include <QTabWidget>
|
|
#include <QTimer>
|
|
#include <QUndoCommand>
|
|
#include <QVBoxLayout>
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
|
|
namespace {
|
|
inline float sliderToVolume(int slider) {
|
|
float x = static_cast<float>(slider) / 100.0f;
|
|
return x * x * x;
|
|
}
|
|
inline int volumeToSlider(float volume) {
|
|
return static_cast<int>(std::round(std::cbrt(volume) * 100.0f));
|
|
}
|
|
}
|
|
|
|
class DeleteVirtualNodeCommand : public QUndoCommand {
|
|
public:
|
|
struct Snapshot {
|
|
uint32_t pwNodeId;
|
|
QtNodes::NodeId qtNodeId;
|
|
std::string name;
|
|
std::string mediaClass;
|
|
QPointF position;
|
|
};
|
|
|
|
DeleteVirtualNodeCommand(GraphEditorWidget *widget,
|
|
const QList<QtNodes::NodeId> &nodeIds)
|
|
: m_widget(widget) {
|
|
WarpGraphModel *model = widget->m_model;
|
|
for (auto nodeId : nodeIds) {
|
|
const WarpNodeData *data = model->warpNodeData(nodeId);
|
|
if (!data)
|
|
continue;
|
|
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
|
|
if (type != WarpNodeType::kVirtualSink &&
|
|
type != WarpNodeType::kVirtualSource)
|
|
continue;
|
|
|
|
Snapshot snap;
|
|
snap.pwNodeId = data->info.id.value;
|
|
snap.qtNodeId = nodeId;
|
|
snap.name = data->info.name;
|
|
snap.mediaClass = data->info.media_class;
|
|
snap.position =
|
|
model->nodeData(nodeId, QtNodes::NodeRole::Position).toPointF();
|
|
m_snapshots.push_back(snap);
|
|
}
|
|
setText(QStringLiteral("Delete Virtual Node"));
|
|
}
|
|
|
|
void undo() override {
|
|
if (!m_widget)
|
|
return;
|
|
auto *client = m_widget->m_client;
|
|
auto *model = m_widget->m_model;
|
|
if (!client || !model)
|
|
return;
|
|
|
|
for (const auto &snap : m_snapshots) {
|
|
model->setPendingPosition(snap.name, snap.position);
|
|
bool isSink = snap.mediaClass == "Audio/Sink" ||
|
|
snap.mediaClass == "Audio/Duplex";
|
|
if (isSink) {
|
|
client->CreateVirtualSink(snap.name);
|
|
} else {
|
|
client->CreateVirtualSource(snap.name);
|
|
}
|
|
}
|
|
model->refreshFromClient();
|
|
}
|
|
|
|
void redo() override {
|
|
if (!m_widget)
|
|
return;
|
|
auto *client = m_widget->m_client;
|
|
auto *model = m_widget->m_model;
|
|
if (!client || !model)
|
|
return;
|
|
|
|
for (auto &snap : m_snapshots) {
|
|
uint32_t currentPwId = model->findPwNodeIdByName(snap.name);
|
|
if (currentPwId != 0) {
|
|
snap.pwNodeId = currentPwId;
|
|
client->RemoveNode(warppipe::NodeId{currentPwId});
|
|
}
|
|
}
|
|
model->refreshFromClient();
|
|
}
|
|
|
|
private:
|
|
GraphEditorWidget *m_widget = nullptr;
|
|
std::vector<Snapshot> m_snapshots;
|
|
};
|
|
|
|
class VolumeChangeCommand : public QUndoCommand {
|
|
public:
|
|
VolumeChangeCommand(WarpGraphModel *model, QtNodes::NodeId nodeId,
|
|
WarpGraphModel::NodeVolumeState previous,
|
|
WarpGraphModel::NodeVolumeState next)
|
|
: m_model(model), m_nodeId(nodeId), m_previous(previous), m_next(next) {
|
|
setText(QStringLiteral("Volume Change"));
|
|
}
|
|
|
|
void undo() override {
|
|
if (m_model)
|
|
m_model->setNodeVolumeState(m_nodeId, m_previous);
|
|
}
|
|
|
|
void redo() override {
|
|
if (m_model)
|
|
m_model->setNodeVolumeState(m_nodeId, m_next);
|
|
}
|
|
|
|
private:
|
|
WarpGraphModel *m_model = nullptr;
|
|
QtNodes::NodeId m_nodeId;
|
|
WarpGraphModel::NodeVolumeState m_previous;
|
|
WarpGraphModel::NodeVolumeState m_next;
|
|
};
|
|
|
|
GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
|
QWidget *parent)
|
|
: QWidget(parent), m_client(client) {
|
|
m_layoutPath =
|
|
QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) +
|
|
QStringLiteral("/layout.json");
|
|
|
|
m_model = new WarpGraphModel(client, this);
|
|
bool hasLayout = m_model->loadLayout(m_layoutPath);
|
|
|
|
m_scene = new QtNodes::BasicGraphicsScene(*m_model, this);
|
|
m_scene->setItemIndexMethod(QGraphicsScene::BspTreeIndex);
|
|
|
|
QtNodes::ConnectionStyle::setConnectionStyle(
|
|
R"({"ConnectionStyle": {
|
|
"ConstructionColor": "#b4b4c8",
|
|
"NormalColor": "#c8c8dc",
|
|
"SelectedColor": "#ffa500",
|
|
"SelectedHaloColor": "#ffa50040",
|
|
"HoveredColor": "#f0c878",
|
|
"LineWidth": 2.4,
|
|
"ConstructionLineWidth": 1.8,
|
|
"PointDiameter": 10.0,
|
|
"UseDataDefinedColors": false
|
|
}})");
|
|
|
|
m_view = new ZoomGraphicsView(m_scene);
|
|
m_view->setFocusPolicy(Qt::StrongFocus);
|
|
m_view->viewport()->setFocusPolicy(Qt::StrongFocus);
|
|
m_view->viewport()->installEventFilter(this);
|
|
connect(m_view, &ZoomGraphicsView::scaleChanged, m_view,
|
|
[this]() { m_view->updateProxyCacheMode(); });
|
|
|
|
m_presetDir =
|
|
QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) +
|
|
QStringLiteral("/presets");
|
|
|
|
m_sidebar = new QTabWidget();
|
|
m_sidebar->setTabPosition(QTabWidget::North);
|
|
m_sidebar->setDocumentMode(true);
|
|
m_sidebar->setStyleSheet(QStringLiteral(
|
|
"QTabWidget::pane { border: none; background: #1a1a1e; }"
|
|
"QTabBar::tab { background: #24242a; color: #a0a8b6; padding: 8px 16px;"
|
|
" border: none; border-bottom: 2px solid transparent; }"
|
|
"QTabBar::tab:selected { color: #ecf0f6;"
|
|
" border-bottom: 2px solid #ffa500; }"
|
|
"QTabBar::tab:hover { background: #2e2e36; }"));
|
|
|
|
auto *presetsTab = new QWidget();
|
|
auto *presetsLayout = new QVBoxLayout(presetsTab);
|
|
presetsLayout->setContentsMargins(8, 8, 8, 8);
|
|
presetsLayout->setSpacing(6);
|
|
|
|
auto *savePresetBtn = new QPushButton(QStringLiteral("Save Preset..."));
|
|
savePresetBtn->setStyleSheet(QStringLiteral(
|
|
"QPushButton { background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;"
|
|
" border-radius: 4px; padding: 6px 12px; }"
|
|
"QPushButton:hover { background: #3a3a44; }"
|
|
"QPushButton:pressed { background: #44444e; }"));
|
|
connect(savePresetBtn, &QPushButton::clicked, this,
|
|
&GraphEditorWidget::savePreset);
|
|
|
|
auto *loadPresetBtn = new QPushButton(QStringLiteral("Load Preset..."));
|
|
loadPresetBtn->setStyleSheet(savePresetBtn->styleSheet());
|
|
connect(loadPresetBtn, &QPushButton::clicked, this,
|
|
&GraphEditorWidget::loadPreset);
|
|
|
|
presetsLayout->addWidget(savePresetBtn);
|
|
presetsLayout->addWidget(loadPresetBtn);
|
|
|
|
presetsLayout->addSpacing(16);
|
|
|
|
auto *zoomSensLabel = new QLabel(QStringLiteral("ZOOM SENSITIVITY"));
|
|
zoomSensLabel->setStyleSheet(QStringLiteral(
|
|
"QLabel { color: #a0a8b6; font-size: 11px; font-weight: bold;"
|
|
" background: transparent; }"));
|
|
presetsLayout->addWidget(zoomSensLabel);
|
|
|
|
m_zoomSensSlider = new QSlider(Qt::Horizontal);
|
|
m_zoomSensSlider->setRange(5, 50);
|
|
m_zoomSensSlider->setValue(20);
|
|
m_zoomSensSlider->setStyleSheet(QStringLiteral(
|
|
"QSlider::groove:horizontal {"
|
|
" background: #1a1a1e; border-radius: 3px; height: 6px; }"
|
|
"QSlider::handle:horizontal {"
|
|
" background: #ecf0f6; border-radius: 5px;"
|
|
" width: 10px; margin: -4px 0; }"
|
|
"QSlider::sub-page:horizontal {"
|
|
" background: #4caf50; border-radius: 3px; }"));
|
|
|
|
m_zoomSensValue = new QLabel(QStringLiteral("1.20x"));
|
|
m_zoomSensValue->setStyleSheet(QStringLiteral(
|
|
"QLabel { color: #ecf0f6; font-size: 11px; background: transparent; }"));
|
|
m_zoomSensValue->setAlignment(Qt::AlignCenter);
|
|
|
|
connect(m_zoomSensSlider, &QSlider::valueChanged, this,
|
|
[this](int value) {
|
|
double sensitivity = 1.0 + value / 100.0;
|
|
m_view->setZoomSensitivity(sensitivity);
|
|
m_zoomSensValue->setText(
|
|
QString::number(sensitivity, 'f', 2) + QStringLiteral("x"));
|
|
scheduleSaveLayout();
|
|
});
|
|
|
|
presetsLayout->addWidget(m_zoomSensSlider);
|
|
presetsLayout->addWidget(m_zoomSensValue);
|
|
|
|
auto sliderStyle = m_zoomSensSlider->styleSheet();
|
|
auto valueLabelStyle = m_zoomSensValue->styleSheet();
|
|
|
|
presetsLayout->addSpacing(12);
|
|
|
|
auto *zoomMinLabel = new QLabel(QStringLiteral("MIN ZOOM"));
|
|
zoomMinLabel->setStyleSheet(zoomSensLabel->styleSheet());
|
|
presetsLayout->addWidget(zoomMinLabel);
|
|
|
|
m_zoomMinSlider = new QSlider(Qt::Horizontal);
|
|
m_zoomMinSlider->setRange(5, 90);
|
|
m_zoomMinSlider->setValue(30);
|
|
m_zoomMinSlider->setStyleSheet(sliderStyle);
|
|
|
|
m_zoomMinValue = new QLabel(QStringLiteral("0.30x"));
|
|
m_zoomMinValue->setStyleSheet(valueLabelStyle);
|
|
m_zoomMinValue->setAlignment(Qt::AlignCenter);
|
|
|
|
connect(m_zoomMinSlider, &QSlider::valueChanged, this,
|
|
[this](int value) {
|
|
double minZoom = value / 100.0;
|
|
if (m_zoomMaxSlider->value() <= value) {
|
|
m_zoomMaxSlider->setValue(value + 5);
|
|
}
|
|
m_view->setScaleRange(minZoom,
|
|
m_zoomMaxSlider->value() / 100.0);
|
|
m_zoomMinValue->setText(
|
|
QString::number(minZoom, 'f', 2) + QStringLiteral("x"));
|
|
scheduleSaveLayout();
|
|
});
|
|
|
|
presetsLayout->addWidget(m_zoomMinSlider);
|
|
presetsLayout->addWidget(m_zoomMinValue);
|
|
|
|
presetsLayout->addSpacing(12);
|
|
|
|
auto *zoomMaxLabel = new QLabel(QStringLiteral("MAX ZOOM"));
|
|
zoomMaxLabel->setStyleSheet(zoomSensLabel->styleSheet());
|
|
presetsLayout->addWidget(zoomMaxLabel);
|
|
|
|
m_zoomMaxSlider = new QSlider(Qt::Horizontal);
|
|
m_zoomMaxSlider->setRange(10, 500);
|
|
m_zoomMaxSlider->setValue(200);
|
|
m_zoomMaxSlider->setStyleSheet(sliderStyle);
|
|
|
|
m_zoomMaxValue = new QLabel(QStringLiteral("2.00x"));
|
|
m_zoomMaxValue->setStyleSheet(valueLabelStyle);
|
|
m_zoomMaxValue->setAlignment(Qt::AlignCenter);
|
|
|
|
connect(m_zoomMaxSlider, &QSlider::valueChanged, this,
|
|
[this](int value) {
|
|
double maxZoom = value / 100.0;
|
|
if (m_zoomMinSlider->value() >= value) {
|
|
m_zoomMinSlider->setValue(value - 5);
|
|
}
|
|
m_view->setScaleRange(m_zoomMinSlider->value() / 100.0,
|
|
maxZoom);
|
|
m_zoomMaxValue->setText(
|
|
QString::number(maxZoom, 'f', 2) + QStringLiteral("x"));
|
|
scheduleSaveLayout();
|
|
});
|
|
|
|
presetsLayout->addWidget(m_zoomMaxSlider);
|
|
presetsLayout->addWidget(m_zoomMaxValue);
|
|
presetsLayout->addStretch();
|
|
|
|
auto *metersTab = new QWidget();
|
|
auto *metersLayout = new QVBoxLayout(metersTab);
|
|
metersLayout->setContentsMargins(8, 8, 8, 8);
|
|
metersLayout->setSpacing(8);
|
|
|
|
auto *masterLabel = new QLabel(QStringLiteral("MASTER OUTPUT"));
|
|
masterLabel->setStyleSheet(QStringLiteral(
|
|
"QLabel { color: #a0a8b6; font-size: 11px; font-weight: bold;"
|
|
" background: transparent; }"));
|
|
metersLayout->addWidget(masterLabel);
|
|
|
|
auto *masterRow = new QWidget();
|
|
auto *masterRowLayout = new QHBoxLayout(masterRow);
|
|
masterRowLayout->setContentsMargins(0, 0, 0, 0);
|
|
masterRowLayout->setSpacing(4);
|
|
m_masterMeterL = new AudioLevelMeter();
|
|
m_masterMeterL->setFixedWidth(18);
|
|
m_masterMeterL->setMinimumHeight(100);
|
|
m_masterMeterR = new AudioLevelMeter();
|
|
m_masterMeterR->setFixedWidth(18);
|
|
m_masterMeterR->setMinimumHeight(100);
|
|
masterRowLayout->addStretch();
|
|
masterRowLayout->addWidget(m_masterMeterL);
|
|
masterRowLayout->addWidget(m_masterMeterR);
|
|
masterRowLayout->addStretch();
|
|
metersLayout->addWidget(masterRow);
|
|
|
|
auto *nodeMetersLabel = new QLabel(QStringLiteral("NODE METERS"));
|
|
nodeMetersLabel->setStyleSheet(masterLabel->styleSheet());
|
|
metersLayout->addWidget(nodeMetersLabel);
|
|
|
|
m_nodeMeterScroll = new QScrollArea();
|
|
m_nodeMeterScroll->setWidgetResizable(true);
|
|
m_nodeMeterScroll->setStyleSheet(QStringLiteral(
|
|
"QScrollArea { background: transparent; border: none; }"
|
|
"QScrollBar:vertical { background: #1a1a1e; width: 8px; }"
|
|
"QScrollBar::handle:vertical { background: #3a3a44; border-radius: 4px; }"
|
|
"QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }"));
|
|
m_nodeMeterContainer = new QWidget();
|
|
m_nodeMeterContainer->setStyleSheet(QStringLiteral("background: transparent;"));
|
|
auto *nodeMeterLayout = new QVBoxLayout(m_nodeMeterContainer);
|
|
nodeMeterLayout->setContentsMargins(0, 0, 0, 0);
|
|
nodeMeterLayout->setSpacing(2);
|
|
nodeMeterLayout->addStretch();
|
|
m_nodeMeterScroll->setWidget(m_nodeMeterContainer);
|
|
metersLayout->addWidget(m_nodeMeterScroll, 1);
|
|
|
|
metersTab->setStyleSheet(QStringLiteral("background: #1a1a1e;"));
|
|
m_sidebar->addTab(metersTab, QStringLiteral("METERS"));
|
|
|
|
m_mixerScroll = new QScrollArea();
|
|
m_mixerScroll->setWidgetResizable(true);
|
|
m_mixerScroll->setStyleSheet(QStringLiteral(
|
|
"QScrollArea { background: #1a1a1e; border: none; }"
|
|
"QScrollBar:vertical { background: #1a1a1e; width: 8px; }"
|
|
"QScrollBar::handle:vertical { background: #3a3a44; border-radius: 4px; }"
|
|
"QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }"));
|
|
m_mixerContainer = new QWidget();
|
|
m_mixerContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;"));
|
|
auto *mixerLayout = new QVBoxLayout(m_mixerContainer);
|
|
mixerLayout->setContentsMargins(4, 4, 4, 4);
|
|
mixerLayout->setSpacing(2);
|
|
mixerLayout->addStretch();
|
|
m_mixerScroll->setWidget(m_mixerContainer);
|
|
m_sidebar->addTab(m_mixerScroll, QStringLiteral("MIXER"));
|
|
m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS"));
|
|
|
|
m_rulesScroll = new QScrollArea();
|
|
m_rulesScroll->setWidgetResizable(true);
|
|
m_rulesScroll->setStyleSheet(m_mixerScroll->styleSheet());
|
|
m_rulesContainer = new QWidget();
|
|
m_rulesContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;"));
|
|
auto *rulesLayout = new QVBoxLayout(m_rulesContainer);
|
|
rulesLayout->setContentsMargins(8, 8, 8, 8);
|
|
rulesLayout->setSpacing(6);
|
|
rulesLayout->addStretch();
|
|
m_rulesScroll->setWidget(m_rulesContainer);
|
|
m_sidebar->addTab(m_rulesScroll, QStringLiteral("RULES"));
|
|
|
|
m_nodeDetailsScroll = new QScrollArea();
|
|
m_nodeDetailsScroll->setWidgetResizable(true);
|
|
m_nodeDetailsScroll->setStyleSheet(m_mixerScroll->styleSheet());
|
|
m_nodeDetailsContainer = new QWidget();
|
|
m_nodeDetailsContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;"));
|
|
auto *nodeDetailsLayout = new QVBoxLayout(m_nodeDetailsContainer);
|
|
nodeDetailsLayout->setContentsMargins(8, 8, 8, 8);
|
|
nodeDetailsLayout->setSpacing(6);
|
|
auto *noSelectionLabel = new QLabel(QStringLiteral("Select a node to view details"));
|
|
noSelectionLabel->setStyleSheet(QStringLiteral(
|
|
"color: #6a6a7a; font-style: italic; background: transparent;"));
|
|
noSelectionLabel->setAlignment(Qt::AlignCenter);
|
|
noSelectionLabel->setWordWrap(true);
|
|
nodeDetailsLayout->addStretch();
|
|
nodeDetailsLayout->addWidget(noSelectionLabel);
|
|
nodeDetailsLayout->addStretch();
|
|
m_nodeDetailsScroll->setWidget(m_nodeDetailsContainer);
|
|
m_sidebar->addTab(m_nodeDetailsScroll, QStringLiteral("NODE"));
|
|
|
|
m_splitter = new QSplitter(Qt::Horizontal);
|
|
m_splitter->addWidget(m_view);
|
|
m_splitter->addWidget(m_sidebar);
|
|
m_splitter->setStretchFactor(0, 1);
|
|
m_splitter->setStretchFactor(1, 0);
|
|
m_splitter->setSizes({1200, 320});
|
|
m_splitter->setStyleSheet(QStringLiteral(
|
|
"QSplitter::handle { background: #2a2a32; width: 2px; }"));
|
|
|
|
auto *layout = new QVBoxLayout(this);
|
|
layout->setContentsMargins(0, 0, 0, 0);
|
|
layout->addWidget(m_splitter);
|
|
|
|
m_view->setContextMenuPolicy(Qt::CustomContextMenu);
|
|
connect(m_view, &QWidget::customContextMenuRequested, this,
|
|
&GraphEditorWidget::onContextMenuRequested);
|
|
|
|
removeDefaultActions();
|
|
|
|
auto *deleteAction =
|
|
new QAction(QStringLiteral("Delete Selection"), m_view);
|
|
deleteAction->setShortcut(QKeySequence::Delete);
|
|
deleteAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(deleteAction, &QAction::triggered, this,
|
|
&GraphEditorWidget::deleteSelection);
|
|
m_view->addAction(deleteAction);
|
|
|
|
auto *copyAction =
|
|
new QAction(QStringLiteral("Copy Selection"), m_view);
|
|
copyAction->setShortcut(QKeySequence::Copy);
|
|
copyAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(copyAction, &QAction::triggered, this,
|
|
&GraphEditorWidget::copySelection);
|
|
m_view->addAction(copyAction);
|
|
|
|
auto *pasteAction =
|
|
new QAction(QStringLiteral("Paste Selection"), m_view);
|
|
pasteAction->setShortcut(QKeySequence::Paste);
|
|
pasteAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(pasteAction, &QAction::triggered, this,
|
|
[this]() { pasteSelection(QPointF(30, 30)); });
|
|
m_view->addAction(pasteAction);
|
|
|
|
auto *duplicateAction =
|
|
new QAction(QStringLiteral("Duplicate Selection"), m_view);
|
|
duplicateAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D));
|
|
duplicateAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(duplicateAction, &QAction::triggered, this,
|
|
&GraphEditorWidget::duplicateSelection);
|
|
m_view->addAction(duplicateAction);
|
|
|
|
auto *autoArrangeAction =
|
|
new QAction(QStringLiteral("Auto-Arrange"), m_view);
|
|
autoArrangeAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_L));
|
|
autoArrangeAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(autoArrangeAction, &QAction::triggered, this, [this]() {
|
|
m_model->autoArrange();
|
|
saveLayoutWithViewState();
|
|
});
|
|
m_view->addAction(autoArrangeAction);
|
|
|
|
auto *selectAllAction =
|
|
new QAction(QStringLiteral("Select All"), m_view);
|
|
selectAllAction->setShortcut(QKeySequence::SelectAll);
|
|
selectAllAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(selectAllAction, &QAction::triggered, this, [this]() {
|
|
for (auto *item : m_scene->items()) {
|
|
item->setSelected(true);
|
|
}
|
|
});
|
|
m_view->addAction(selectAllAction);
|
|
|
|
auto *deselectAllAction =
|
|
new QAction(QStringLiteral("Deselect All"), m_view);
|
|
deselectAllAction->setShortcut(
|
|
QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_A));
|
|
deselectAllAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(deselectAllAction, &QAction::triggered, m_scene,
|
|
&QGraphicsScene::clearSelection);
|
|
m_view->addAction(deselectAllAction);
|
|
|
|
auto *zoomFitAllAction =
|
|
new QAction(QStringLiteral("Zoom Fit All"), m_view);
|
|
zoomFitAllAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_F));
|
|
zoomFitAllAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(zoomFitAllAction, &QAction::triggered, m_view,
|
|
&QtNodes::GraphicsView::zoomFitAll);
|
|
m_view->addAction(zoomFitAllAction);
|
|
|
|
auto *zoomFitSelectedAction =
|
|
new QAction(QStringLiteral("Zoom Fit Selected"), m_view);
|
|
zoomFitSelectedAction->setShortcut(QKeySequence(Qt::Key_F));
|
|
zoomFitSelectedAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(zoomFitSelectedAction, &QAction::triggered, m_view,
|
|
&QtNodes::GraphicsView::zoomFitSelected);
|
|
m_view->addAction(zoomFitSelectedAction);
|
|
|
|
auto *refreshAction =
|
|
new QAction(QStringLiteral("Refresh Graph"), m_view);
|
|
refreshAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
|
|
refreshAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(refreshAction, &QAction::triggered, this, [this]() {
|
|
m_model->refreshFromClient();
|
|
});
|
|
m_view->addAction(refreshAction);
|
|
|
|
connect(m_model, &WarpGraphModel::nodePositionUpdated, this,
|
|
&GraphEditorWidget::scheduleSaveLayout);
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeCreated, this,
|
|
&GraphEditorWidget::scheduleSaveLayout);
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this,
|
|
&GraphEditorWidget::scheduleSaveLayout);
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeUpdated, this,
|
|
&GraphEditorWidget::scheduleSaveLayout);
|
|
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeCreated, this,
|
|
[this](QtNodes::NodeId nodeId) {
|
|
wireVolumeWidget(nodeId);
|
|
rebuildMixerStrips();
|
|
rebuildNodeMeters();
|
|
rebuildRulesList();
|
|
});
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this,
|
|
[this](QtNodes::NodeId nodeId) {
|
|
m_mixerStrips.erase(nodeId);
|
|
m_nodeMeters.erase(nodeId);
|
|
rebuildMixerStrips();
|
|
rebuildNodeMeters();
|
|
rebuildRulesList();
|
|
if (nodeId == m_selectedNodeId) {
|
|
m_selectedNodeId = 0;
|
|
clearNodeDetailsPanel();
|
|
}
|
|
});
|
|
|
|
connect(m_scene, &QGraphicsScene::selectionChanged, this,
|
|
&GraphEditorWidget::onSelectionChanged);
|
|
|
|
m_saveTimer = new QTimer(this);
|
|
m_saveTimer->setSingleShot(true);
|
|
m_saveTimer->setInterval(1000);
|
|
connect(m_saveTimer, &QTimer::timeout, this,
|
|
&GraphEditorWidget::saveLayoutWithViewState);
|
|
|
|
m_model->refreshFromClient();
|
|
rebuildRulesList();
|
|
if (!hasLayout) {
|
|
m_model->autoArrange();
|
|
}
|
|
|
|
QTimer::singleShot(0, this, &GraphEditorWidget::restoreViewState);
|
|
|
|
if (m_model->allNodeIds().size() > 0) {
|
|
m_graphReady = true;
|
|
Q_EMIT graphReady();
|
|
}
|
|
|
|
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this,
|
|
&GraphEditorWidget::saveLayoutWithViewState);
|
|
|
|
m_changeTimer = new QTimer(this);
|
|
m_changeTimer->setSingleShot(true);
|
|
m_changeTimer->setInterval(50);
|
|
connect(m_changeTimer, &QTimer::timeout, this,
|
|
&GraphEditorWidget::onRefreshTimer);
|
|
|
|
if (m_client) {
|
|
m_client->SetChangeCallback([this] {
|
|
QMetaObject::invokeMethod(m_changeTimer,
|
|
qOverload<>(&QTimer::start),
|
|
Qt::QueuedConnection);
|
|
});
|
|
}
|
|
|
|
m_refreshTimer = new QTimer(this);
|
|
connect(m_refreshTimer, &QTimer::timeout, this,
|
|
&GraphEditorWidget::onRefreshTimer);
|
|
m_refreshTimer->start(2000);
|
|
|
|
m_meterTimer = new QTimer(this);
|
|
m_meterTimer->setTimerType(Qt::PreciseTimer);
|
|
connect(m_meterTimer, &QTimer::timeout, this,
|
|
&GraphEditorWidget::updateMeters);
|
|
m_meterTimer->start(33);
|
|
}
|
|
|
|
void GraphEditorWidget::onRefreshTimer() {
|
|
m_model->refreshFromClient();
|
|
|
|
if (!m_graphReady && m_model->allNodeIds().size() > 0) {
|
|
m_graphReady = true;
|
|
Q_EMIT graphReady();
|
|
captureDebugScreenshot("initial_load");
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::scheduleSaveLayout() {
|
|
m_saveTimer->start();
|
|
}
|
|
|
|
GraphEditorWidget::~GraphEditorWidget() {
|
|
if (m_client) {
|
|
m_client->SetChangeCallback(nullptr);
|
|
}
|
|
}
|
|
|
|
int GraphEditorWidget::nodeCount() const {
|
|
return static_cast<int>(m_model->allNodeIds().size());
|
|
}
|
|
|
|
int GraphEditorWidget::linkCount() const {
|
|
int count = 0;
|
|
for (auto nodeId : m_model->allNodeIds()) {
|
|
count += static_cast<int>(
|
|
m_model->allConnectionIds(nodeId).size());
|
|
}
|
|
return count / 2;
|
|
}
|
|
|
|
void GraphEditorWidget::setDebugScreenshotDir(const QString &dir) {
|
|
m_debugScreenshotDir = dir;
|
|
QDir d(dir);
|
|
if (!d.exists()) {
|
|
d.mkpath(".");
|
|
}
|
|
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeCreated, this, [this]() {
|
|
captureDebugScreenshot("node_added");
|
|
});
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this, [this]() {
|
|
captureDebugScreenshot("node_removed");
|
|
});
|
|
connect(m_model, &QtNodes::AbstractGraphModel::connectionCreated, this,
|
|
[this]() { captureDebugScreenshot("connection_added"); });
|
|
connect(m_model, &QtNodes::AbstractGraphModel::connectionDeleted, this,
|
|
[this]() { captureDebugScreenshot("connection_removed"); });
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeUpdated, this, [this]() {
|
|
captureDebugScreenshot("node_updated");
|
|
});
|
|
|
|
if (m_graphReady) {
|
|
QTimer::singleShot(200, this, [this]() {
|
|
captureDebugScreenshot("initial_load");
|
|
});
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::captureDebugScreenshot(const QString &event) {
|
|
if (m_debugScreenshotDir.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
QWidget *win = window();
|
|
if (!win) {
|
|
return;
|
|
}
|
|
|
|
QPixmap pixmap = win->grab();
|
|
if (pixmap.isNull()) {
|
|
return;
|
|
}
|
|
|
|
QString timestamp =
|
|
QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
|
|
QString filename = QString("warppipe_%1_%2.png").arg(timestamp, event);
|
|
pixmap.save(m_debugScreenshotDir + "/" + filename);
|
|
}
|
|
|
|
bool GraphEditorWidget::eventFilter(QObject *obj, QEvent *event) {
|
|
if (obj != m_view->viewport()) {
|
|
return QWidget::eventFilter(obj, event);
|
|
}
|
|
if (event->type() == QEvent::ContextMenu) {
|
|
auto *cme = static_cast<QContextMenuEvent *>(event);
|
|
m_lastContextMenuScenePos = m_view->mapToScene(cme->pos());
|
|
} else if (event->type() == QEvent::MouseButtonPress) {
|
|
auto *me = static_cast<QMouseEvent *>(event);
|
|
if (me->button() == Qt::MiddleButton) {
|
|
m_view->centerOn(m_view->mapToScene(me->pos()));
|
|
return true;
|
|
}
|
|
}
|
|
return QWidget::eventFilter(obj, event);
|
|
}
|
|
|
|
void GraphEditorWidget::onContextMenuRequested(const QPoint &pos) {
|
|
QPointF scenePos = m_view->mapToScene(pos);
|
|
m_lastContextMenuScenePos = scenePos;
|
|
|
|
uint32_t hitPwNodeId = 0;
|
|
QtNodes::NodeId hitQtNodeId = 0;
|
|
for (auto nodeId : m_model->allNodeIds()) {
|
|
const WarpNodeData *data = m_model->warpNodeData(nodeId);
|
|
if (!data) {
|
|
continue;
|
|
}
|
|
QPointF nodePos =
|
|
m_model->nodeData(nodeId, QtNodes::NodeRole::Position).toPointF();
|
|
QSize nodeSize =
|
|
m_model->nodeData(nodeId, QtNodes::NodeRole::Size).toSize();
|
|
QRectF nodeRect(nodePos, QSizeF(nodeSize));
|
|
if (nodeRect.contains(scenePos)) {
|
|
hitPwNodeId = data->info.id.value;
|
|
hitQtNodeId = nodeId;
|
|
break;
|
|
}
|
|
}
|
|
|
|
QPoint screenPos = m_view->mapToGlobal(pos);
|
|
if (hitPwNodeId != 0) {
|
|
showNodeContextMenu(screenPos, hitPwNodeId, hitQtNodeId);
|
|
} else {
|
|
showCanvasContextMenu(screenPos, scenePos);
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
|
|
const QPointF &scenePos) {
|
|
QMenu menu;
|
|
QAction *createSink = menu.addAction(QStringLiteral("Create Virtual Sink"));
|
|
QAction *createSource =
|
|
menu.addAction(QStringLiteral("Create Virtual Source"));
|
|
menu.addSeparator();
|
|
QAction *pasteAction = menu.addAction(QStringLiteral("Paste"));
|
|
pasteAction->setShortcut(QKeySequence::Paste);
|
|
pasteAction->setEnabled(!m_clipboardJson.isEmpty() ||
|
|
(QGuiApplication::clipboard()->mimeData() &&
|
|
QGuiApplication::clipboard()->mimeData()->hasFormat(
|
|
QStringLiteral(
|
|
"application/warppipe-virtual-graph"))));
|
|
menu.addSeparator();
|
|
QAction *selectAll = menu.addAction(QStringLiteral("Select All"));
|
|
selectAll->setShortcut(QKeySequence::SelectAll);
|
|
QAction *deselectAll = menu.addAction(QStringLiteral("Deselect All"));
|
|
deselectAll->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_A));
|
|
menu.addSeparator();
|
|
QAction *zoomFitAll = menu.addAction(QStringLiteral("Zoom Fit All"));
|
|
zoomFitAll->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_F));
|
|
QAction *zoomFitSelected = menu.addAction(QStringLiteral("Zoom Fit Selected"));
|
|
zoomFitSelected->setShortcut(QKeySequence(Qt::Key_F));
|
|
menu.addSeparator();
|
|
QAction *autoArrange = menu.addAction(QStringLiteral("Auto-Arrange"));
|
|
autoArrange->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_L));
|
|
QAction *refreshGraph = menu.addAction(QStringLiteral("Refresh Graph"));
|
|
refreshGraph->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
|
|
menu.addSeparator();
|
|
auto *connStyleMenu = menu.addMenu(QStringLiteral("Connection Style"));
|
|
auto *styleBezier = connStyleMenu->addAction(QStringLiteral("Bezier Curves"));
|
|
styleBezier->setCheckable(true);
|
|
styleBezier->setChecked(m_connectionStyle == ConnectionStyleType::kBezier);
|
|
auto *styleSquare = connStyleMenu->addAction(QStringLiteral("Square Routing"));
|
|
styleSquare->setCheckable(true);
|
|
styleSquare->setChecked(m_connectionStyle == ConnectionStyleType::kSquare);
|
|
|
|
menu.addSeparator();
|
|
QAction *saveLayoutAs = menu.addAction(QStringLiteral("Save Layout As..."));
|
|
QAction *resetLayout = menu.addAction(QStringLiteral("Reset Layout"));
|
|
menu.addSeparator();
|
|
QAction *savePresetAction = menu.addAction(QStringLiteral("Save Preset..."));
|
|
QAction *loadPresetAction = menu.addAction(QStringLiteral("Load Preset..."));
|
|
|
|
QAction *chosen = menu.exec(screenPos);
|
|
if (chosen == createSink) {
|
|
createVirtualNode(true, scenePos);
|
|
} else if (chosen == createSource) {
|
|
createVirtualNode(false, scenePos);
|
|
} else if (chosen == pasteAction) {
|
|
pasteSelection(QPointF(0, 0));
|
|
} else if (chosen == selectAll) {
|
|
for (auto *item : m_scene->items()) {
|
|
item->setSelected(true);
|
|
}
|
|
} else if (chosen == deselectAll) {
|
|
m_scene->clearSelection();
|
|
} else if (chosen == zoomFitAll) {
|
|
m_view->zoomFitAll();
|
|
} else if (chosen == zoomFitSelected) {
|
|
m_view->zoomFitSelected();
|
|
} else if (chosen == autoArrange) {
|
|
m_model->autoArrange();
|
|
saveLayoutWithViewState();
|
|
} else if (chosen == refreshGraph) {
|
|
m_model->refreshFromClient();
|
|
} else if (chosen == saveLayoutAs) {
|
|
QString path = QFileDialog::getSaveFileName(
|
|
this, QStringLiteral("Save Layout As"), QString(),
|
|
QStringLiteral("JSON files (*.json)"));
|
|
if (!path.isEmpty()) {
|
|
saveLayoutWithViewState();
|
|
m_model->saveLayout(path);
|
|
}
|
|
} else if (chosen == resetLayout) {
|
|
m_model->clearSavedPositions();
|
|
m_model->autoArrange();
|
|
m_view->zoomFitAll();
|
|
saveLayoutWithViewState();
|
|
} else if (chosen == styleBezier) {
|
|
setConnectionStyle(ConnectionStyleType::kBezier);
|
|
} else if (chosen == styleSquare) {
|
|
setConnectionStyle(ConnectionStyleType::kSquare);
|
|
} else if (chosen == savePresetAction) {
|
|
savePreset();
|
|
} else if (chosen == loadPresetAction) {
|
|
loadPreset();
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos,
|
|
uint32_t pwNodeId,
|
|
QtNodes::NodeId qtNodeId) {
|
|
const WarpNodeData *data = m_model->warpNodeData(qtNodeId);
|
|
if (!data) {
|
|
return;
|
|
}
|
|
|
|
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
|
|
bool isVirtual =
|
|
type == WarpNodeType::kVirtualSink || type == WarpNodeType::kVirtualSource;
|
|
|
|
QMenu menu;
|
|
|
|
QAction *copyAction = nullptr;
|
|
QAction *duplicateAction = nullptr;
|
|
QAction *deleteAction = nullptr;
|
|
|
|
if (isVirtual) {
|
|
copyAction = menu.addAction(QStringLiteral("Copy"));
|
|
copyAction->setShortcut(QKeySequence::Copy);
|
|
duplicateAction = menu.addAction(QStringLiteral("Duplicate"));
|
|
duplicateAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D));
|
|
menu.addSeparator();
|
|
deleteAction = menu.addAction(QStringLiteral("Delete"));
|
|
deleteAction->setShortcut(QKeySequence::Delete);
|
|
}
|
|
|
|
QAction *createRuleAction = nullptr;
|
|
if (type == WarpNodeType::kApplication) {
|
|
menu.addSeparator();
|
|
createRuleAction = menu.addAction(QStringLiteral("Create Rule..."));
|
|
}
|
|
|
|
menu.addSeparator();
|
|
QAction *pasteAction = menu.addAction(QStringLiteral("Paste"));
|
|
pasteAction->setShortcut(QKeySequence::Paste);
|
|
pasteAction->setEnabled(!m_clipboardJson.isEmpty() ||
|
|
(QGuiApplication::clipboard()->mimeData() &&
|
|
QGuiApplication::clipboard()->mimeData()->hasFormat(
|
|
QStringLiteral(
|
|
"application/warppipe-virtual-graph"))));
|
|
|
|
QAction *chosen = menu.exec(screenPos);
|
|
if (!chosen) {
|
|
return;
|
|
}
|
|
if (chosen == copyAction) {
|
|
copySelection();
|
|
} else if (chosen == duplicateAction) {
|
|
duplicateSelection();
|
|
} else if (chosen == deleteAction && m_client) {
|
|
deleteSelection();
|
|
} else if (chosen == pasteAction) {
|
|
pasteSelection(QPointF(0, 0));
|
|
} else if (chosen == createRuleAction) {
|
|
showAddRuleDialog(data->info.application_name,
|
|
data->info.process_binary,
|
|
data->info.media_role);
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::createVirtualNode(bool isSink,
|
|
const QPointF &scenePos) {
|
|
if (isSink) {
|
|
bool ok = false;
|
|
QString name = QInputDialog::getText(
|
|
this, QStringLiteral("Create Virtual Sink"),
|
|
QStringLiteral("Node name:"), QLineEdit::Normal, QString(), &ok);
|
|
if (!ok || name.trimmed().isEmpty())
|
|
return;
|
|
|
|
std::string nodeName = name.trimmed().toStdString();
|
|
m_model->setPendingPosition(nodeName, scenePos);
|
|
auto result = m_client->CreateVirtualSink(nodeName);
|
|
if (!result.status.ok()) {
|
|
QMessageBox::warning(this, QStringLiteral("Error"),
|
|
QString::fromStdString(result.status.message));
|
|
return;
|
|
}
|
|
m_model->refreshFromClient();
|
|
return;
|
|
}
|
|
|
|
static const QString kDialogStyle = QStringLiteral(
|
|
"QDialog { background: #1e1e22; }"
|
|
"QLabel { color: #ecf0f6; }"
|
|
"QLineEdit { background: #2a2a32; color: #ecf0f6; border: 1px solid #3a3a44;"
|
|
" border-radius: 4px; padding: 4px 8px; }"
|
|
"QCheckBox { color: #ecf0f6; }"
|
|
"QCheckBox::indicator { width: 16px; height: 16px; }"
|
|
"QComboBox { background: #2a2a32; color: #ecf0f6; border: 1px solid #3a3a44;"
|
|
" border-radius: 4px; padding: 4px 8px; }"
|
|
"QComboBox::drop-down { border: none; }"
|
|
"QComboBox QAbstractItemView { background: #2a2a32; color: #ecf0f6;"
|
|
" selection-background-color: #3a3a44; }");
|
|
|
|
static const QString kButtonStyle = QStringLiteral(
|
|
"QPushButton { background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;"
|
|
" border-radius: 4px; padding: 6px 16px; }"
|
|
"QPushButton:hover { background: #3a3a44; }");
|
|
|
|
QDialog dlg(this);
|
|
dlg.setWindowTitle(QStringLiteral("Create Virtual Source"));
|
|
dlg.setStyleSheet(kDialogStyle);
|
|
|
|
auto *form = new QFormLayout(&dlg);
|
|
form->setContentsMargins(16, 16, 16, 16);
|
|
form->setSpacing(8);
|
|
|
|
auto *nameEdit = new QLineEdit();
|
|
nameEdit->setPlaceholderText(QStringLiteral("e.g. Desktop Audio"));
|
|
form->addRow(QStringLiteral("Name:"), nameEdit);
|
|
|
|
auto *loopbackCheck = new QCheckBox(QStringLiteral("Loopback from another node"));
|
|
form->addRow(loopbackCheck);
|
|
|
|
auto *targetCombo = new QComboBox();
|
|
targetCombo->setEnabled(false);
|
|
auto nodesResult = m_client->ListNodes();
|
|
if (nodesResult.ok()) {
|
|
for (const auto &node : nodesResult.value) {
|
|
if (node.media_class.find("Sink") != std::string::npos ||
|
|
node.media_class.find("Source") != std::string::npos) {
|
|
QString label = QString::fromStdString(
|
|
node.description.empty() ? node.name : node.description);
|
|
targetCombo->addItem(label, QString::fromStdString(node.name));
|
|
}
|
|
}
|
|
}
|
|
auto *targetLabel = new QLabel(QStringLiteral("Target Node:"));
|
|
targetLabel->setEnabled(false);
|
|
form->addRow(targetLabel, targetCombo);
|
|
|
|
connect(loopbackCheck, &QCheckBox::toggled, this, [=](bool checked) {
|
|
targetCombo->setEnabled(checked);
|
|
targetLabel->setEnabled(checked);
|
|
});
|
|
|
|
auto *buttons = new QDialogButtonBox(
|
|
QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
|
buttons->setStyleSheet(kButtonStyle);
|
|
connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept);
|
|
connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject);
|
|
form->addRow(buttons);
|
|
|
|
if (dlg.exec() != QDialog::Accepted)
|
|
return;
|
|
|
|
QString name = nameEdit->text().trimmed();
|
|
if (name.isEmpty()) {
|
|
QMessageBox::warning(this, QStringLiteral("Error"),
|
|
QStringLiteral("Name cannot be empty."));
|
|
return;
|
|
}
|
|
|
|
std::string nodeName = name.toStdString();
|
|
m_model->setPendingPosition(nodeName, scenePos);
|
|
|
|
warppipe::VirtualNodeOptions opts;
|
|
if (loopbackCheck->isChecked()) {
|
|
opts.behavior = warppipe::VirtualBehavior::kLoopback;
|
|
opts.target_node = targetCombo->currentData().toString().toStdString();
|
|
}
|
|
|
|
auto result = m_client->CreateVirtualSource(nodeName, opts);
|
|
if (!result.status.ok()) {
|
|
QMessageBox::warning(this, QStringLiteral("Error"),
|
|
QString::fromStdString(result.status.message));
|
|
return;
|
|
}
|
|
m_model->refreshFromClient();
|
|
}
|
|
|
|
void GraphEditorWidget::removeDefaultActions() {
|
|
const QList<QAction *> actions = m_view->actions();
|
|
for (QAction *action : actions) {
|
|
const QString text = action->text();
|
|
if (text.contains(QStringLiteral("Copy Selection")) ||
|
|
text.contains(QStringLiteral("Paste Selection")) ||
|
|
text.contains(QStringLiteral("Duplicate Selection")) ||
|
|
text.contains(QStringLiteral("Delete Selection"))) {
|
|
m_view->removeAction(action);
|
|
action->deleteLater();
|
|
}
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::deleteSelection() {
|
|
if (!m_scene) {
|
|
return;
|
|
}
|
|
|
|
const QList<QGraphicsItem *> items = m_scene->selectedItems();
|
|
QList<QtNodes::NodeId> virtualNodeIds;
|
|
bool hasSelectedConnections = false;
|
|
|
|
for (auto *item : items) {
|
|
if (auto *nodeObj =
|
|
qgraphicsitem_cast<QtNodes::NodeGraphicsObject *>(item)) {
|
|
const WarpNodeData *data = m_model->warpNodeData(nodeObj->nodeId());
|
|
if (!data)
|
|
continue;
|
|
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
|
|
if (type == WarpNodeType::kVirtualSink ||
|
|
type == WarpNodeType::kVirtualSource) {
|
|
virtualNodeIds.append(nodeObj->nodeId());
|
|
}
|
|
} else if (qgraphicsitem_cast<QtNodes::ConnectionGraphicsObject *>(
|
|
item)) {
|
|
hasSelectedConnections = true;
|
|
}
|
|
}
|
|
|
|
if (!virtualNodeIds.isEmpty()) {
|
|
m_scene->undoStack().push(
|
|
new DeleteVirtualNodeCommand(this, virtualNodeIds));
|
|
}
|
|
|
|
if (virtualNodeIds.isEmpty() && hasSelectedConnections) {
|
|
m_scene->undoStack().push(new QtNodes::DeleteCommand(m_scene));
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::copySelection() {
|
|
if (!m_scene || !m_client) {
|
|
return;
|
|
}
|
|
|
|
QJsonArray nodesJson;
|
|
std::unordered_set<std::string> selectedNames;
|
|
QPointF sum;
|
|
int count = 0;
|
|
|
|
const QList<QGraphicsItem *> items = m_scene->selectedItems();
|
|
for (auto *item : items) {
|
|
auto *nodeObj =
|
|
qgraphicsitem_cast<QtNodes::NodeGraphicsObject *>(item);
|
|
if (!nodeObj)
|
|
continue;
|
|
|
|
const WarpNodeData *data = m_model->warpNodeData(nodeObj->nodeId());
|
|
if (!data)
|
|
continue;
|
|
|
|
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
|
|
if (type != WarpNodeType::kVirtualSink &&
|
|
type != WarpNodeType::kVirtualSource)
|
|
continue;
|
|
|
|
QJsonObject nodeJson;
|
|
nodeJson[QStringLiteral("name")] =
|
|
QString::fromStdString(data->info.name);
|
|
nodeJson[QStringLiteral("media_class")] =
|
|
QString::fromStdString(data->info.media_class);
|
|
int channels = static_cast<int>(
|
|
std::max(data->inputPorts.size(), data->outputPorts.size()));
|
|
nodeJson[QStringLiteral("channels")] = channels > 0 ? channels : 2;
|
|
QPointF pos =
|
|
m_model->nodeData(nodeObj->nodeId(), QtNodes::NodeRole::Position)
|
|
.toPointF();
|
|
nodeJson[QStringLiteral("x")] = pos.x();
|
|
nodeJson[QStringLiteral("y")] = pos.y();
|
|
|
|
nodesJson.append(nodeJson);
|
|
selectedNames.insert(data->info.name);
|
|
sum += pos;
|
|
++count;
|
|
}
|
|
|
|
if (nodesJson.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
std::unordered_map<uint32_t, std::pair<std::string, std::string>> portOwner;
|
|
for (auto qtId : m_model->allNodeIds()) {
|
|
const WarpNodeData *data = m_model->warpNodeData(qtId);
|
|
if (!data || selectedNames.find(data->info.name) == selectedNames.end())
|
|
continue;
|
|
for (const auto &port : data->outputPorts) {
|
|
portOwner[port.id.value] = {data->info.name, port.name};
|
|
}
|
|
for (const auto &port : data->inputPorts) {
|
|
portOwner[port.id.value] = {data->info.name, port.name};
|
|
}
|
|
}
|
|
|
|
QJsonArray linksJson;
|
|
auto linksResult = m_client->ListLinks();
|
|
if (linksResult.ok()) {
|
|
for (const auto &link : linksResult.value) {
|
|
auto outIt = portOwner.find(link.output_port.value);
|
|
auto inIt = portOwner.find(link.input_port.value);
|
|
if (outIt != portOwner.end() && inIt != portOwner.end()) {
|
|
QJsonObject linkJson;
|
|
linkJson[QStringLiteral("source")] = QString::fromStdString(
|
|
outIt->second.first + ":" + outIt->second.second);
|
|
linkJson[QStringLiteral("target")] = QString::fromStdString(
|
|
inIt->second.first + ":" + inIt->second.second);
|
|
linksJson.append(linkJson);
|
|
}
|
|
}
|
|
}
|
|
|
|
QJsonObject root;
|
|
root[QStringLiteral("nodes")] = nodesJson;
|
|
root[QStringLiteral("links")] = linksJson;
|
|
root[QStringLiteral("center_x")] = count > 0 ? sum.x() / count : 0.0;
|
|
root[QStringLiteral("center_y")] = count > 0 ? sum.y() / count : 0.0;
|
|
root[QStringLiteral("version")] = 1;
|
|
|
|
m_clipboardJson = root;
|
|
|
|
QJsonDocument doc(root);
|
|
auto *mime = new QMimeData();
|
|
mime->setData(QStringLiteral("application/warppipe-virtual-graph"),
|
|
doc.toJson(QJsonDocument::Compact));
|
|
mime->setText(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)));
|
|
QGuiApplication::clipboard()->setMimeData(mime);
|
|
}
|
|
|
|
void GraphEditorWidget::pasteSelection(const QPointF &offset) {
|
|
if (!m_client || !m_model) {
|
|
return;
|
|
}
|
|
|
|
QJsonObject root;
|
|
const QMimeData *mime = QGuiApplication::clipboard()->mimeData();
|
|
if (mime &&
|
|
mime->hasFormat(
|
|
QStringLiteral("application/warppipe-virtual-graph"))) {
|
|
root = QJsonDocument::fromJson(
|
|
mime->data(QStringLiteral(
|
|
"application/warppipe-virtual-graph")))
|
|
.object();
|
|
} else if (!m_clipboardJson.isEmpty()) {
|
|
root = m_clipboardJson;
|
|
}
|
|
|
|
if (root.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
std::unordered_set<std::string> existingNames;
|
|
auto nodesResult = m_client->ListNodes();
|
|
if (nodesResult.ok()) {
|
|
for (const auto &node : nodesResult.value) {
|
|
existingNames.insert(node.name);
|
|
}
|
|
}
|
|
|
|
std::unordered_map<std::string, std::string> nameMap;
|
|
|
|
const QJsonArray nodesArray =
|
|
root[QStringLiteral("nodes")].toArray();
|
|
for (const auto &entry : nodesArray) {
|
|
QJsonObject nodeObj = entry.toObject();
|
|
std::string baseName =
|
|
nodeObj[QStringLiteral("name")].toString().toStdString();
|
|
std::string mediaClass =
|
|
nodeObj[QStringLiteral("media_class")].toString().toStdString();
|
|
double x = nodeObj[QStringLiteral("x")].toDouble();
|
|
double y = nodeObj[QStringLiteral("y")].toDouble();
|
|
|
|
if (baseName.empty())
|
|
continue;
|
|
|
|
std::string newName = baseName + " Copy";
|
|
int suffix = 2;
|
|
while (existingNames.count(newName)) {
|
|
newName = baseName + " Copy " + std::to_string(suffix++);
|
|
}
|
|
existingNames.insert(newName);
|
|
nameMap[baseName] = newName;
|
|
|
|
m_model->setPendingPosition(newName, QPointF(x, y) + offset);
|
|
|
|
bool isSink =
|
|
mediaClass == "Audio/Sink" || mediaClass == "Audio/Duplex";
|
|
if (isSink) {
|
|
m_client->CreateVirtualSink(newName);
|
|
} else {
|
|
m_client->CreateVirtualSource(newName);
|
|
}
|
|
}
|
|
|
|
const QJsonArray linksArray =
|
|
root[QStringLiteral("links")].toArray();
|
|
for (const auto &entry : linksArray) {
|
|
QJsonObject linkObj = entry.toObject();
|
|
std::string source =
|
|
linkObj[QStringLiteral("source")].toString().toStdString();
|
|
std::string target =
|
|
linkObj[QStringLiteral("target")].toString().toStdString();
|
|
|
|
auto splitKey = [](const std::string &s)
|
|
-> std::pair<std::string, std::string> {
|
|
auto pos = s.rfind(':');
|
|
if (pos == std::string::npos)
|
|
return {s, ""};
|
|
return {s.substr(0, pos), s.substr(pos + 1)};
|
|
};
|
|
|
|
auto [outName, outPort] = splitKey(source);
|
|
auto [inName, inPort] = splitKey(target);
|
|
|
|
auto outIt = nameMap.find(outName);
|
|
auto inIt = nameMap.find(inName);
|
|
if (outIt == nameMap.end() || inIt == nameMap.end())
|
|
continue;
|
|
|
|
PendingPasteLink pending;
|
|
pending.outNodeName = outIt->second;
|
|
pending.outPortName = outPort;
|
|
pending.inNodeName = inIt->second;
|
|
pending.inPortName = inPort;
|
|
m_pendingPasteLinks.push_back(pending);
|
|
}
|
|
|
|
m_model->refreshFromClient();
|
|
tryResolvePendingLinks();
|
|
}
|
|
|
|
void GraphEditorWidget::duplicateSelection() {
|
|
copySelection();
|
|
pasteSelection(QPointF(40, 40));
|
|
}
|
|
|
|
void GraphEditorWidget::tryResolvePendingLinks() {
|
|
if (m_pendingPasteLinks.empty() || !m_client) {
|
|
return;
|
|
}
|
|
|
|
auto nodesResult = m_client->ListNodes();
|
|
if (!nodesResult.ok()) {
|
|
return;
|
|
}
|
|
|
|
std::vector<PendingPasteLink> remaining;
|
|
|
|
for (const auto &pending : m_pendingPasteLinks) {
|
|
warppipe::PortId outPortId{0};
|
|
warppipe::PortId inPortId{0};
|
|
bool foundOut = false;
|
|
bool foundIn = false;
|
|
|
|
for (const auto &node : nodesResult.value) {
|
|
if (!foundOut && node.name == pending.outNodeName) {
|
|
auto portsResult = m_client->ListPorts(node.id);
|
|
if (portsResult.ok()) {
|
|
for (const auto &port : portsResult.value) {
|
|
if (!port.is_input && port.name == pending.outPortName) {
|
|
outPortId = port.id;
|
|
foundOut = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!foundIn && node.name == pending.inNodeName) {
|
|
auto portsResult = m_client->ListPorts(node.id);
|
|
if (portsResult.ok()) {
|
|
for (const auto &port : portsResult.value) {
|
|
if (port.is_input && port.name == pending.inPortName) {
|
|
inPortId = port.id;
|
|
foundIn = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (foundOut && foundIn) {
|
|
m_client->CreateLink(outPortId, inPortId, warppipe::LinkOptions{.linger = true});
|
|
} else {
|
|
remaining.push_back(pending);
|
|
}
|
|
}
|
|
|
|
m_pendingPasteLinks = remaining;
|
|
}
|
|
|
|
void GraphEditorWidget::saveLayoutWithViewState() {
|
|
WarpGraphModel::ViewState vs;
|
|
vs.scale = m_view->getScale();
|
|
QPointF center = m_view->mapToScene(m_view->viewport()->rect().center());
|
|
vs.centerX = center.x();
|
|
vs.centerY = center.y();
|
|
QList<int> sizes = m_splitter->sizes();
|
|
vs.splitterGraph = sizes.value(0, 1200);
|
|
vs.splitterSidebar = sizes.value(1, 320);
|
|
vs.connectionStyle = static_cast<int>(m_connectionStyle);
|
|
vs.zoomSensitivity = m_view->zoomSensitivity();
|
|
vs.zoomMin = m_zoomMinSlider->value() / 100.0;
|
|
vs.zoomMax = m_zoomMaxSlider->value() / 100.0;
|
|
vs.valid = true;
|
|
m_model->saveLayout(m_layoutPath, vs);
|
|
}
|
|
|
|
void GraphEditorWidget::restoreViewState() {
|
|
auto vs = m_model->savedViewState();
|
|
if (vs.valid) {
|
|
m_view->setupScale(vs.scale);
|
|
m_view->centerOn(QPointF(vs.centerX, vs.centerY));
|
|
if (vs.splitterGraph > 0 || vs.splitterSidebar > 0) {
|
|
m_splitter->setSizes({vs.splitterGraph, vs.splitterSidebar});
|
|
}
|
|
if (vs.connectionStyle == static_cast<int>(ConnectionStyleType::kSquare)) {
|
|
setConnectionStyle(ConnectionStyleType::kSquare);
|
|
}
|
|
if (vs.zoomSensitivity > 0.0) {
|
|
m_view->setZoomSensitivity(vs.zoomSensitivity);
|
|
int sliderVal = static_cast<int>((vs.zoomSensitivity - 1.0) * 100.0);
|
|
m_zoomSensSlider->setValue(sliderVal);
|
|
}
|
|
if (vs.zoomMin > 0.0) {
|
|
m_zoomMinSlider->setValue(static_cast<int>(vs.zoomMin * 100.0));
|
|
}
|
|
if (vs.zoomMax > 0.0) {
|
|
m_zoomMaxSlider->setValue(static_cast<int>(vs.zoomMax * 100.0));
|
|
}
|
|
} else {
|
|
m_view->zoomFitAll();
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::savePreset() {
|
|
QDir dir(m_presetDir);
|
|
if (!dir.exists())
|
|
dir.mkpath(".");
|
|
|
|
QString path = QFileDialog::getSaveFileName(
|
|
this, QStringLiteral("Save Preset"), m_presetDir,
|
|
QStringLiteral("JSON files (*.json)"));
|
|
if (path.isEmpty())
|
|
return;
|
|
if (!path.endsWith(QStringLiteral(".json"), Qt::CaseInsensitive))
|
|
path += QStringLiteral(".json");
|
|
|
|
if (PresetManager::savePreset(path, m_client, m_model)) {
|
|
if (auto *mw = qobject_cast<QMainWindow *>(window()))
|
|
mw->statusBar()->showMessage(
|
|
QStringLiteral("Preset saved: ") + QFileInfo(path).fileName(), 4000);
|
|
} else {
|
|
QMessageBox::warning(this, QStringLiteral("Error"),
|
|
QStringLiteral("Failed to save preset."));
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::loadPreset() {
|
|
QString path = QFileDialog::getOpenFileName(
|
|
this, QStringLiteral("Load Preset"), m_presetDir,
|
|
QStringLiteral("JSON files (*.json)"));
|
|
if (path.isEmpty())
|
|
return;
|
|
|
|
if (PresetManager::loadPreset(path, m_client, m_model)) {
|
|
if (auto *mw = qobject_cast<QMainWindow *>(window()))
|
|
mw->statusBar()->showMessage(
|
|
QStringLiteral("Preset loaded: ") + QFileInfo(path).fileName(), 4000);
|
|
} else {
|
|
QMessageBox::warning(this, QStringLiteral("Error"),
|
|
QStringLiteral("Failed to load preset."));
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::wireVolumeWidget(QtNodes::NodeId nodeId) {
|
|
auto widget =
|
|
m_model->nodeData(nodeId, QtNodes::NodeRole::Widget);
|
|
auto *w = widget.value<QWidget *>();
|
|
auto *vol = qobject_cast<NodeVolumeWidget *>(w);
|
|
if (!vol)
|
|
return;
|
|
|
|
auto capturedId = nodeId;
|
|
|
|
connect(vol, &NodeVolumeWidget::volumeChanged, this,
|
|
[this, capturedId](int value) {
|
|
auto state = m_model->nodeVolumeState(capturedId);
|
|
state.volume = sliderToVolume(value);
|
|
m_model->setNodeVolumeState(capturedId, state);
|
|
});
|
|
|
|
connect(vol, &NodeVolumeWidget::sliderReleased, this,
|
|
[this, capturedId, vol]() {
|
|
auto current = m_model->nodeVolumeState(capturedId);
|
|
WarpGraphModel::NodeVolumeState previous;
|
|
previous.volume = current.volume;
|
|
previous.mute = current.mute;
|
|
m_scene->undoStack().push(
|
|
new VolumeChangeCommand(m_model, capturedId, previous, current));
|
|
});
|
|
|
|
connect(vol, &NodeVolumeWidget::muteToggled, this,
|
|
[this, capturedId](bool muted) {
|
|
auto previous = m_model->nodeVolumeState(capturedId);
|
|
auto next = previous;
|
|
next.mute = muted;
|
|
m_model->setNodeVolumeState(capturedId, next);
|
|
m_scene->undoStack().push(
|
|
new VolumeChangeCommand(m_model, capturedId, previous, next));
|
|
});
|
|
}
|
|
|
|
void GraphEditorWidget::rebuildMixerStrips() {
|
|
if (!m_mixerContainer)
|
|
return;
|
|
|
|
auto *layout = m_mixerContainer->layout();
|
|
if (!layout)
|
|
return;
|
|
|
|
while (layout->count() > 0) {
|
|
auto *item = layout->takeAt(0);
|
|
if (item->widget())
|
|
item->widget()->deleteLater();
|
|
delete item;
|
|
}
|
|
m_mixerStrips.clear();
|
|
|
|
auto nodeIds = m_model->allNodeIds();
|
|
std::vector<QtNodes::NodeId> sorted(nodeIds.begin(), nodeIds.end());
|
|
std::sort(sorted.begin(), sorted.end());
|
|
|
|
for (auto nodeId : sorted) {
|
|
const WarpNodeData *data = m_model->warpNodeData(nodeId);
|
|
if (!data)
|
|
continue;
|
|
if (!nodeHasVolume(WarpGraphModel::classifyNode(data->info)))
|
|
continue;
|
|
|
|
auto *strip = new QWidget();
|
|
strip->setStyleSheet(QStringLiteral(
|
|
"QWidget { background: #24242a; border-radius: 4px; }"));
|
|
|
|
auto *stripLayout = new QHBoxLayout(strip);
|
|
stripLayout->setContentsMargins(6, 4, 6, 4);
|
|
stripLayout->setSpacing(6);
|
|
|
|
auto *label = new QLabel(
|
|
WarpGraphModel::classifyNode(data->info) == WarpNodeType::kApplication
|
|
? QString::fromStdString(
|
|
data->info.application_name.empty()
|
|
? data->info.name
|
|
: data->info.application_name)
|
|
: QString::fromStdString(
|
|
data->info.description.empty()
|
|
? data->info.name
|
|
: data->info.description));
|
|
label->setFixedWidth(120);
|
|
label->setStyleSheet(QStringLiteral(
|
|
"QLabel { color: #a0a8b6; font-size: 11px; background: transparent; }"));
|
|
label->setToolTip(QString::fromStdString(data->info.name));
|
|
|
|
auto *slider = new ClickSlider(Qt::Horizontal);
|
|
slider->setRange(0, 100);
|
|
auto state = m_model->nodeVolumeState(nodeId);
|
|
slider->setValue(volumeToSlider(state.volume));
|
|
slider->setStyleSheet(QStringLiteral(
|
|
"QSlider::groove:horizontal {"
|
|
" background: #1a1a1e; border-radius: 3px; height: 6px; }"
|
|
"QSlider::handle:horizontal {"
|
|
" background: #ecf0f6; border-radius: 5px;"
|
|
" width: 10px; margin: -4px 0; }"
|
|
"QSlider::sub-page:horizontal {"
|
|
" background: #4caf50; border-radius: 3px; }"));
|
|
|
|
auto *muteBtn = new QToolButton();
|
|
muteBtn->setText(QStringLiteral("M"));
|
|
muteBtn->setCheckable(true);
|
|
muteBtn->setChecked(state.mute);
|
|
muteBtn->setFixedSize(22, 22);
|
|
muteBtn->setStyleSheet(QStringLiteral(
|
|
"QToolButton {"
|
|
" background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;"
|
|
" border-radius: 4px; font-weight: bold; font-size: 11px; }"
|
|
"QToolButton:checked {"
|
|
" background: #b03030; color: #ecf0f6; border: 1px solid #d04040; }"
|
|
"QToolButton:hover { background: #3a3a44; }"
|
|
"QToolButton:checked:hover { background: #c04040; }"));
|
|
|
|
stripLayout->addWidget(label);
|
|
stripLayout->addWidget(slider, 1);
|
|
stripLayout->addWidget(muteBtn);
|
|
|
|
auto capturedId = nodeId;
|
|
|
|
connect(slider, &QSlider::valueChanged, this,
|
|
[this, capturedId](int value) {
|
|
auto s = m_model->nodeVolumeState(capturedId);
|
|
s.volume = sliderToVolume(value);
|
|
m_model->setNodeVolumeState(capturedId, s);
|
|
});
|
|
|
|
connect(slider, &QSlider::sliderReleased, this,
|
|
[this, capturedId]() {
|
|
auto current = m_model->nodeVolumeState(capturedId);
|
|
m_scene->undoStack().push(
|
|
new VolumeChangeCommand(m_model, capturedId, current, current));
|
|
});
|
|
|
|
connect(muteBtn, &QToolButton::toggled, this,
|
|
[this, capturedId](bool muted) {
|
|
auto prev = m_model->nodeVolumeState(capturedId);
|
|
auto next = prev;
|
|
next.mute = muted;
|
|
m_model->setNodeVolumeState(capturedId, next);
|
|
m_scene->undoStack().push(
|
|
new VolumeChangeCommand(m_model, capturedId, prev, next));
|
|
});
|
|
|
|
connect(m_model, &WarpGraphModel::nodeVolumeChanged, slider,
|
|
[slider, muteBtn, capturedId](QtNodes::NodeId id,
|
|
WarpGraphModel::NodeVolumeState,
|
|
WarpGraphModel::NodeVolumeState cur) {
|
|
if (id != capturedId)
|
|
return;
|
|
QSignalBlocker sb(slider);
|
|
QSignalBlocker mb(muteBtn);
|
|
slider->setValue(volumeToSlider(cur.volume));
|
|
muteBtn->setChecked(cur.mute);
|
|
});
|
|
|
|
layout->addWidget(strip);
|
|
m_mixerStrips[nodeId] = strip;
|
|
}
|
|
|
|
static_cast<QVBoxLayout *>(layout)->addStretch();
|
|
}
|
|
|
|
void GraphEditorWidget::updateMeters() {
|
|
if (!m_client)
|
|
return;
|
|
|
|
auto master = m_client->MeterPeak();
|
|
if (master.ok()) {
|
|
m_masterMeterL->setLevel(master.value.peak_left);
|
|
m_masterMeterR->setLevel(master.value.peak_right);
|
|
}
|
|
|
|
for (auto &[nodeId, row] : m_nodeMeters) {
|
|
const WarpNodeData *data = m_model->warpNodeData(nodeId);
|
|
if (!data || !row.meter)
|
|
continue;
|
|
auto peak = m_client->NodeMeterPeak(data->info.id);
|
|
if (peak.ok()) {
|
|
row.meter->setLevel(
|
|
std::max(peak.value.peak_left, peak.value.peak_right));
|
|
}
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::rebuildNodeMeters() {
|
|
if (!m_nodeMeterContainer || !m_client)
|
|
return;
|
|
|
|
auto *layout = m_nodeMeterContainer->layout();
|
|
if (!layout)
|
|
return;
|
|
|
|
std::unordered_map<uint32_t, bool> old_pw_ids;
|
|
for (const auto &[nid, row] : m_nodeMeters) {
|
|
const WarpNodeData *d = m_model->warpNodeData(nid);
|
|
if (d)
|
|
old_pw_ids[d->info.id.value] = true;
|
|
}
|
|
|
|
while (layout->count() > 0) {
|
|
auto *item = layout->takeAt(0);
|
|
if (item->widget())
|
|
item->widget()->deleteLater();
|
|
delete item;
|
|
}
|
|
m_nodeMeters.clear();
|
|
|
|
auto nodeIds = m_model->allNodeIds();
|
|
std::vector<QtNodes::NodeId> sorted(nodeIds.begin(), nodeIds.end());
|
|
std::sort(sorted.begin(), sorted.end());
|
|
|
|
std::unordered_map<uint32_t, bool> new_pw_ids;
|
|
for (auto nodeId : sorted) {
|
|
const WarpNodeData *data = m_model->warpNodeData(nodeId);
|
|
if (!data)
|
|
continue;
|
|
|
|
new_pw_ids[data->info.id.value] = true;
|
|
m_client->EnsureNodeMeter(data->info.id);
|
|
|
|
auto *row = new QWidget();
|
|
auto *rowLayout = new QHBoxLayout(row);
|
|
rowLayout->setContentsMargins(0, 0, 0, 0);
|
|
rowLayout->setSpacing(6);
|
|
|
|
auto *label = new QLabel(
|
|
WarpGraphModel::classifyNode(data->info) == WarpNodeType::kApplication
|
|
? QString::fromStdString(
|
|
data->info.application_name.empty()
|
|
? data->info.name
|
|
: data->info.application_name)
|
|
: QString::fromStdString(
|
|
data->info.description.empty()
|
|
? data->info.name
|
|
: data->info.description));
|
|
label->setStyleSheet(QStringLiteral(
|
|
"QLabel { color: #a0a8b6; font-size: 11px; background: transparent; }"));
|
|
label->setToolTip(QString::fromStdString(data->info.name));
|
|
|
|
auto *meter = new AudioLevelMeter();
|
|
meter->setFixedWidth(26);
|
|
meter->setMinimumHeight(70);
|
|
|
|
rowLayout->addWidget(label, 1);
|
|
rowLayout->addWidget(meter);
|
|
|
|
layout->addWidget(row);
|
|
|
|
NodeMeterRow meterRow;
|
|
meterRow.widget = row;
|
|
meterRow.meter = meter;
|
|
meterRow.label = label;
|
|
m_nodeMeters[nodeId] = meterRow;
|
|
}
|
|
|
|
static_cast<QVBoxLayout *>(layout)->addStretch();
|
|
|
|
for (const auto &[pw_id, _] : old_pw_ids) {
|
|
if (new_pw_ids.find(pw_id) == new_pw_ids.end()) {
|
|
m_client->DisableNodeMeter(warppipe::NodeId{pw_id});
|
|
}
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::rebuildRulesList() {
|
|
if (!m_rulesContainer || !m_client)
|
|
return;
|
|
|
|
auto *layout = m_rulesContainer->layout();
|
|
if (!layout)
|
|
return;
|
|
|
|
while (layout->count() > 0) {
|
|
auto *item = layout->takeAt(0);
|
|
if (item->widget())
|
|
item->widget()->deleteLater();
|
|
delete item;
|
|
}
|
|
|
|
const QString labelStyle = QStringLiteral(
|
|
"QLabel { color: #a0a8b6; font-size: 11px; background: transparent; }");
|
|
const QString valueStyle = QStringLiteral(
|
|
"QLabel { color: #ecf0f6; font-size: 12px; background: transparent; }");
|
|
const QString btnStyle = QStringLiteral(
|
|
"QPushButton { background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;"
|
|
" border-radius: 4px; padding: 6px 12px; }"
|
|
"QPushButton:hover { background: #3a3a44; }"
|
|
"QPushButton:pressed { background: #44444e; }");
|
|
const QString editBtnStyle = QStringLiteral(
|
|
"QPushButton { background: transparent; color: #5070a0; border: none;"
|
|
" font-size: 14px; font-weight: bold; padding: 2px 6px; }"
|
|
"QPushButton:hover { color: #70a0e0; }");
|
|
const QString delBtnStyle = QStringLiteral(
|
|
"QPushButton { background: transparent; color: #a05050; border: none;"
|
|
" font-size: 14px; font-weight: bold; padding: 2px 6px; }"
|
|
"QPushButton:hover { color: #e05050; }");
|
|
|
|
auto *header = new QLabel(QStringLiteral("ROUTING RULES"));
|
|
header->setStyleSheet(QStringLiteral(
|
|
"QLabel { color: #a0a8b6; font-size: 11px; font-weight: bold;"
|
|
" background: transparent; }"));
|
|
layout->addWidget(header);
|
|
|
|
auto rulesResult = m_client->ListRouteRules();
|
|
if (rulesResult.ok()) {
|
|
for (const auto &rule : rulesResult.value) {
|
|
auto *card = new QWidget();
|
|
card->setStyleSheet(QStringLiteral(
|
|
"QWidget { background: #24242a; border-radius: 4px; }"));
|
|
auto *cardLayout = new QHBoxLayout(card);
|
|
cardLayout->setContentsMargins(8, 6, 4, 6);
|
|
cardLayout->setSpacing(8);
|
|
|
|
QString matchText;
|
|
if (!rule.match.application_name.empty())
|
|
matchText += QStringLiteral("app: ") +
|
|
QString::fromStdString(rule.match.application_name);
|
|
if (!rule.match.process_binary.empty()) {
|
|
if (!matchText.isEmpty()) matchText += QStringLiteral(", ");
|
|
matchText += QStringLiteral("bin: ") +
|
|
QString::fromStdString(rule.match.process_binary);
|
|
}
|
|
if (!rule.match.media_role.empty()) {
|
|
if (!matchText.isEmpty()) matchText += QStringLiteral(", ");
|
|
matchText += QStringLiteral("role: ") +
|
|
QString::fromStdString(rule.match.media_role);
|
|
}
|
|
|
|
auto *infoLayout = new QVBoxLayout();
|
|
infoLayout->setContentsMargins(0, 0, 0, 0);
|
|
infoLayout->setSpacing(2);
|
|
|
|
auto *matchLabel = new QLabel(matchText);
|
|
matchLabel->setStyleSheet(valueStyle);
|
|
infoLayout->addWidget(matchLabel);
|
|
|
|
auto *targetLabel = new QLabel(
|
|
QString(QChar(0x2192)) + QStringLiteral(" ") +
|
|
QString::fromStdString(rule.target_node));
|
|
targetLabel->setStyleSheet(labelStyle);
|
|
infoLayout->addWidget(targetLabel);
|
|
|
|
cardLayout->addLayout(infoLayout, 1);
|
|
|
|
auto *editBtn = new QPushButton(QString(QChar(0x270E)));
|
|
editBtn->setFixedSize(24, 24);
|
|
editBtn->setStyleSheet(editBtnStyle);
|
|
warppipe::RuleId ruleId = rule.id;
|
|
std::string ruleApp = rule.match.application_name;
|
|
std::string ruleBin = rule.match.process_binary;
|
|
std::string ruleRole = rule.match.media_role;
|
|
std::string ruleTarget = rule.target_node;
|
|
connect(editBtn, &QPushButton::clicked, this,
|
|
[this, ruleApp, ruleBin, ruleRole, ruleTarget, ruleId]() {
|
|
showAddRuleDialog(ruleApp, ruleBin, ruleRole, ruleTarget, ruleId);
|
|
});
|
|
cardLayout->addWidget(editBtn);
|
|
|
|
auto *delBtn = new QPushButton(QString(QChar(0x2715)));
|
|
delBtn->setFixedSize(24, 24);
|
|
delBtn->setStyleSheet(delBtnStyle);
|
|
connect(delBtn, &QPushButton::clicked, this, [this, ruleId]() {
|
|
m_client->RemoveRouteRule(ruleId);
|
|
rebuildRulesList();
|
|
});
|
|
cardLayout->addWidget(delBtn);
|
|
|
|
layout->addWidget(card);
|
|
}
|
|
}
|
|
|
|
auto *addBtn = new QPushButton(QStringLiteral("Add Rule..."));
|
|
addBtn->setStyleSheet(btnStyle);
|
|
connect(addBtn, &QPushButton::clicked, this,
|
|
[this]() { showAddRuleDialog(); });
|
|
layout->addWidget(addBtn);
|
|
|
|
static_cast<QVBoxLayout *>(layout)->addStretch();
|
|
}
|
|
|
|
void GraphEditorWidget::setConnectionStyle(ConnectionStyleType style) {
|
|
if (style == m_connectionStyle)
|
|
return;
|
|
m_connectionStyle = style;
|
|
|
|
if (style == ConnectionStyleType::kSquare) {
|
|
m_scene->setConnectionPainter(std::make_unique<SquareConnectionPainter>());
|
|
} else {
|
|
m_scene->setConnectionPainter(
|
|
std::make_unique<QtNodes::DefaultConnectionPainter>());
|
|
}
|
|
|
|
for (auto *item : m_scene->items()) {
|
|
item->update();
|
|
}
|
|
|
|
scheduleSaveLayout();
|
|
}
|
|
|
|
void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp,
|
|
const std::string &prefillBin,
|
|
const std::string &prefillRole,
|
|
const std::string &prefillTarget,
|
|
warppipe::RuleId editRuleId) {
|
|
if (!m_client)
|
|
return;
|
|
|
|
bool editing = editRuleId.value != 0;
|
|
|
|
QDialog dlg(this);
|
|
dlg.setWindowTitle(editing ? QStringLiteral("Edit Routing Rule")
|
|
: QStringLiteral("Add Routing Rule"));
|
|
dlg.setStyleSheet(QStringLiteral(
|
|
"QDialog { background: #1e1e22; }"
|
|
"QLabel { color: #ecf0f6; }"
|
|
"QLineEdit { background: #2a2a32; color: #ecf0f6; border: 1px solid #3a3a44;"
|
|
" border-radius: 4px; padding: 4px 8px; }"
|
|
"QComboBox { background: #2a2a32; color: #ecf0f6; border: 1px solid #3a3a44;"
|
|
" border-radius: 4px; padding: 4px 8px; }"
|
|
"QComboBox::drop-down { border: none; }"
|
|
"QComboBox QAbstractItemView { background: #2a2a32; color: #ecf0f6;"
|
|
" selection-background-color: #3a3a44; }"));
|
|
|
|
auto *form = new QFormLayout(&dlg);
|
|
form->setContentsMargins(16, 16, 16, 16);
|
|
form->setSpacing(8);
|
|
|
|
auto *appNameEdit = new QLineEdit();
|
|
appNameEdit->setPlaceholderText(QStringLiteral("e.g. Firefox"));
|
|
if (!prefillApp.empty())
|
|
appNameEdit->setText(QString::fromStdString(prefillApp));
|
|
form->addRow(QStringLiteral("Application Name:"), appNameEdit);
|
|
|
|
auto *processBinEdit = new QLineEdit();
|
|
processBinEdit->setPlaceholderText(QStringLiteral("e.g. firefox"));
|
|
if (!prefillBin.empty())
|
|
processBinEdit->setText(QString::fromStdString(prefillBin));
|
|
form->addRow(QStringLiteral("Process Binary:"), processBinEdit);
|
|
|
|
auto *mediaRoleEdit = new QLineEdit();
|
|
mediaRoleEdit->setPlaceholderText(QStringLiteral("e.g. Music"));
|
|
if (!prefillRole.empty())
|
|
mediaRoleEdit->setText(QString::fromStdString(prefillRole));
|
|
form->addRow(QStringLiteral("Media Role:"), mediaRoleEdit);
|
|
|
|
auto *targetCombo = new QComboBox();
|
|
auto nodesResult = m_client->ListNodes();
|
|
if (nodesResult.ok()) {
|
|
for (const auto &node : nodesResult.value) {
|
|
if (node.media_class.find("Sink") != std::string::npos) {
|
|
QString label = QString::fromStdString(
|
|
node.description.empty() ? node.name : node.description);
|
|
targetCombo->addItem(label, QString::fromStdString(node.name));
|
|
}
|
|
}
|
|
}
|
|
if (!prefillTarget.empty()) {
|
|
int idx = targetCombo->findData(QString::fromStdString(prefillTarget));
|
|
if (idx >= 0)
|
|
targetCombo->setCurrentIndex(idx);
|
|
}
|
|
form->addRow(QStringLiteral("Target Node:"), targetCombo);
|
|
|
|
auto *buttons = new QDialogButtonBox(
|
|
QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
|
buttons->setStyleSheet(QStringLiteral(
|
|
"QPushButton { background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;"
|
|
" border-radius: 4px; padding: 6px 16px; }"
|
|
"QPushButton:hover { background: #3a3a44; }"));
|
|
connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept);
|
|
connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject);
|
|
form->addRow(buttons);
|
|
|
|
if (dlg.exec() != QDialog::Accepted)
|
|
return;
|
|
|
|
std::string appName = appNameEdit->text().trimmed().toStdString();
|
|
std::string procBin = processBinEdit->text().trimmed().toStdString();
|
|
std::string role = mediaRoleEdit->text().trimmed().toStdString();
|
|
std::string target = targetCombo->currentData().toString().toStdString();
|
|
|
|
if (appName.empty() && procBin.empty() && role.empty()) {
|
|
QMessageBox::warning(this, QStringLiteral("Invalid Rule"),
|
|
QStringLiteral("At least one match field must be filled."));
|
|
return;
|
|
}
|
|
if (target.empty()) {
|
|
QMessageBox::warning(this, QStringLiteral("Invalid Rule"),
|
|
QStringLiteral("A target node must be selected."));
|
|
return;
|
|
}
|
|
|
|
if (editing) {
|
|
m_client->RemoveRouteRule(editRuleId);
|
|
}
|
|
|
|
warppipe::RouteRule rule;
|
|
rule.match.application_name = appName;
|
|
rule.match.process_binary = procBin;
|
|
rule.match.media_role = role;
|
|
rule.target_node = target;
|
|
m_client->AddRouteRule(rule);
|
|
rebuildRulesList();
|
|
}
|
|
|
|
void GraphEditorWidget::onSelectionChanged() {
|
|
auto items = m_scene->selectedItems();
|
|
|
|
QtNodes::NodeId selected = 0;
|
|
for (QGraphicsItem *item : items) {
|
|
if (auto *ngo =
|
|
qgraphicsitem_cast<QtNodes::NodeGraphicsObject *>(item)) {
|
|
selected = ngo->nodeId();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (selected == m_selectedNodeId)
|
|
return;
|
|
|
|
if (selected != 0) {
|
|
m_selectedNodeId = selected;
|
|
updateNodeDetailsPanel(selected);
|
|
m_sidebar->setCurrentWidget(m_nodeDetailsScroll);
|
|
} else {
|
|
m_selectedNodeId = 0;
|
|
clearNodeDetailsPanel();
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::clearNodeDetailsPanel() {
|
|
delete m_nodeDetailsScroll->takeWidget();
|
|
m_nodeDetailsContainer = new QWidget();
|
|
m_nodeDetailsContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;"));
|
|
auto *layout = new QVBoxLayout(m_nodeDetailsContainer);
|
|
layout->setContentsMargins(8, 8, 8, 8);
|
|
layout->setSpacing(6);
|
|
auto *label = new QLabel(QStringLiteral("Select a node to view details"));
|
|
label->setStyleSheet(QStringLiteral(
|
|
"color: #6a6a7a; font-style: italic; background: transparent;"));
|
|
label->setAlignment(Qt::AlignCenter);
|
|
label->setWordWrap(true);
|
|
layout->addStretch();
|
|
layout->addWidget(label);
|
|
layout->addStretch();
|
|
m_nodeDetailsScroll->setWidget(m_nodeDetailsContainer);
|
|
}
|
|
|
|
static QLabel *makeDetailHeader(const QString &text) {
|
|
auto *label = new QLabel(text);
|
|
label->setStyleSheet(QStringLiteral(
|
|
"QLabel { color: #a0a8b6; font-size: 11px; font-weight: bold;"
|
|
" background: transparent; }"));
|
|
return label;
|
|
}
|
|
|
|
static QLabel *makeDetailValue(const QString &text) {
|
|
auto *label = new QLabel(text);
|
|
label->setStyleSheet(QStringLiteral(
|
|
"QLabel { color: #ecf0f6; font-size: 12px; background: transparent; }"));
|
|
label->setWordWrap(true);
|
|
label->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
|
return label;
|
|
}
|
|
|
|
void GraphEditorWidget::updateNodeDetailsPanel(QtNodes::NodeId nodeId) {
|
|
const WarpNodeData *data = m_model->warpNodeData(nodeId);
|
|
if (!data)
|
|
return;
|
|
|
|
const auto &info = data->info;
|
|
WarpNodeType type = WarpGraphModel::classifyNode(info);
|
|
bool isVirtual = type == WarpNodeType::kVirtualSink ||
|
|
type == WarpNodeType::kVirtualSource;
|
|
|
|
delete m_nodeDetailsScroll->takeWidget();
|
|
m_nodeDetailsContainer = new QWidget();
|
|
m_nodeDetailsContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;"));
|
|
auto *layout = new QVBoxLayout(m_nodeDetailsContainer);
|
|
layout->setContentsMargins(8, 8, 8, 8);
|
|
layout->setSpacing(4);
|
|
|
|
auto addField = [&](const QString &header, const QString &value) {
|
|
if (value.isEmpty())
|
|
return;
|
|
layout->addSpacing(4);
|
|
layout->addWidget(makeDetailHeader(header));
|
|
layout->addWidget(makeDetailValue(value));
|
|
};
|
|
|
|
QString typeLabel;
|
|
switch (type) {
|
|
case WarpNodeType::kHardwareSink: typeLabel = QStringLiteral("Hardware Sink"); break;
|
|
case WarpNodeType::kHardwareSource: typeLabel = QStringLiteral("Hardware Source"); break;
|
|
case WarpNodeType::kVirtualSink: typeLabel = QStringLiteral("Virtual Sink"); break;
|
|
case WarpNodeType::kVirtualSource: typeLabel = QStringLiteral("Virtual Source"); break;
|
|
case WarpNodeType::kApplication: typeLabel = QStringLiteral("Application"); break;
|
|
case WarpNodeType::kVideoSource: typeLabel = QStringLiteral("Video Source"); break;
|
|
case WarpNodeType::kVideoSink: typeLabel = QStringLiteral("Video Sink"); break;
|
|
default: typeLabel = QStringLiteral("Unknown"); break;
|
|
}
|
|
|
|
auto *titleLabel = new QLabel(QString::fromStdString(
|
|
info.description.empty() ? info.name : info.description));
|
|
titleLabel->setStyleSheet(QStringLiteral(
|
|
"QLabel { color: #ecf0f6; font-size: 14px; font-weight: bold;"
|
|
" background: transparent; }"));
|
|
titleLabel->setWordWrap(true);
|
|
layout->addWidget(titleLabel);
|
|
layout->addSpacing(2);
|
|
|
|
auto *typeBadge = new QLabel(typeLabel);
|
|
typeBadge->setStyleSheet(QStringLiteral(
|
|
"QLabel { color: #1a1a1e; background: %1; font-size: 10px;"
|
|
" font-weight: bold; border-radius: 3px; padding: 2px 6px; }")
|
|
.arg(isVirtual ? QStringLiteral("#4caf50")
|
|
: QStringLiteral("#6a7a8a")));
|
|
typeBadge->setFixedHeight(18);
|
|
typeBadge->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Fixed);
|
|
layout->addWidget(typeBadge);
|
|
|
|
static const QString kEditStyle = QStringLiteral(
|
|
"QLineEdit { background: #2a2a32; color: #ecf0f6;"
|
|
" border: 1px solid #3a3a44; border-radius: 4px; padding: 4px 8px; }");
|
|
static const QString kComboStyle = QStringLiteral(
|
|
"QComboBox { background: #2a2a32; color: #ecf0f6;"
|
|
" border: 1px solid #3a3a44; border-radius: 4px; padding: 4px 8px; }"
|
|
"QComboBox::drop-down { border: none; }"
|
|
"QComboBox QAbstractItemView { background: #2a2a32; color: #ecf0f6;"
|
|
" selection-background-color: #3a3a44; }");
|
|
static const QString kCheckStyle = QStringLiteral(
|
|
"QCheckBox { color: #ecf0f6; background: transparent; }"
|
|
"QCheckBox::indicator { width: 16px; height: 16px; }");
|
|
static const QString kBtnStyle = QStringLiteral(
|
|
"QPushButton { background: #2e2e36; color: #ecf0f6;"
|
|
" border: 1px solid #3a3a44; border-radius: 4px;"
|
|
" padding: 6px 16px; font-weight: bold; }"
|
|
"QPushButton:hover { background: #3a3a44; }");
|
|
static const QString kDeleteBtnStyle = QStringLiteral(
|
|
"QPushButton { background: #b03030; color: #ecf0f6;"
|
|
" border: 1px solid #d04040; border-radius: 4px;"
|
|
" padding: 6px 16px; font-weight: bold; }"
|
|
"QPushButton:hover { background: #c04040; }");
|
|
|
|
if (isVirtual) {
|
|
bool isSource = type == WarpNodeType::kVirtualSource;
|
|
auto vnResult = m_client->GetVirtualNodeInfo(info.id);
|
|
|
|
layout->addSpacing(4);
|
|
layout->addWidget(makeDetailHeader(QStringLiteral("NAME")));
|
|
auto *nameEdit = new QLineEdit(QString::fromStdString(info.name));
|
|
nameEdit->setStyleSheet(kEditStyle);
|
|
layout->addWidget(nameEdit);
|
|
|
|
QCheckBox *loopbackCheck = nullptr;
|
|
QComboBox *targetCombo = nullptr;
|
|
|
|
if (isSource) {
|
|
layout->addSpacing(8);
|
|
loopbackCheck = new QCheckBox(QStringLiteral("Loopback from another node"));
|
|
loopbackCheck->setStyleSheet(kCheckStyle);
|
|
layout->addWidget(loopbackCheck);
|
|
|
|
layout->addSpacing(4);
|
|
layout->addWidget(makeDetailHeader(QStringLiteral("TARGET NODE")));
|
|
targetCombo = new QComboBox();
|
|
targetCombo->setStyleSheet(kComboStyle);
|
|
targetCombo->setEnabled(false);
|
|
auto nodesResult = m_client->ListNodes();
|
|
if (nodesResult.ok()) {
|
|
for (const auto &node : nodesResult.value) {
|
|
if (node.id.value == info.id.value)
|
|
continue;
|
|
if (node.media_class.find("Sink") != std::string::npos ||
|
|
node.media_class.find("Source") != std::string::npos) {
|
|
QString label = QString::fromStdString(
|
|
node.description.empty() ? node.name : node.description);
|
|
targetCombo->addItem(label, QString::fromStdString(node.name));
|
|
}
|
|
}
|
|
}
|
|
layout->addWidget(targetCombo);
|
|
|
|
if (vnResult.ok() && vnResult.value.loopback) {
|
|
loopbackCheck->setChecked(true);
|
|
int idx = targetCombo->findData(
|
|
QString::fromStdString(vnResult.value.target_node));
|
|
if (idx >= 0)
|
|
targetCombo->setCurrentIndex(idx);
|
|
}
|
|
|
|
connect(loopbackCheck, &QCheckBox::toggled, targetCombo,
|
|
&QWidget::setEnabled);
|
|
}
|
|
|
|
layout->addSpacing(12);
|
|
auto *applyBtn = new QPushButton(QStringLiteral("Apply Changes"));
|
|
applyBtn->setStyleSheet(kBtnStyle);
|
|
connect(applyBtn, &QPushButton::clicked, this,
|
|
[this, nodeId, nameEdit, loopbackCheck, targetCombo, isSource]() {
|
|
const WarpNodeData *d = m_model->warpNodeData(nodeId);
|
|
if (!d) return;
|
|
|
|
QString newName = nameEdit->text().trimmed();
|
|
if (newName.isEmpty()) {
|
|
QMessageBox::warning(this, QStringLiteral("Error"),
|
|
QStringLiteral("Name cannot be empty."));
|
|
return;
|
|
}
|
|
|
|
QPointF pos = m_model->nodeData(nodeId,
|
|
QtNodes::NodeRole::Position).toPointF();
|
|
|
|
auto removeStatus = m_client->RemoveNode(d->info.id);
|
|
if (!removeStatus.ok()) {
|
|
QMessageBox::warning(this, QStringLiteral("Error"),
|
|
QString::fromStdString(removeStatus.message));
|
|
return;
|
|
}
|
|
|
|
std::string nodeName = newName.toStdString();
|
|
m_model->setPendingPosition(nodeName, pos);
|
|
|
|
warppipe::Status status;
|
|
if (isSource) {
|
|
warppipe::VirtualNodeOptions opts;
|
|
if (loopbackCheck && loopbackCheck->isChecked() && targetCombo) {
|
|
opts.behavior = warppipe::VirtualBehavior::kLoopback;
|
|
opts.target_node =
|
|
targetCombo->currentData().toString().toStdString();
|
|
}
|
|
auto result = m_client->CreateVirtualSource(nodeName, opts);
|
|
status = result.status;
|
|
} else {
|
|
auto result = m_client->CreateVirtualSink(nodeName);
|
|
status = result.status;
|
|
}
|
|
|
|
if (!status.ok()) {
|
|
QMessageBox::warning(this, QStringLiteral("Error"),
|
|
QString::fromStdString(status.message));
|
|
m_model->refreshFromClient();
|
|
return;
|
|
}
|
|
|
|
m_selectedNodeId = 0;
|
|
m_model->refreshFromClient();
|
|
|
|
for (auto nid : m_model->allNodeIds()) {
|
|
const WarpNodeData *nd = m_model->warpNodeData(nid);
|
|
if (nd && nd->info.name == nodeName) {
|
|
m_selectedNodeId = nid;
|
|
updateNodeDetailsPanel(nid);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (auto *mw = qobject_cast<QMainWindow *>(window()))
|
|
mw->statusBar()->showMessage(
|
|
QStringLiteral("Virtual node updated"), 4000);
|
|
});
|
|
layout->addWidget(applyBtn);
|
|
|
|
layout->addSpacing(8);
|
|
auto *deleteBtn = new QPushButton(QStringLiteral("Delete Node"));
|
|
deleteBtn->setStyleSheet(kDeleteBtnStyle);
|
|
connect(deleteBtn, &QPushButton::clicked, this, [this, nodeId]() {
|
|
const WarpNodeData *d = m_model->warpNodeData(nodeId);
|
|
if (!d) return;
|
|
auto status = m_client->RemoveNode(d->info.id);
|
|
if (status.ok()) {
|
|
m_model->refreshFromClient();
|
|
clearNodeDetailsPanel();
|
|
m_selectedNodeId = 0;
|
|
} else {
|
|
QMessageBox::warning(this, QStringLiteral("Error"),
|
|
QString::fromStdString(status.message));
|
|
}
|
|
});
|
|
layout->addWidget(deleteBtn);
|
|
} else {
|
|
addField(QStringLiteral("NAME"), QString::fromStdString(info.name));
|
|
if (!info.description.empty() && info.description != info.name)
|
|
addField(QStringLiteral("DESCRIPTION"),
|
|
QString::fromStdString(info.description));
|
|
addField(QStringLiteral("MEDIA CLASS"),
|
|
QString::fromStdString(info.media_class));
|
|
if (!info.application_name.empty())
|
|
addField(QStringLiteral("APPLICATION"),
|
|
QString::fromStdString(info.application_name));
|
|
if (!info.process_binary.empty())
|
|
addField(QStringLiteral("PROCESS"),
|
|
QString::fromStdString(info.process_binary));
|
|
if (!info.media_role.empty())
|
|
addField(QStringLiteral("MEDIA ROLE"),
|
|
QString::fromStdString(info.media_role));
|
|
}
|
|
|
|
addField(QStringLiteral("NODE ID"),
|
|
QString::number(info.id.value));
|
|
|
|
if (!data->inputPorts.empty()) {
|
|
layout->addSpacing(8);
|
|
layout->addWidget(makeDetailHeader(QStringLiteral("INPUT PORTS")));
|
|
for (const auto &port : data->inputPorts) {
|
|
layout->addWidget(makeDetailValue(
|
|
QStringLiteral(" ") + QString::fromStdString(port.name)));
|
|
}
|
|
}
|
|
|
|
if (!data->outputPorts.empty()) {
|
|
layout->addSpacing(8);
|
|
layout->addWidget(makeDetailHeader(QStringLiteral("OUTPUT PORTS")));
|
|
for (const auto &port : data->outputPorts) {
|
|
layout->addWidget(makeDetailValue(
|
|
QStringLiteral(" ") + QString::fromStdString(port.name)));
|
|
}
|
|
}
|
|
|
|
layout->addStretch();
|
|
m_nodeDetailsScroll->setWidget(m_nodeDetailsContainer);
|
|
}
|