#include "GraphEditorWidget.h" #include "PresetManager.h" #include "WarpGraphModel.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 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; }; 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(); m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS")); 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); m_saveTimer = new QTimer(this); m_saveTimer->setSingleShot(true); m_saveTimer->setInterval(1000); connect(m_saveTimer, &QTimer::timeout, this, &GraphEditorWidget::saveLayoutWithViewState); m_model->refreshFromClient(); 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_refreshTimer = new QTimer(this); connect(m_refreshTimer, &QTimer::timeout, this, &GraphEditorWidget::onRefreshTimer); m_refreshTimer->start(500); } 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(); } } 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(); 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 == 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 *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)); } } 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{}); } 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.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}); } } 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.")); } }