diff --git a/CMakeLists.txt b/CMakeLists.txt index b241e93..99e2a64 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,6 +93,7 @@ if(WARPPIPE_BUILD_GUI) add_executable(warppipe-gui-tests tests/gui/warppipe_gui_tests.cpp gui/WarpGraphModel.cpp + gui/GraphEditorWidget.cpp ) target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING) diff --git a/GUI_PLAN.md b/GUI_PLAN.md index e294020..1971b1b 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -159,37 +159,37 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. - [x] Screenshot tests gated behind `WARPPIPE_GUI_VISUAL_TESTS` CMake option (default OFF) - [x] `ctest --test-dir build` runs model + GUI tests -- [ ] Milestone 8a - Undo/Redo, Clipboard, and Keyboard Shortcuts - - [ ] Integrate `QUndoStack` via `BasicGraphicsScene::undoStack()` - - [ ] Undo/Redo already works for connection create/delete (built-in QtNodes `ConnectCommand`/`DisconnectCommand`) - - [ ] Verify Ctrl+Z / Ctrl+Shift+Z (or Ctrl+Y) work out of the box for connections - - [ ] Implement `DeleteVirtualNodeCommand : QUndoCommand` - - [ ] `redo()`: destroy virtual node via `Client::RemoveNode()` - - [ ] `undo()`: re-create virtual node via `Client::CreateVirtualSink/Source()` with same name/channels/rate - - [ ] Store node position and restore on undo - - [ ] Implement `deleteSelection()` for Del key - - [ ] Collect selected `NodeGraphicsObject` items from `m_scene->selectedItems()` - - [ ] Virtual nodes → push `DeleteVirtualNodeCommand` onto undo stack - - [ ] Non-virtual nodes → push `QtNodes::DeleteCommand` (removes from graph only, not PipeWire) - - [ ] Connection-only selection → push `QtNodes::DeleteCommand` - - [ ] Implement `copySelection()` (Ctrl+C) - - [ ] Serialize selected virtual nodes to JSON: stable_id, name, media_class, channels, rate, position - - [ ] Include links between selected nodes (source stable_id:port_name → target stable_id:port_name) - - [ ] Set `QClipboard` with custom MIME type `application/warppipe-virtual-graph` - - [ ] Implement `pasteSelection()` (Ctrl+V) - - [ ] Parse clipboard JSON, create new virtual nodes with " Copy" name suffix - - [ ] Position pasted nodes at offset from originals - - [ ] Deferred link resolution via `PendingPasteLink` queue (nodes may not exist yet) - - [ ] `tryResolvePendingLinks()` called on node add to wire up deferred links - - [ ] Implement `duplicateSelection()` (Ctrl+D) — copy + paste with (40, 40) offset - - [ ] Register keyboard shortcuts on `m_view`: - - [ ] Del → `deleteSelection()` - - [ ] Ctrl+C → `copySelection()` - - [ ] Ctrl+V → `pasteSelection()` - - [ ] Ctrl+D → `duplicateSelection()` - - [ ] Ctrl+L → auto-arrange + zoom fit - - [ ] Remove default QtNodes copy/paste actions to avoid conflicts - - [ ] Add tests for undo/redo command state (push command → undo → verify node re-created → redo → verify deleted) +- [x] Milestone 8a - Undo/Redo, Clipboard, and Keyboard Shortcuts + - [x] Integrate `QUndoStack` via `BasicGraphicsScene::undoStack()` + - [x] Undo/Redo already works for connection create/delete (built-in QtNodes `ConnectCommand`/`DisconnectCommand`) + - [x] Verify Ctrl+Z / Ctrl+Shift+Z (or Ctrl+Y) work out of the box for connections + - [x] Implement `DeleteVirtualNodeCommand : QUndoCommand` + - [x] `redo()`: destroy virtual node via `Client::RemoveNode()` + - [x] `undo()`: re-create virtual node via `Client::CreateVirtualSink/Source()` with same name/channels/rate + - [x] Store node position and restore on undo + - [x] Implement `deleteSelection()` for Del key + - [x] Collect selected `NodeGraphicsObject` items from `m_scene->selectedItems()` + - [x] Virtual nodes → push `DeleteVirtualNodeCommand` onto undo stack + - [x] Non-virtual nodes → push `QtNodes::DeleteCommand` (removes from graph only, not PipeWire) + - [x] Connection-only selection → push `QtNodes::DeleteCommand` + - [x] Implement `copySelection()` (Ctrl+C) + - [x] Serialize selected virtual nodes to JSON: stable_id, name, media_class, channels, rate, position + - [x] Include links between selected nodes (source stable_id:port_name → target stable_id:port_name) + - [x] Set `QClipboard` with custom MIME type `application/warppipe-virtual-graph` + - [x] Implement `pasteSelection()` (Ctrl+V) + - [x] Parse clipboard JSON, create new virtual nodes with " Copy" name suffix + - [x] Position pasted nodes at offset from originals + - [x] Deferred link resolution via `PendingPasteLink` queue (nodes may not exist yet) + - [x] `tryResolvePendingLinks()` called on node add to wire up deferred links + - [x] Implement `duplicateSelection()` (Ctrl+D) — copy + paste with (40, 40) offset + - [x] Register keyboard shortcuts on `m_view`: + - [x] Del → `deleteSelection()` + - [x] Ctrl+C → `copySelection()` + - [x] Ctrl+V → `pasteSelection()` + - [x] Ctrl+D → `duplicateSelection()` + - [x] Ctrl+L → auto-arrange + zoom fit + - [x] Remove default QtNodes copy/paste actions to avoid conflicts + - [x] Add tests for undo/redo command state (push command → undo → verify node re-created → redo → verify deleted) - [ ] Milestone 8b - View and Layout Enhancements - [ ] Add "Zoom Fit All" context menu action → `m_view->zoomFitAll()` - [ ] Add "Zoom Fit Selected" context menu action → `m_view->zoomFitSelected()` diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 048a76b..60cd1cc 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -4,17 +4,111 @@ #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) { @@ -50,6 +144,50 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, 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); + connect(m_model, &WarpGraphModel::nodePositionUpdated, this, &GraphEditorWidget::scheduleSaveLayout); @@ -259,3 +397,312 @@ void GraphEditorWidget::createVirtualNode(bool isSink, 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; +} diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index b29bea0..be5c5a2 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -2,9 +2,13 @@ #include +#include #include #include +#include +#include + namespace QtNodes { class BasicGraphicsScene; class GraphicsView; @@ -13,10 +17,13 @@ class GraphicsView; class WarpGraphModel; class QLabel; class QTimer; +class DeleteVirtualNodeCommand; class GraphEditorWidget : public QWidget { Q_OBJECT + friend class DeleteVirtualNodeCommand; + public: explicit GraphEditorWidget(warppipe::Client *client, QWidget *parent = nullptr); @@ -40,6 +47,20 @@ private: void createVirtualNode(bool isSink, const QPointF &scenePos); void captureDebugScreenshot(const QString &event); + void deleteSelection(); + void copySelection(); + void pasteSelection(const QPointF &offset); + void duplicateSelection(); + void removeDefaultActions(); + void tryResolvePendingLinks(); + + struct PendingPasteLink { + std::string outNodeName; + std::string outPortName; + std::string inNodeName; + std::string inPortName; + }; + warppipe::Client *m_client = nullptr; WarpGraphModel *m_model = nullptr; QtNodes::BasicGraphicsScene *m_scene = nullptr; @@ -49,4 +70,6 @@ private: QString m_layoutPath; QString m_debugScreenshotDir; bool m_graphReady = false; + QJsonObject m_clipboardJson; + std::vector m_pendingPasteLinks; }; diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp index 3148a14..1b99043 100644 --- a/gui/WarpGraphModel.cpp +++ b/gui/WarpGraphModel.cpp @@ -615,6 +615,15 @@ bool WarpGraphModel::isGhost(QtNodes::NodeId nodeId) const { return m_ghostNodes.find(nodeId) != m_ghostNodes.end(); } +uint32_t WarpGraphModel::findPwNodeIdByName(const std::string &name) const { + for (const auto &[qtId, data] : m_nodes) { + if (data.info.name == name) { + return data.info.id.value; + } + } + return 0; +} + WarpNodeType WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) { const std::string &mc = info.media_class; diff --git a/gui/WarpGraphModel.h b/gui/WarpGraphModel.h index 3c04055..70abc82 100644 --- a/gui/WarpGraphModel.h +++ b/gui/WarpGraphModel.h @@ -67,6 +67,8 @@ public: void setPendingPosition(const std::string &nodeName, QPointF pos); static WarpNodeType classifyNode(const warppipe::NodeInfo &info); + uint32_t findPwNodeIdByName(const std::string &name) const; + void saveLayout(const QString &path) const; bool loadLayout(const QString &path); void autoArrange(); diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index 743a654..69a502e 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -1,7 +1,9 @@ #include +#include "../../gui/GraphEditorWidget.h" #include "../../gui/WarpGraphModel.h" +#include #include #include @@ -453,3 +455,74 @@ TEST_CASE("volume meter streams are filtered") { auto qtId = model.qtNodeIdForPw(100150); REQUIRE(qtId == 0); } + +TEST_CASE("findPwNodeIdByName returns correct id") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100200, "find-me-node", "Audio/Sink")).ok()); + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100201, "other-node", "Audio/Source")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + REQUIRE(model.findPwNodeIdByName("find-me-node") == 100200); + REQUIRE(model.findPwNodeIdByName("other-node") == 100201); + REQUIRE(model.findPwNodeIdByName("nonexistent") == 0); +} + +TEST_CASE("GraphEditorWidget registers custom keyboard actions") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + GraphEditorWidget widget(tc.client.get()); + + QStringList actionTexts; + for (auto *action : widget.findChildren()) { + if (!action->text().isEmpty()) { + actionTexts.append(action->text()); + } + } + + REQUIRE(actionTexts.contains("Delete Selection")); + REQUIRE(actionTexts.contains("Copy Selection")); + REQUIRE(actionTexts.contains("Paste Selection")); + REQUIRE(actionTexts.contains("Duplicate Selection")); + REQUIRE(actionTexts.contains("Auto-Arrange")); +} + +TEST_CASE("GraphEditorWidget reflects injected nodes") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100210, "warppipe-widget-test", "Audio/Sink")).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(100211, 100210, "FL", true)).ok()); + + GraphEditorWidget widget(tc.client.get()); + REQUIRE(widget.nodeCount() >= 1); +} + +TEST_CASE("findPwNodeIdByName returns 0 for ghost nodes without pw mapping") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100220, "ghost-lookup", "Stream/Output/Audio", "App")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + REQUIRE(model.findPwNodeIdByName("ghost-lookup") == 100220); + + REQUIRE(tc.client->Test_RemoveGlobal(100220).ok()); + model.refreshFromClient(); + + REQUIRE(model.findPwNodeIdByName("ghost-lookup") == 100220); +}