#include "GraphEditorWidget.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 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); auto *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); layout->addWidget(m_view); 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(); m_model->saveLayout(m_layoutPath); }); 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); m_saveTimer = new QTimer(this); m_saveTimer->setSingleShot(true); m_saveTimer->setInterval(1000); connect(m_saveTimer, &QTimer::timeout, this, [this]() { m_model->saveLayout(m_layoutPath); }); m_model->refreshFromClient(); if (!hasLayout) { m_model->autoArrange(); } if (m_model->allNodeIds().size() > 0) { m_graphReady = true; Q_EMIT graphReady(); } 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() && event->type() == QEvent::ContextMenu) { auto *cme = static_cast(event); m_lastContextMenuScenePos = m_view->mapToScene(cme->pos()); } 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)); 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(); } else if (chosen == refreshGraph) { m_model->refreshFromClient(); } } 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; }