diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 60cd1cc..bd85833 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -135,6 +136,9 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, }})"); 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); @@ -292,10 +296,21 @@ void GraphEditorWidget::captureDebugScreenshot(const QString &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) { @@ -308,13 +323,14 @@ void GraphEditorWidget::onContextMenuRequested(const QPoint &pos) { 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); + showNodeContextMenu(screenPos, hitPwNodeId, hitQtNodeId); } else { showCanvasContextMenu(screenPos, scenePos); } @@ -327,6 +343,14 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos, 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 *autoArrange = menu.addAction(QStringLiteral("Auto-Arrange")); QAction *chosen = menu.exec(screenPos); @@ -334,15 +358,17 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos, createVirtualNode(true, scenePos); } else if (chosen == createSource) { createVirtualNode(false, scenePos); + } else if (chosen == pasteAction) { + pasteSelection(QPointF(0, 0)); } else if (chosen == autoArrange) { m_model->autoArrange(); } } void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos, - uint32_t pwNodeId) { - QtNodes::NodeId qtId = m_model->qtNodeIdForPw(pwNodeId); - const WarpNodeData *data = m_model->warpNodeData(qtId); + uint32_t pwNodeId, + QtNodes::NodeId qtNodeId) { + const WarpNodeData *data = m_model->warpNodeData(qtNodeId); if (!data) { return; } @@ -351,17 +377,42 @@ void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos, bool isVirtual = type == WarpNodeType::kVirtualSink || type == WarpNodeType::kVirtualSource; - if (!isVirtual) { - return; + 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); } - QMenu menu; - QAction *deleteAction = menu.addAction(QStringLiteral("Delete Node")); + 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 == deleteAction && m_client) { - m_client->RemoveNode(warppipe::NodeId{pwNodeId}); - m_model->refreshFromClient(); + 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)); } } diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index be5c5a2..9a5dbf3 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -10,6 +11,7 @@ #include namespace QtNodes { +using NodeId = unsigned int; class BasicGraphicsScene; class GraphicsView; } // namespace QtNodes @@ -33,6 +35,8 @@ public: void setDebugScreenshotDir(const QString &dir); + bool eventFilter(QObject *obj, QEvent *event) override; + Q_SIGNALS: void graphReady(); @@ -43,7 +47,8 @@ private slots: private: void showCanvasContextMenu(const QPoint &screenPos, const QPointF &scenePos); - void showNodeContextMenu(const QPoint &screenPos, uint32_t pwNodeId); + void showNodeContextMenu(const QPoint &screenPos, uint32_t pwNodeId, + QtNodes::NodeId qtNodeId); void createVirtualNode(bool isSink, const QPointF &scenePos); void captureDebugScreenshot(const QString &event); @@ -72,4 +77,5 @@ private: bool m_graphReady = false; QJsonObject m_clipboardJson; std::vector m_pendingPasteLinks; + QPointF m_lastContextMenuScenePos; }; diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp index 1b99043..4934f2a 100644 --- a/gui/WarpGraphModel.cpp +++ b/gui/WarpGraphModel.cpp @@ -628,14 +628,13 @@ WarpNodeType WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) { const std::string &mc = info.media_class; - bool isVirtual = (info.name.find("warppipe") != std::string::npos); - if (mc == "Audio/Sink" || mc == "Audio/Duplex") { - return isVirtual ? WarpNodeType::kVirtualSink : WarpNodeType::kHardwareSink; + return info.is_virtual ? WarpNodeType::kVirtualSink + : WarpNodeType::kHardwareSink; } if (mc == "Audio/Source") { - return isVirtual ? WarpNodeType::kVirtualSource - : WarpNodeType::kHardwareSource; + return info.is_virtual ? WarpNodeType::kVirtualSource + : WarpNodeType::kHardwareSource; } if (mc == "Stream/Output/Audio" || mc == "Stream/Input/Audio") { return WarpNodeType::kApplication; diff --git a/gui/main.cpp b/gui/main.cpp index d2da3a5..eec09c9 100644 --- a/gui/main.cpp +++ b/gui/main.cpp @@ -83,6 +83,10 @@ int main(int argc, char *argv[]) { warppipe::ConnectionOptions opts; opts.application_name = "warppipe-gui"; + opts.config_path = + (QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) + + QStringLiteral("/config.json")) + .toStdString(); auto result = warppipe::Client::Create(opts); if (!result.ok()) { diff --git a/include/warppipe/warppipe.hpp b/include/warppipe/warppipe.hpp index 33c7adf..c222736 100644 --- a/include/warppipe/warppipe.hpp +++ b/include/warppipe/warppipe.hpp @@ -94,6 +94,7 @@ struct NodeInfo { std::string application_name; std::string process_binary; std::string media_role; + bool is_virtual = false; }; struct PortInfo { diff --git a/src/warppipe.cpp b/src/warppipe.cpp index e75f4eb..6c10935 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -339,6 +339,8 @@ void Client::Impl::RegistryGlobal(void* data, info.application_name = LookupString(props, PW_KEY_APP_NAME); info.process_binary = LookupString(props, PW_KEY_APP_PROCESS_BINARY); info.media_role = LookupString(props, PW_KEY_MEDIA_ROLE); + std::string virt_str = LookupString(props, PW_KEY_NODE_VIRTUAL); + info.is_virtual = (virt_str == "true"); impl->nodes[id] = info; impl->CheckRulesForNode(info); return; @@ -1098,7 +1100,13 @@ Result> Client::ListNodes() { std::vector items; items.reserve(impl_->nodes.size()); for (const auto& entry : impl_->nodes) { - items.push_back(entry.second); + NodeInfo info = entry.second; + if (!info.is_virtual && + impl_->virtual_streams.find(entry.first) != + impl_->virtual_streams.end()) { + info.is_virtual = true; + } + items.push_back(std::move(info)); } return {Status::Ok(), std::move(items)}; } diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index 69a502e..bddc1bf 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -33,13 +33,15 @@ struct TestClient { warppipe::NodeInfo MakeNode(uint32_t id, const std::string &name, const std::string &media_class, const std::string &app_name = {}, - const std::string &desc = {}) { + const std::string &desc = {}, + bool is_virtual = false) { warppipe::NodeInfo n; n.id = warppipe::NodeId{id}; n.name = name; n.media_class = media_class; n.application_name = app_name; n.description = desc; + n.is_virtual = is_virtual; return n; } @@ -86,12 +88,12 @@ TEST_CASE("classifyNode identifies hardware source") { } TEST_CASE("classifyNode identifies virtual sink") { - auto n = MakeNode(3, "warppipe-gaming-sink", "Audio/Sink"); + auto n = MakeNode(3, "gaming-sink", "Audio/Sink", {}, {}, true); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSink); } TEST_CASE("classifyNode identifies virtual source") { - auto n = MakeNode(4, "warppipe-mic", "Audio/Source"); + auto n = MakeNode(4, "my-mic", "Audio/Source", {}, {}, true); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSource); } @@ -110,6 +112,26 @@ TEST_CASE("classifyNode returns unknown for unrecognized media class") { REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kUnknown); } +TEST_CASE("classifyNode virtual sink without warppipe in name") { + auto n = MakeNode(10, "Sink", "Audio/Sink", {}, {}, true); + REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSink); +} + +TEST_CASE("classifyNode virtual source without warppipe in name") { + auto n = MakeNode(11, "Mic", "Audio/Source", {}, {}, true); + REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSource); +} + +TEST_CASE("classifyNode non-virtual sink with warppipe in name") { + auto n = MakeNode(12, "warppipe-hw", "Audio/Sink", {}, {}, false); + REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kHardwareSink); +} + +TEST_CASE("classifyNode virtual duplex treated as virtual sink") { + auto n = MakeNode(13, "my-duplex", "Audio/Duplex", {}, {}, true); + REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSink); +} + TEST_CASE("classifyNode duplex treated as sink") { auto n = MakeNode(8, "alsa_duplex", "Audio/Duplex"); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kHardwareSink); @@ -217,7 +239,7 @@ TEST_CASE("node style varies by type") { REQUIRE(tc.client->Test_InsertNode( MakeNode(100040, "hw-sink", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertNode( - MakeNode(100041, "warppipe-vsink", "Audio/Sink")).ok()); + MakeNode(100041, "my-vsink", "Audio/Sink", {}, {}, true)).ok()); REQUIRE(tc.client->Test_InsertNode( MakeNode(100042, "app-stream", "Stream/Output/Audio", "App")).ok());