Fix virtual nodes

This commit is contained in:
Joey Yakimowich-Payne 2026-01-30 07:30:51 -07:00
commit 1dd4ef7327
7 changed files with 113 additions and 22 deletions

View file

@ -10,6 +10,7 @@
#include <QAction> #include <QAction>
#include <QClipboard> #include <QClipboard>
#include <QContextMenuEvent>
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
#include <QGraphicsItem> #include <QGraphicsItem>
@ -135,6 +136,9 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
}})"); }})");
m_view = new QtNodes::GraphicsView(m_scene); 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); auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0); layout->setContentsMargins(0, 0, 0, 0);
@ -292,10 +296,21 @@ void GraphEditorWidget::captureDebugScreenshot(const QString &event) {
pixmap.save(m_debugScreenshotDir + "/" + filename); 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<QContextMenuEvent *>(event);
m_lastContextMenuScenePos = m_view->mapToScene(cme->pos());
}
return QWidget::eventFilter(obj, event);
}
void GraphEditorWidget::onContextMenuRequested(const QPoint &pos) { void GraphEditorWidget::onContextMenuRequested(const QPoint &pos) {
QPointF scenePos = m_view->mapToScene(pos); QPointF scenePos = m_view->mapToScene(pos);
m_lastContextMenuScenePos = scenePos;
uint32_t hitPwNodeId = 0; uint32_t hitPwNodeId = 0;
QtNodes::NodeId hitQtNodeId = 0;
for (auto nodeId : m_model->allNodeIds()) { for (auto nodeId : m_model->allNodeIds()) {
const WarpNodeData *data = m_model->warpNodeData(nodeId); const WarpNodeData *data = m_model->warpNodeData(nodeId);
if (!data) { if (!data) {
@ -308,13 +323,14 @@ void GraphEditorWidget::onContextMenuRequested(const QPoint &pos) {
QRectF nodeRect(nodePos, QSizeF(nodeSize)); QRectF nodeRect(nodePos, QSizeF(nodeSize));
if (nodeRect.contains(scenePos)) { if (nodeRect.contains(scenePos)) {
hitPwNodeId = data->info.id.value; hitPwNodeId = data->info.id.value;
hitQtNodeId = nodeId;
break; break;
} }
} }
QPoint screenPos = m_view->mapToGlobal(pos); QPoint screenPos = m_view->mapToGlobal(pos);
if (hitPwNodeId != 0) { if (hitPwNodeId != 0) {
showNodeContextMenu(screenPos, hitPwNodeId); showNodeContextMenu(screenPos, hitPwNodeId, hitQtNodeId);
} else { } else {
showCanvasContextMenu(screenPos, scenePos); showCanvasContextMenu(screenPos, scenePos);
} }
@ -327,6 +343,14 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
QAction *createSource = QAction *createSource =
menu.addAction(QStringLiteral("Create Virtual Source")); menu.addAction(QStringLiteral("Create Virtual Source"));
menu.addSeparator(); 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 *autoArrange = menu.addAction(QStringLiteral("Auto-Arrange"));
QAction *chosen = menu.exec(screenPos); QAction *chosen = menu.exec(screenPos);
@ -334,15 +358,17 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
createVirtualNode(true, scenePos); createVirtualNode(true, scenePos);
} else if (chosen == createSource) { } else if (chosen == createSource) {
createVirtualNode(false, scenePos); createVirtualNode(false, scenePos);
} else if (chosen == pasteAction) {
pasteSelection(QPointF(0, 0));
} else if (chosen == autoArrange) { } else if (chosen == autoArrange) {
m_model->autoArrange(); m_model->autoArrange();
} }
} }
void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos, void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos,
uint32_t pwNodeId) { uint32_t pwNodeId,
QtNodes::NodeId qtId = m_model->qtNodeIdForPw(pwNodeId); QtNodes::NodeId qtNodeId) {
const WarpNodeData *data = m_model->warpNodeData(qtId); const WarpNodeData *data = m_model->warpNodeData(qtNodeId);
if (!data) { if (!data) {
return; return;
} }
@ -351,17 +377,42 @@ void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos,
bool isVirtual = bool isVirtual =
type == WarpNodeType::kVirtualSink || type == WarpNodeType::kVirtualSource; type == WarpNodeType::kVirtualSink || type == WarpNodeType::kVirtualSource;
if (!isVirtual) { QMenu menu;
return;
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 *pasteAction = menu.addAction(QStringLiteral("Paste"));
QAction *deleteAction = menu.addAction(QStringLiteral("Delete Node")); 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); QAction *chosen = menu.exec(screenPos);
if (chosen == deleteAction && m_client) { if (!chosen) {
m_client->RemoveNode(warppipe::NodeId{pwNodeId}); return;
m_model->refreshFromClient(); }
if (chosen == copyAction) {
copySelection();
} else if (chosen == duplicateAction) {
duplicateSelection();
} else if (chosen == deleteAction && m_client) {
deleteSelection();
} else if (chosen == pasteAction) {
pasteSelection(QPointF(0, 0));
} }
} }

View file

