diff --git a/GUI_PLAN.md b/GUI_PLAN.md index d1a8d68..cc562d0 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -74,20 +74,20 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. - [x] Remove from m_connections - [x] Verify: Drag connection from output port to input port creates PipeWire link; delete removes it -- [ ] Milestone 4 - Context Menu and Virtual Node Creation - - [ ] Add context menu to GraphEditorWidget: - - [ ] Right-click on canvas (not on node) shows menu - - [ ] Menu items: "Create Virtual Sink", "Create Virtual Source" - - [ ] Implement "Create Virtual Sink": - - [ ] Prompt user for name (QInputDialog or inline text field) - - [ ] Call `Client::CreateVirtualSink(name, VirtualNodeOptions{})` with default options - - [ ] On success, node appears in graph at context menu position - - [ ] Implement "Create Virtual Source": - - [ ] Same as sink but call `Client::CreateVirtualSource()` - - [ ] Add context menu on nodes: - - [ ] Right-click on virtual node shows "Delete Node" option - - [ ] Call `Client::RemoveNode(nodeId)` and remove from graph - - [ ] Verify: Can create/delete virtual sinks and sources via right-click +- [x] Milestone 4 - Context Menu and Virtual Node Creation + - [x] Add context menu to GraphEditorWidget: + - [x] Right-click on canvas (not on node) shows menu + - [x] Menu items: "Create Virtual Sink", "Create Virtual Source" + - [x] Implement "Create Virtual Sink": + - [x] Prompt user for name (QInputDialog or inline text field) + - [x] Call `Client::CreateVirtualSink(name, VirtualNodeOptions{})` with default options + - [x] On success, node appears in graph at context menu position + - [x] Implement "Create Virtual Source": + - [x] Same as sink but call `Client::CreateVirtualSource()` + - [x] Add context menu on nodes: + - [x] Right-click on virtual node shows "Delete Node" option + - [x] Call `Client::RemoveNode(nodeId)` and remove from graph + - [x] Verify: Can create/delete virtual sinks and sources via right-click - [ ] Milestone 5 - Layout Persistence and Polish - [ ] Implement layout save/load: diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index b2a3ef0..dc26cca 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -4,6 +4,9 @@ #include #include +#include +#include +#include #include #include @@ -18,6 +21,10 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, layout->setContentsMargins(0, 0, 0, 0); layout->addWidget(m_view); + m_view->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_view, &QWidget::customContextMenuRequested, this, + &GraphEditorWidget::onContextMenuRequested); + m_model->refreshFromClient(); m_refreshTimer = new QTimer(this); @@ -27,3 +34,105 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, } void GraphEditorWidget::onRefreshTimer() { m_model->refreshFromClient(); } + +void GraphEditorWidget::onContextMenuRequested(const QPoint &pos) { + QPointF scenePos = m_view->mapToScene(pos); + + uint32_t hitPwNodeId = 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; + break; + } + } + + QPoint screenPos = m_view->mapToGlobal(pos); + if (hitPwNodeId != 0) { + showNodeContextMenu(screenPos, hitPwNodeId); + } 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")); + + QAction *chosen = menu.exec(screenPos); + if (chosen == createSink) { + createVirtualNode(true, scenePos); + } else if (chosen == createSource) { + createVirtualNode(false, scenePos); + } +} + +void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos, + uint32_t pwNodeId) { + QtNodes::NodeId qtId = m_model->qtNodeIdForPw(pwNodeId); + const WarpNodeData *data = m_model->warpNodeData(qtId); + if (!data) { + return; + } + + WarpNodeType type = WarpGraphModel::classifyNode(data->info); + bool isVirtual = + type == WarpNodeType::kVirtualSink || type == WarpNodeType::kVirtualSource; + + if (!isVirtual) { + return; + } + + QMenu menu; + QAction *deleteAction = menu.addAction(QStringLiteral("Delete Node")); + + QAction *chosen = menu.exec(screenPos); + if (chosen == deleteAction && m_client) { + m_client->RemoveNode(warppipe::NodeId{pwNodeId}); + m_model->refreshFromClient(); + } +} + +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(); +} diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index 1271297..f210faf 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -21,8 +21,13 @@ public: private slots: void onRefreshTimer(); + void onContextMenuRequested(const QPoint &pos); private: + void showCanvasContextMenu(const QPoint &screenPos, const QPointF &scenePos); + void showNodeContextMenu(const QPoint &screenPos, uint32_t pwNodeId); + void createVirtualNode(bool isSink, const QPointF &scenePos); + warppipe::Client *m_client = nullptr; WarpGraphModel *m_model = nullptr; QtNodes::BasicGraphicsScene *m_scene = nullptr; diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp index e9d498e..f0f0de5 100644 --- a/gui/WarpGraphModel.cpp +++ b/gui/WarpGraphModel.cpp @@ -318,11 +318,43 @@ void WarpGraphModel::refreshFromClient() { for (const auto &nodeInfo : nodesResult.value) { seenPwIds.insert(nodeInfo.id.value); + WarpNodeType nodeType = classifyNode(nodeInfo); + bool isStream = nodeType == WarpNodeType::kApplication; + if (isStream && nodeInfo.name.empty() && + nodeInfo.application_name.empty()) { + continue; + } + auto existing = m_pwToQt.find(nodeInfo.id.value); if (existing != m_pwToQt.end()) { QtNodes::NodeId qtId = existing->second; auto &data = m_nodes[qtId]; data.info = nodeInfo; + + bool portsMissing = + data.inputPorts.empty() && data.outputPorts.empty(); + if (portsMissing) { + auto portsResult = m_client->ListPorts(nodeInfo.id); + if (portsResult.ok() && !portsResult.value.empty()) { + for (const auto &port : portsResult.value) { + if (port.is_input) { + data.inputPorts.push_back(port); + } else { + data.outputPorts.push_back(port); + } + } + std::sort(data.inputPorts.begin(), data.inputPorts.end(), + [](const auto &a, const auto &b) { + return a.name < b.name; + }); + std::sort(data.outputPorts.begin(), data.outputPorts.end(), + [](const auto &a, const auto &b) { + return a.name < b.name; + }); + Q_EMIT nodeUpdated(qtId); + } + } + if (m_ghostNodes.erase(qtId)) { Q_EMIT nodeUpdated(qtId); } @@ -392,7 +424,14 @@ void WarpGraphModel::refreshFromClient() { auto [nodeIt, _] = m_nodes.emplace(qtId, std::move(data)); m_pwToQt.emplace(nodeInfo.id.value, qtId); - m_positions.emplace(qtId, nextPosition(nodeIt->second)); + + auto pendingIt = m_pendingPositions.find(nodeInfo.name); + if (pendingIt != m_pendingPositions.end()) { + m_positions.emplace(qtId, pendingIt->second); + m_pendingPositions.erase(pendingIt); + } else { + m_positions.emplace(qtId, nextPosition(nodeIt->second)); + } Q_EMIT nodeCreated(qtId); } @@ -510,6 +549,11 @@ QtNodes::NodeId WarpGraphModel::qtNodeIdForPw(uint32_t pwNodeId) const { return 0; } +void WarpGraphModel::setPendingPosition(const std::string &nodeName, + QPointF pos) { + m_pendingPositions[nodeName] = pos; +} + QString WarpGraphModel::captionForNode(const warppipe::NodeInfo &info) { if (!info.description.empty()) { return QString::fromStdString(info.description); diff --git a/gui/WarpGraphModel.h b/gui/WarpGraphModel.h index 4497b90..a3f68cf 100644 --- a/gui/WarpGraphModel.h +++ b/gui/WarpGraphModel.h @@ -64,10 +64,11 @@ public: const WarpNodeData *warpNodeData(QtNodes::NodeId nodeId) const; QtNodes::NodeId qtNodeIdForPw(uint32_t pwNodeId) const; bool isGhost(QtNodes::NodeId nodeId) const; + void setPendingPosition(const std::string &nodeName, QPointF pos); + static WarpNodeType classifyNode(const warppipe::NodeInfo &info); private: static QString captionForNode(const warppipe::NodeInfo &info); - static WarpNodeType classifyNode(const warppipe::NodeInfo &info); static QVariant styleForNode(WarpNodeType type, bool ghost); QPointF nextPosition(const WarpNodeData &data); static QSize estimateNodeSize(const WarpNodeData &data); @@ -93,4 +94,6 @@ private: double m_rowMaxHeight = 0.0; bool m_refreshing = false; + + std::unordered_map m_pendingPositions; };