#include "AudioLevelMeter.h" #include "GraphEditorWidget.h" #include "PresetManager.h" #include "SquareConnectionPainter.h" #include "VolumeWidgets.h" #include "WarpGraphModel.h" #include "ZoomGraphicsView.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { inline float sliderToVolume(int slider) { float x = static_cast(slider) / 100.0f; return x * x * x; } inline int volumeToSlider(float volume) { return static_cast(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 &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 m_snapshots; }; class VolumeChangeCommand : public QUndoCommand { public: VolumeChangeCommand(WarpGraphModel *model, QtNodes::NodeId nodeId, WarpGraphModel::NodeVolumeState previous, WarpGraphModel::NodeVolumeState next) : m_model(model), m_nodeId(nodeId), m_previous(previous), m_next(next) { setText(QStringLiteral("Volume Change")); } void undo() override { if (m_model) m_model->setNodeVolumeState(m_nodeId, m_previous); } void redo() override { if (m_model) m_model->setNodeVolumeState(m_nodeId, m_next); } private: WarpGraphModel *m_model = nullptr; QtNodes::NodeId m_nodeId; WarpGraphModel::NodeVolumeState m_previous; WarpGraphModel::NodeVolumeState m_next; }; GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, QWidget *parent) : QWidget(parent), m_client(client) { m_layoutPath = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) + QStringLiteral("/layout.json"); m_model = new WarpGraphModel(client, this); bool hasLayout = m_model->loadLayout(m_layoutPath); m_scene = new QtNodes::BasicGraphicsScene(*m_model, this); m_scene->setItemIndexMethod(QGraphicsScene::BspTreeIndex); QtNodes::ConnectionStyle::setConnectionStyle( R"({"ConnectionStyle": { "ConstructionColor": "#b4b4c8", "NormalColor": "#c8c8dc", "SelectedColor": "#ffa500", "SelectedHaloColor": "#ffa50040", "HoveredColor": "#f0c878", "LineWidth": 2.4, "ConstructionLineWidth": 1.8, "PointDiameter": 10.0, "UseDataDefinedColors": false }})"); m_view = new ZoomGraphicsView(m_scene); m_view->setFocusPolicy(Qt::StrongFocus); m_view->viewport()->setFocusPolicy(Qt::StrongFocus); m_view->viewport()->installEventFilter(this); connect(m_view, &ZoomGraphicsView::scaleChanged, m_view, [this]() { m_view->updateProxyCacheMode(); }); m_presetDir = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) + QStringLiteral("/presets"); m_sidebar = new QTabWidget(); m_sidebar->setTabPosition(QTabWidget::North); m_sidebar->setDocumentMode(true); m_sidebar->setStyleSheet(QStringLiteral( "QTabWidget::pane { border: none; background: #1a1a1e; }" "QTabBar::tab { background: #24242a; color: #a0a8b6; padding: 8px 16px;" " border: none; border-bottom: 2px solid transparent; }" "QTabBar::tab:selected { color: #ecf0f6;" " border-bottom: 2px solid #ffa500; }" "QTabBar::tab:hover { background: #2e2e36; }")); auto *presetsTab = new QWidget(); auto *presetsLayout = new QVBoxLayout(presetsTab); presetsLayout->setContentsMargins(8, 8, 8, 8); presetsLayout->setSpacing(6); auto *savePresetBtn = new QPushButton(QStringLiteral("Save Preset...")); savePresetBtn->setStyleSheet(QStringLiteral( "QPushButton { background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;" " border-radius: 4px; padding: 6px 12px; }" "QPushButton:hover { background: #3a3a44; }" "QPushButton:pressed { background: #44444e; }")); connect(savePresetBtn, &QPushButton::clicked, this, &GraphEditorWidget::savePreset); auto *loadPresetBtn = new QPushButton(QStringLiteral("Load Preset...")); loadPresetBtn->setStyleSheet(savePresetBtn->styleSheet()); connect(loadPresetBtn, &QPushButton::clicked, this, &GraphEditorWidget::loadPreset); presetsLayout->addWidget(savePresetBtn); presetsLayout->addWidget(loadPresetBtn); presetsLayout->addSpacing(16); auto *zoomSensLabel = new QLabel(QStringLiteral("ZOOM SENSITIVITY")); zoomSensLabel->setStyleSheet(QStringLiteral( "QLabel { color: #a0a8b6; font-size: 11px; font-weight: bold;" " background: transparent; }")); presetsLayout->addWidget(zoomSensLabel); m_zoomSensSlider = new QSlider(Qt::Horizontal); m_zoomSensSlider->setRange(5, 50); m_zoomSensSlider->setValue(20); m_zoomSensSlider->setStyleSheet(QStringLiteral( "QSlider::groove:horizontal {" " background: #1a1a1e; border-radius: 3px; height: 6px; }" "QSlider::handle:horizontal {" " background: #ecf0f6; border-radius: 5px;" " width: 10px; margin: -4px 0; }" "QSlider::sub-page:horizontal {" " background: #4caf50; border-radius: 3px; }")); m_zoomSensValue = new QLabel(QStringLiteral("1.20x")); m_zoomSensValue->setStyleSheet(QStringLiteral( "QLabel { color: #ecf0f6; font-size: 11px; background: transparent; }")); m_zoomSensValue->setAlignment(Qt::AlignCenter); connect(m_zoomSensSlider, &QSlider::valueChanged, this, [this](int value) { double sensitivity = 1.0 + value / 100.0; m_view->setZoomSensitivity(sensitivity); m_zoomSensValue->setText( QString::number(sensitivity, 'f', 2) + QStringLiteral("x")); scheduleSaveLayout(); }); presetsLayout->addWidget(m_zoomSensSlider); presetsLayout->addWidget(m_zoomSensValue); auto sliderStyle = m_zoomSensSlider->styleSheet(); auto valueLabelStyle = m_zoomSensValue->styleSheet(); presetsLayout->addSpacing(12); auto *zoomMinLabel = new QLabel(QStringLiteral("MIN ZOOM")); zoomMinLabel->setStyleSheet(zoomSensLabel->styleSheet()); presetsLayout->addWidget(zoomMinLabel); m_zoomMinSlider = new QSlider(Qt::Horizontal); m_zoomMinSlider->setRange(5, 90); m_zoomMinSlider->setValue(30); m_zoomMinSlider->setStyleSheet(sliderStyle); m_zoomMinValue = new QLabel(QStringLiteral("0.30x")); m_zoomMinValue->setStyleSheet(valueLabelStyle); m_zoomMinValue->setAlignment(Qt::AlignCenter); connect(m_zoomMinSlider, &QSlider::valueChanged, this, [this](int value) { double minZoom = value / 100.0; if (m_zoomMaxSlider->value() <= value) { m_zoomMaxSlider->setValue(value + 5); } m_view->setScaleRange(minZoom, m_zoomMaxSlider->value() / 100.0); m_zoomMinValue->setText( QString::number(minZoom, 'f', 2) + QStringLiteral("x")); scheduleSaveLayout(); }); presetsLayout->addWidget(m_zoomMinSlider); presetsLayout->addWidget(m_zoomMinValue); presetsLayout->addSpacing(12); auto *zoomMaxLabel = new QLabel(QStringLiteral("MAX ZOOM")); zoomMaxLabel->setStyleSheet(zoomSensLabel->styleSheet()); presetsLayout->addWidget(zoomMaxLabel); m_zoomMaxSlider = new QSlider(Qt::Horizontal); m_zoomMaxSlider->setRange(10, 500); m_zoomMaxSlider->setValue(200); m_zoomMaxSlider->setStyleSheet(sliderStyle); m_zoomMaxValue = new QLabel(QStringLiteral("2.00x")); m_zoomMaxValue->setStyleSheet(valueLabelStyle); m_zoomMaxValue->setAlignment(Qt::AlignCenter); connect(m_zoomMaxSlider, &QSlider::valueChanged, this, [this](int value) { double maxZoom = value / 100.0; if (m_zoomMinSlider->value() >= value) { m_zoomMinSlider->setValue(value - 5); } m_view->setScaleRange(m_zoomMinSlider->value() / 100.0, maxZoom); m_zoomMaxValue->setText( QString::number(maxZoom, 'f', 2) + QStringLiteral("x")); scheduleSaveLayout(); }); presetsLayout->addWidget(m_zoomMaxSlider); presetsLayout->addWidget(m_zoomMaxValue); presetsLayout->addStretch(); auto *metersTab = new QWidget(); auto *metersLayout = new QVBoxLayout(metersTab); metersLayout->setContentsMargins(8, 8, 8, 8); metersLayout->setSpacing(8); auto *masterLabel = new QLabel(QStringLiteral("MASTER OUTPUT")); masterLabel->setStyleSheet(QStringLiteral( "QLabel { color: #a0a8b6; font-size: 11px; font-weight: bold;" " background: transparent; }")); metersLayout->addWidget(masterLabel); auto *masterRow = new QWidget(); auto *masterRowLayout = new QHBoxLayout(masterRow); masterRowLayout->setContentsMargins(0, 0, 0, 0); masterRowLayout->setSpacing(4); m_masterMeterL = new AudioLevelMeter(); m_masterMeterL->setFixedWidth(18); m_masterMeterL->setMinimumHeight(100); m_masterMeterR = new AudioLevelMeter(); m_masterMeterR->setFixedWidth(18); m_masterMeterR->setMinimumHeight(100); masterRowLayout->addStretch(); masterRowLayout->addWidget(m_masterMeterL); masterRowLayout->addWidget(m_masterMeterR); masterRowLayout->addStretch(); metersLayout->addWidget(masterRow); auto *nodeMetersLabel = new QLabel(QStringLiteral("NODE METERS")); nodeMetersLabel->setStyleSheet(masterLabel->styleSheet()); metersLayout->addWidget(nodeMetersLabel); m_nodeMeterScroll = new QScrollArea(); m_nodeMeterScroll->setWidgetResizable(true); m_nodeMeterScroll->setStyleSheet(QStringLiteral( "QScrollArea { background: transparent; border: none; }" "QScrollBar:vertical { background: #1a1a1e; width: 8px; }" "QScrollBar::handle:vertical { background: #3a3a44; border-radius: 4px; }" "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }")); m_nodeMeterContainer = new QWidget(); m_nodeMeterContainer->setStyleSheet(QStringLiteral("background: transparent;")); auto *nodeMeterLayout = new QVBoxLayout(m_nodeMeterContainer); nodeMeterLayout->setContentsMargins(0, 0, 0, 0); nodeMeterLayout->setSpacing(2); nodeMeterLayout->addStretch(); m_nodeMeterScroll->setWidget(m_nodeMeterContainer); metersLayout->addWidget(m_nodeMeterScroll, 1); metersTab->setStyleSheet(QStringLiteral("background: #1a1a1e;")); m_sidebar->addTab(metersTab, QStringLiteral("METERS")); m_mixerScroll = new QScrollArea(); m_mixerScroll->setWidgetResizable(true); m_mixerScroll->setStyleSheet(QStringLiteral( "QScrollArea { background: #1a1a1e; border: none; }" "QScrollBar:vertical { background: #1a1a1e; width: 8px; }" "QScrollBar::handle:vertical { background: #3a3a44; border-radius: 4px; }" "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }")); m_mixerContainer = new QWidget(); m_mixerContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;")); auto *mixerLayout = new QVBoxLayout(m_mixerContainer); mixerLayout->setContentsMargins(4, 4, 4, 4); mixerLayout->setSpacing(2); mixerLayout->addStretch(); m_mixerScroll->setWidget(m_mixerContainer); m_sidebar->addTab(m_mixerScroll, QStringLiteral("MIXER")); m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS")); m_rulesScroll = new QScrollArea(); m_rulesScroll->setWidgetResizable(true); m_rulesScroll->setStyleSheet(m_mixerScroll->styleSheet()); m_rulesContainer = new QWidget(); m_rulesContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;")); auto *rulesLayout = new QVBoxLayout(m_rulesContainer); rulesLayout->setContentsMargins(8, 8, 8, 8); rulesLayout->setSpacing(6); rulesLayout->addStretch(); m_rulesScroll->setWidget(m_rulesContainer); m_sidebar->addTab(m_rulesScroll, QStringLiteral("RULES")); m_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() { m_saveTimer->start(); } GraphEditorWidget::~GraphEditorWidget() { if (m_client) { m_client->SetChangeCallback(nullptr); } } int GraphEditorWidget::nodeCount() const { return static_cast(m_model->allNodeIds().size()); } int GraphEditorWidget::linkCount() const { int count = 0; for (auto nodeId : m_model->allNodeIds()) { count += static_cast( 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(event); m_lastContextMenuScenePos = m_view->mapToScene(cme->pos()); } else if (event->type() == QEvent::MouseButtonPress) { auto *me = static_cast(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 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 items = m_scene->selectedItems(); QList virtualNodeIds; bool hasSelectedConnections = false; for (auto *item : items) { if (auto *nodeObj = qgraphicsitem_cast(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( 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 selectedNames; QPointF sum; int count = 0; const QList items = m_scene->selectedItems(); for (auto *item : items) { auto *nodeObj = qgraphicsitem_cast(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( 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> 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 existingNames; auto nodesResult = m_client->ListNodes(); if (nodesResult.ok()) { for (const auto &node : nodesResult.value) { existingNames.insert(node.name); } } std::unordered_map 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 { 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 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 sizes = m_splitter->sizes(); vs.splitterGraph = sizes.value(0, 1200); vs.splitterSidebar = sizes.value(1, 320); vs.connectionStyle = static_cast(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(ConnectionStyleType::kSquare)) { setConnectionStyle(ConnectionStyleType::kSquare); } if (vs.zoomSensitivity > 0.0) { m_view->setZoomSensitivity(vs.zoomSensitivity); int sliderVal = static_cast((vs.zoomSensitivity - 1.0) * 100.0); m_zoomSensSlider->setValue(sliderVal); } if (vs.zoomMin > 0.0) { m_zoomMinSlider->setValue(static_cast(vs.zoomMin * 100.0)); } if (vs.zoomMax > 0.0) { m_zoomMaxSlider->setValue(static_cast(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(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(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(); auto *vol = qobject_cast(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 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(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 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 sorted(nodeIds.begin(), nodeIds.end()); std::sort(sorted.begin(), sorted.end()); std::unordered_map 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(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(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()); } else { m_scene->setConnectionPainter( std::make_unique()); } 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(); }