@ -3,6 +3,7 @@
#include <warppipe/warppipe.hpp> #include <warppipe/warppipe.hpp>
#include <QJsonObject> #include <QJsonObject>
#include <QPointF>
#include <QString> #include <QString>
#include <QWidget> #include <QWidget>
@ -10,6 +11,7 @@
#include <vector> #include <vector>
namespace QtNodes { namespace QtNodes {
using NodeId = unsigned int;
class BasicGraphicsScene; class BasicGraphicsScene;
class GraphicsView; class GraphicsView;
} // namespace QtNodes } // namespace QtNodes
@ -33,6 +35,8 @@ public:
void setDebugScreenshotDir(const QString &dir); void setDebugScreenshotDir(const QString &dir);
bool eventFilter(QObject *obj, QEvent *event) override;
Q_SIGNALS: Q_SIGNALS:
void graphReady(); void graphReady();
@ -43,7 +47,8 @@ private slots:
private: private:
void showCanvasContextMenu(const QPoint &screenPos, const QPointF &scenePos); 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 createVirtualNode(bool isSink, const QPointF &scenePos);
void captureDebugScreenshot(const QString &event); void captureDebugScreenshot(const QString &event);
@ -72,4 +77,5 @@ private:
bool m_graphReady = false; bool m_graphReady = false;
QJsonObject m_clipboardJson; QJsonObject m_clipboardJson;
std::vector<PendingPasteLink> m_pendingPasteLinks; std::vector<PendingPasteLink> m_pendingPasteLinks;
QPointF m_lastContextMenuScenePos;
}; };

View file

@ -628,14 +628,13 @@ WarpNodeType
WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) { WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) {
const std::string &mc = info.media_class; const std::string &mc = info.media_class;
bool isVirtual = (info.name.find("warppipe") != std::string::npos);
if (mc == "Audio/Sink" || mc == "Audio/Duplex") { 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") { if (mc == "Audio/Source") {
return isVirtual ? WarpNodeType::kVirtualSource return info.is_virtual ? WarpNodeType::kVirtualSource
: WarpNodeType::kHardwareSource; : WarpNodeType::kHardwareSource;
} }
if (mc == "Stream/Output/Audio" || mc == "Stream/Input/Audio") { if (mc == "Stream/Output/Audio" || mc == "Stream/Input/Audio") {
return WarpNodeType::kApplication; return WarpNodeType::kApplication;

View file

@ -83,6 +83,10 @@ int main(int argc, char *argv[]) {
warppipe::ConnectionOptions opts; warppipe::ConnectionOptions opts;
opts.application_name = "warppipe-gui"; opts.application_name = "warppipe-gui";
opts.config_path =
(QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) +
QStringLiteral("/config.json"))
.toStdString();
auto result = warppipe::Client::Create(opts); auto result = warppipe::Client::Create(opts);
if (!result.ok()) { if (!result.ok()) {

View file

@ -94,6 +94,7 @@ struct NodeInfo {
std::string application_name; std::string application_name;
std::string process_binary; std::string process_binary;
std::string media_role; std::string media_role;
bool is_virtual = false;
}; };
struct PortInfo { struct PortInfo {

View file

@ -339,6 +339,8 @@ void Client::Impl::RegistryGlobal(void* data,
info.application_name = LookupString(props, PW_KEY_APP_NAME); info.application_name = LookupString(props, PW_KEY_APP_NAME);
info.process_binary = LookupString(props, PW_KEY_APP_PROCESS_BINARY); info.process_binary = LookupString(props, PW_KEY_APP_PROCESS_BINARY);
info.media_role = LookupString(props, PW_KEY_MEDIA_ROLE); 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->nodes[id] = info;
impl->CheckRulesForNode(info); impl->CheckRulesForNode(info);
return; return;
@ -1098,7 +1100,13 @@ Result<std::vector<NodeInfo>> Client::ListNodes() {
std::vector<NodeInfo> items; std::vector<NodeInfo> items;
items.reserve(impl_->nodes.size()); items.reserve(impl_->nodes.size());
for (const auto& entry : impl_->nodes) { 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)}; return {Status::Ok(), std::move(items)};
} }

View file

@ -33,13 +33,15 @@ struct TestClient {
warppipe::NodeInfo MakeNode(uint32_t id, const std::string &name, warppipe::NodeInfo MakeNode(uint32_t id, const std::string &name,
const std::string &media_class, const std::string &media_class,
const std::string &app_name = {}, const std::string &app_name = {},
const std::string &desc = {}) { const std::string &desc = {},
bool is_virtual = false) {
warppipe::NodeInfo n; warppipe::NodeInfo n;
n.id = warppipe::NodeId{id}; n.id = warppipe::NodeId{id};
n.name = name; n.name = name;
n.media_class = media_class; n.media_class = media_class;
n.application_name = app_name; n.application_name = app_name;
n.description = desc; n.description = desc;
n.is_virtual = is_virtual;
return n; return n;
} }
@ -86,12 +88,12 @@ TEST_CASE("classifyNode identifies hardware source") {
} }
TEST_CASE("classifyNode identifies virtual sink") { 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); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSink);
} }
TEST_CASE("classifyNode identifies virtual source") { 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); 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); 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") { TEST_CASE("classifyNode duplex treated as sink") {
auto n = MakeNode(8, "alsa_duplex", "Audio/Duplex"); auto n = MakeNode(8, "alsa_duplex", "Audio/Duplex");
REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kHardwareSink); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kHardwareSink);
@ -217,7 +239,7 @@ TEST_CASE("node style varies by type") {
REQUIRE(tc.client->Test_InsertNode( REQUIRE(tc.client->Test_InsertNode(
MakeNode(100040, "hw-sink", "Audio/Sink")).ok()); MakeNode(100040, "hw-sink", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertNode( 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( REQUIRE(tc.client->Test_InsertNode(
MakeNode(100042, "app-stream", "Stream/Output/Audio", "App")).ok()); MakeNode(100042, "app-stream", "Stream/Output/Audio", "App")).ok());