warp-pipe/gui/GraphEditorWidget.cpp

1698 lines
58 KiB
C++

#include "AudioLevelMeter.h"
#include "GraphEditorWidget.h"
#include "PresetManager.h"
#include "SquareConnectionPainter.h"
#include "VolumeWidgets.h"
#include "WarpGraphModel.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 <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);
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 QtNodes::GraphicsView(m_scene);
m_view->setFocusPolicy(Qt::StrongFocus);
m_view->viewport()->setFocusPolicy(Qt::StrongFocus);
m_view->viewport()->installEventFilter(this);
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->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_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();
});
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() {
if (!m_saveTimer->isActive()) {
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) {
QString label = isSink ? QStringLiteral("Create Virtual Sink")
: QStringLiteral("Create Virtual Source");
bool ok = false;
QString name = QInputDialog::getText(this, label,
QStringLiteral("Node name:"),
QLineEdit::Normal, QString(), &ok);
if (!ok || name.trimmed().isEmpty()) {
return;
}
std::string nodeName = name.trimmed().toStdString();
m_model->setPendingPosition(nodeName, scenePos);
warppipe::Status status;
if (isSink) {
auto result = m_client->CreateVirtualSink(nodeName);
status = result.status;
} else {
auto result = m_client->CreateVirtualSource(nodeName);
status = result.status;
}
if (!status.ok()) {
QMessageBox::warning(this, QStringLiteral("Error"),
QString::fromStdString(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.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);
}
} 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();
}