warp-pipe/gui/GraphEditorWidget.cpp
2026-02-06 09:15:25 -07:00

2256 lines
79 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);
connect(m_model, &WarpGraphModel::beginBatchUpdate, this, [this]() {
m_scene->setItemIndexMethod(QGraphicsScene::NoIndex);
});
connect(m_model, &WarpGraphModel::endBatchUpdate, this, [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);
}
m_meterTimer->stop();
m_refreshTimer->stop();
m_changeTimer->stop();
m_saveTimer->stop();
}
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 *detailsAction = menu.addAction(QStringLiteral("Node Details"));
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 == detailsAction) {
m_selectedNodeId = qtNodeId;
updateNodeDetailsPanel(qtNodeId);
m_sidebar->setCurrentWidget(m_nodeDetailsScroll);
} 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;
m_selectedNodeId = selected;
if (selected != 0 && m_sidebar->currentWidget() == m_nodeDetailsScroll) {
updateNodeDetailsPanel(selected);
} else if (selected == 0 && m_sidebar->currentWidget() == m_nodeDetailsScroll) {
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);
connect(loopbackCheck, &QCheckBox::toggled, targetCombo,
&QWidget::setEnabled);
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);
}
}
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);
}