From 05d6c06603ad9ea18d40aa229c7991340a8aa22e Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 28 Jan 2026 11:19:34 -0700 Subject: [PATCH 1/6] Add delete --- PROJECT_PLAN.md | 6 +- src/gui/GraphEditorWidget.cpp | 341 +++++++++++++++++++++++++++- src/gui/GraphEditorWidget.h | 24 ++ src/gui/PipeWireGraphModel.cpp | 21 +- src/pipewire/pipewirecontroller.cpp | 42 ++++ src/pipewire/pipewirecontroller.h | 1 + 6 files changed, 429 insertions(+), 6 deletions(-) diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index aa4a027..059e902 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -1240,9 +1240,9 @@ private: **Estimated Time:** 1-2 weeks - [x] Integrate QUndoStack for all graph operations - [x] Implement command classes for link, volume, node operations -- [ ] Add keyboard shortcuts (Delete, Ctrl+D, Ctrl+Z, etc.) -- [ ] Implement context menus for nodes/canvas -- [ ] Add copy/paste/duplicate functionality +- [x] Add keyboard shortcuts (Delete, Ctrl+D, Ctrl+Z, etc.) +- [x] Implement context menus for nodes/canvas +- [x] Add copy/paste/duplicate functionality - [ ] **Acceptance Criteria:** Full undo/redo history, keyboard shortcuts work ### Milestone 7: Error Handling & Edge Cases diff --git a/src/gui/GraphEditorWidget.cpp b/src/gui/GraphEditorWidget.cpp index eecd3a4..0f16191 100644 --- a/src/gui/GraphEditorWidget.cpp +++ b/src/gui/GraphEditorWidget.cpp @@ -17,15 +17,24 @@ #include #include #include +#include #include #include #include #include +#include +#include +#include +#include +#include #include #include #include #include +#include +#include +#include #include "gui/ClickSlider.h" @@ -106,6 +115,8 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi m_model->loadLayout(); m_scene = new QtNodes::BasicGraphicsScene(*m_model, this); m_view = new QtNodes::GraphicsView(m_scene); + m_view->setFocusPolicy(Qt::StrongFocus); + m_view->viewport()->setFocusPolicy(Qt::StrongFocus); m_scene->setBackgroundBrush(QColor(28, 30, 34)); m_splitter = new QSplitter(this); @@ -218,10 +229,38 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi m_view->setContextMenuPolicy(Qt::ActionsContextMenu); + removeDefaultCopyPasteActions(); + auto *refreshAction = new QAction(QString("Refresh Graph"), m_view); connect(refreshAction, &QAction::triggered, this, &GraphEditorWidget::refreshGraph); m_view->addAction(refreshAction); + auto *deleteAction = new QAction(QString("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(QString("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(QString("Paste Selection"), m_view); + pasteAction->setShortcut(QKeySequence::Paste); + pasteAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(pasteAction, &QAction::triggered, [this]() { + pasteSelection(QPointF(30, 30)); + }); + m_view->addAction(pasteAction); + + auto *duplicateAction = new QAction(QString("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 *zoomFitAllAction = new QAction(QString("Zoom Fit All"), m_view); connect(zoomFitAllAction, &QAction::triggered, m_view, &QtNodes::GraphicsView::zoomFitAll); m_view->addAction(zoomFitAllAction); @@ -392,6 +431,9 @@ void GraphEditorWidget::refreshGraph() m_linkIdToConnection.clear(); m_nodeLinkCounts.clear(); m_linksById.clear(); + m_pendingPastePositions.clear(); + m_pendingPasteVolumes.clear(); + m_pendingPasteLinks.clear(); m_model->reset(); syncGraph(); @@ -403,9 +445,21 @@ void GraphEditorWidget::refreshGraph() void GraphEditorWidget::onNodeAdded(const Potato::NodeInfo &node) { if (isAudioEndpoint(node)) { - m_model->addPipeWireNode(node); + const QtNodes::NodeId nodeId = m_model->addPipeWireNode(node); refreshNodeMeter(node.id, node); refreshMixerStrip(node.id, node); + if (m_pendingPastePositions.contains(node.stableId)) { + const QPointF pos = m_pendingPastePositions.take(node.stableId); + m_model->setNodeData(nodeId, QtNodes::NodeRole::Position, pos); + updateLayoutState(); + } + if (m_pendingPasteVolumes.contains(node.stableId)) { + const NodeVolumeState state = m_pendingPasteVolumes.take(node.stableId); + m_controller->setNodeVolume(node.id, state.volume, state.mute); + m_model->setNodeVolumeState(node.id, state, false); + updateMixerState(node.id, node); + } + tryResolvePendingLinks(); } } @@ -423,6 +477,7 @@ void GraphEditorWidget::onNodeChanged(const Potato::NodeInfo &node) updateNodeMeterState(node.id, node); refreshMixerStrip(node.id, node); updateMixerState(node.id, node); + tryResolvePendingLinks(); } void GraphEditorWidget::onNodeRemoved(uint32_t nodeId) @@ -1061,3 +1116,287 @@ void GraphEditorWidget::pushVolumeCommand(uint32_t nodeId, const NodeVolumeState } m_scene->undoStack().push(new VolumeChangeCommand(this, nodeId, previous, next)); } + +void GraphEditorWidget::removeDefaultCopyPasteActions() +{ + const QList actions = m_view->actions(); + for (QAction *action : actions) { + const QString text = action->text(); + if (text.contains("Copy Selection") + || text.contains("Paste Selection") + || text.contains("Duplicate Selection") + || text.contains("Delete Selection")) { + m_view->removeAction(action); + action->deleteLater(); + } + } +} + +QString GraphEditorWidget::mediaClassToString(Potato::MediaClass mediaClass) const +{ + switch (mediaClass) { + case Potato::MediaClass::AudioSink: + return QString("Audio/Sink"); + case Potato::MediaClass::AudioSource: + return QString("Audio/Source"); + case Potato::MediaClass::AudioDuplex: + return QString("Audio/Duplex"); + case Potato::MediaClass::Stream: + return QString("Stream"); + default: + return QString(); + } +} + +Potato::MediaClass GraphEditorWidget::mediaClassFromString(const QString &value) const +{ + if (value.contains("Audio/Source")) { + return Potato::MediaClass::AudioSource; + } + if (value.contains("Audio/Duplex")) { + return Potato::MediaClass::AudioDuplex; + } + if (value.contains("Audio/Sink")) { + return Potato::MediaClass::AudioSink; + } + if (value.contains("Stream")) { + return Potato::MediaClass::Stream; + } + return Potato::MediaClass::Unknown; +} + +bool GraphEditorWidget::findNodeByStableId(const QString &stableId, Potato::NodeInfo &node) const +{ + const QVector nodes = m_controller->nodes(); + for (const auto &item : nodes) { + if (item.stableId == stableId) { + node = item; + return true; + } + } + return false; +} + +bool GraphEditorWidget::findPortByName(const Potato::NodeInfo &node, const QString &name, bool output, uint32_t &portId) const +{ + const auto &ports = output ? node.outputPorts : node.inputPorts; + for (const auto &port : ports) { + if (port.name == name) { + portId = port.id; + return true; + } + } + return false; +} + +void GraphEditorWidget::copySelection() +{ + QJsonArray nodesJson; + QSet selectedStableIds; + QPointF sum; + int count = 0; + + const QList items = m_scene->selectedItems(); + for (auto *item : items) { + if (auto *nodeObj = qgraphicsitem_cast(item)) { + const Potato::NodeInfo *info = m_model->nodeInfo(nodeObj->nodeId()); + if (!info || info->type != Potato::NodeType::Virtual || info->stableId.isEmpty()) { + continue; + } + QJsonObject nodeJson; + nodeJson["stable_id"] = info->stableId; + nodeJson["name"] = info->name; + nodeJson["description"] = info->description; + nodeJson["media_class"] = mediaClassToString(info->mediaClass); + const int channels = std::max(info->inputPorts.size(), info->outputPorts.size()); + nodeJson["channels"] = channels > 0 ? channels : 2; + nodeJson["rate"] = 48000; + const QPointF pos = m_model->nodeData(nodeObj->nodeId(), QtNodes::NodeRole::Position).toPointF(); + nodeJson["x"] = pos.x(); + nodeJson["y"] = pos.y(); + NodeVolumeState state; + if (m_model->nodeVolumeState(info->id, state)) { + nodeJson["volume"] = state.volume; + nodeJson["mute"] = state.mute; + } + nodesJson.append(nodeJson); + selectedStableIds.insert(info->stableId); + sum += pos; + ++count; + } + } + + if (nodesJson.isEmpty()) { + return; + } + + QJsonArray linksJson; + const QVector links = m_controller->links(); + for (const auto &link : links) { + const Potato::NodeInfo outNode = m_controller->nodeById(link.outputNodeId); + const Potato::NodeInfo inNode = m_controller->nodeById(link.inputNodeId); + if (!selectedStableIds.contains(outNode.stableId) || !selectedStableIds.contains(inNode.stableId)) { + continue; + } + QString outPortName; + QString inPortName; + for (const auto &port : outNode.outputPorts) { + if (port.id == link.outputPortId) { + outPortName = port.name; + break; + } + } + for (const auto &port : inNode.inputPorts) { + if (port.id == link.inputPortId) { + inPortName = port.name; + break; + } + } + if (outPortName.isEmpty() || inPortName.isEmpty()) { + continue; + } + QJsonObject linkJson; + linkJson["source"] = QString("%1:%2").arg(outNode.stableId, outPortName); + linkJson["target"] = QString("%1:%2").arg(inNode.stableId, inPortName); + linksJson.append(linkJson); + } + + const QPointF center = count > 0 ? sum / count : QPointF(0, 0); + QJsonObject root; + root["nodes"] = nodesJson; + root["links"] = linksJson; + root["center_x"] = center.x(); + root["center_y"] = center.y(); + root["version"] = QString("1.0"); + m_clipboardJson = root; + + QJsonDocument doc(root); + auto *mime = new QMimeData(); + mime->setData("application/potato-virtual-graph", doc.toJson(QJsonDocument::Compact)); + mime->setText(doc.toJson(QJsonDocument::Compact)); + QGuiApplication::clipboard()->setMimeData(mime); +} + +void GraphEditorWidget::pasteSelection(const QPointF &offset) +{ + QJsonObject root; + const QMimeData *mime = QGuiApplication::clipboard()->mimeData(); + if (mime && mime->hasFormat("application/potato-virtual-graph")) { + root = QJsonDocument::fromJson(mime->data("application/potato-virtual-graph")).object(); + } else if (!m_clipboardJson.isEmpty()) { + root = m_clipboardJson; + } + + if (root.isEmpty()) { + return; + } + + QHash stableMap; + QSet existingNames; + for (const auto &node : m_controller->nodes()) { + existingNames.insert(node.name); + } + + const QJsonArray nodesJson = root.value("nodes").toArray(); + for (const auto &entry : nodesJson) { + const QJsonObject nodeObj = entry.toObject(); + const QString baseName = nodeObj.value("name").toString(); + const QString description = nodeObj.value("description").toString(); + const QString stableId = nodeObj.value("stable_id").toString(); + const QString mediaClassValue = nodeObj.value("media_class").toString(); + if (baseName.isEmpty() || stableId.isEmpty()) { + continue; + } + QString newName = baseName + QString(" Copy"); + int suffix = 2; + while (existingNames.contains(newName)) { + newName = baseName + QString(" Copy %1").arg(suffix++); + } + existingNames.insert(newName); + stableMap.insert(stableId, newName); + + const int channels = nodeObj.value("channels").toInt(2); + const int rate = nodeObj.value("rate").toInt(48000); + const Potato::MediaClass mediaClass = mediaClassFromString(mediaClassValue); + const QString newDescription = description.isEmpty() ? newName : description; + + if (mediaClass == Potato::MediaClass::AudioSource) { + m_controller->createVirtualSource(newName, newDescription, channels, rate); + } else { + m_controller->createVirtualSink(newName, newDescription, channels, rate); + } + + const QPointF pos(nodeObj.value("x").toDouble(), nodeObj.value("y").toDouble()); + m_pendingPastePositions.insert(newName, pos + offset); + const float volume = static_cast(nodeObj.value("volume").toDouble(1.0)); + const bool mute = nodeObj.value("mute").toBool(false); + m_pendingPasteVolumes.insert(newName, NodeVolumeState{volume, mute}); + } + + const QJsonArray linksJson = root.value("links").toArray(); + for (const auto &entry : linksJson) { + const QJsonObject linkObj = entry.toObject(); + const QString source = linkObj.value("source").toString(); + const QString target = linkObj.value("target").toString(); + const int splitSource = source.lastIndexOf(':'); + const int splitTarget = target.lastIndexOf(':'); + if (splitSource <= 0 || splitTarget <= 0) { + continue; + } + const QString outStable = source.left(splitSource); + const QString outPort = source.mid(splitSource + 1); + const QString inStable = target.left(splitTarget); + const QString inPort = target.mid(splitTarget + 1); + if (!stableMap.contains(outStable) || !stableMap.contains(inStable)) { + continue; + } + PendingPasteLink link; + link.outStableId = stableMap.value(outStable); + link.outPortName = outPort; + link.inStableId = stableMap.value(inStable); + link.inPortName = inPort; + m_pendingPasteLinks.append(link); + } + + tryResolvePendingLinks(); +} + +void GraphEditorWidget::duplicateSelection() +{ + copySelection(); + pasteSelection(QPointF(40, 40)); +} + +void GraphEditorWidget::deleteSelection() +{ + if (m_scene) { + m_scene->undoStack().push(new QtNodes::DeleteCommand(m_scene)); + } +} + +void GraphEditorWidget::tryResolvePendingLinks() +{ + if (m_pendingPasteLinks.isEmpty()) { + return; + } + + QVector remaining; + for (const auto &pending : m_pendingPasteLinks) { + Potato::NodeInfo outNode; + Potato::NodeInfo inNode; + if (!findNodeByStableId(pending.outStableId, outNode) + || !findNodeByStableId(pending.inStableId, inNode)) { + remaining.append(pending); + continue; + } + uint32_t outPortId = 0; + uint32_t inPortId = 0; + if (!findPortByName(outNode, pending.outPortName, true, outPortId) + || !findPortByName(inNode, pending.inPortName, false, inPortId)) { + remaining.append(pending); + continue; + } + m_controller->createLink(outNode.id, outPortId, inNode.id, inPortId); + } + m_pendingPasteLinks = remaining; +} diff --git a/src/gui/GraphEditorWidget.h b/src/gui/GraphEditorWidget.h index 31977f4..6c1e70e 100644 --- a/src/gui/GraphEditorWidget.h +++ b/src/gui/GraphEditorWidget.h @@ -11,6 +11,9 @@ #include #include #include +#include +#include +#include class AudioLevelMeter; class QLabel; @@ -57,6 +60,16 @@ private: void applySoloState(); void applyVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool updateMixer); void pushVolumeCommand(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &next); + void copySelection(); + void pasteSelection(const QPointF &offset); + void duplicateSelection(); + void deleteSelection(); + void tryResolvePendingLinks(); + bool findNodeByStableId(const QString &stableId, Potato::NodeInfo &node) const; + bool findPortByName(const Potato::NodeInfo &node, const QString &name, bool output, uint32_t &portId) const; + QString mediaClassToString(Potato::MediaClass mediaClass) const; + Potato::MediaClass mediaClassFromString(const QString &value) const; + void removeDefaultCopyPasteActions(); void handleLinkRemoved(uint32_t linkId); bool isMeterNode(uint32_t nodeId) const; int activeLinkCount(uint32_t nodeId) const; @@ -65,6 +78,13 @@ private: bool eventFilter(QObject *object, QEvent *event) override; Potato::PipeWireController *m_controller = nullptr; + struct PendingPasteLink { + QString outStableId; + QString outPortName; + QString inStableId; + QString inPortName; + }; + PipeWireGraphModel *m_model = nullptr; QtNodes::BasicGraphicsScene *m_scene = nullptr; QtNodes::GraphicsView *m_view = nullptr; @@ -83,6 +103,10 @@ private: QMap m_mixerStartState; QMap m_mixerLastState; QSet m_mixerSoloNodes; + QJsonObject m_clipboardJson; + QHash m_pendingPastePositions; + QHash m_pendingPasteVolumes; + QVector m_pendingPasteLinks; QSet m_ignoreCreate; QSet m_ignoreDelete; diff --git a/src/gui/PipeWireGraphModel.cpp b/src/gui/PipeWireGraphModel.cpp index a4973f1..c89f7ee 100644 --- a/src/gui/PipeWireGraphModel.cpp +++ b/src/gui/PipeWireGraphModel.cpp @@ -498,6 +498,16 @@ bool PipeWireGraphModel::deleteNode(QtNodes::NodeId const nodeId) return false; } + const Potato::NodeInfo info = m_nodes.at(nodeId); + if (info.type != Potato::NodeType::Virtual) { + return false; + } + + const Potato::NodeInfo liveNode = m_controller ? m_controller->nodeById(info.id) : Potato::NodeInfo{}; + if (liveNode.isValid()) { + m_controller->destroyVirtualNode(info.id); + } + std::vector toRemove; for (const auto &conn : m_connections) { if (conn.outNodeId == nodeId || conn.inNodeId == nodeId) { @@ -517,9 +527,16 @@ bool PipeWireGraphModel::deleteNode(QtNodes::NodeId const nodeId) return true; } -QJsonObject PipeWireGraphModel::saveNode(QtNodes::NodeId const) const +QJsonObject PipeWireGraphModel::saveNode(QtNodes::NodeId const nodeId) const { - return QJsonObject(); + QJsonObject obj; + obj["id"] = static_cast(nodeId); + const QPointF pos = nodeData(nodeId, QtNodes::NodeRole::Position).toPointF(); + QJsonObject posJson; + posJson["x"] = pos.x(); + posJson["y"] = pos.y(); + obj["position"] = posJson; + return obj; } void PipeWireGraphModel::loadNode(QJsonObject const &) diff --git a/src/pipewire/pipewirecontroller.cpp b/src/pipewire/pipewirecontroller.cpp index c85d56c..9c03aaf 100644 --- a/src/pipewire/pipewirecontroller.cpp +++ b/src/pipewire/pipewirecontroller.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -557,6 +558,47 @@ bool PipeWireController::createVirtualSource(const QString &name, const QString return createVirtualDevice(name, description, "support.null-audio-sink", "Audio/Source", channels, rate); } +bool PipeWireController::destroyVirtualNode(uint32_t nodeId) +{ + if (!m_threadLoop || !m_core || !m_registry) { + return false; + } + + const NodeInfo node = nodeById(nodeId); + if (!node.isValid()) { + return false; + } + + if (node.type != NodeType::Virtual && !node.name.startsWith("Potato_")) { + return false; + } + + bool destroyed = false; + lock(); + for (auto it = m_virtualDevices.begin(); it != m_virtualDevices.end(); ) { + struct pw_proxy *proxy = *it; + if (proxy && pw_proxy_get_bound_id(proxy) == nodeId) { + pw_proxy_destroy(proxy); + it = m_virtualDevices.erase(it); + destroyed = true; + } else { + ++it; + } + } + + if (!destroyed) { + auto *proxy = static_cast( + pw_registry_bind(m_registry, nodeId, PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, 0)); + if (proxy) { + pw_proxy_destroy(proxy); + destroyed = true; + } + } + unlock(); + + return destroyed; +} + float PipeWireController::nodeMeterPeak(uint32_t nodeId) const { QMutexLocker lock(&m_meterMutex); diff --git a/src/pipewire/pipewirecontroller.h b/src/pipewire/pipewirecontroller.h index dd07e2f..eb9579b 100644 --- a/src/pipewire/pipewirecontroller.h +++ b/src/pipewire/pipewirecontroller.h @@ -47,6 +47,7 @@ public: bool setNodeVolume(uint32_t nodeId, float volume, bool mute); bool createVirtualSink(const QString &name, const QString &description, int channels, int rate); bool createVirtualSource(const QString &name, const QString &description, int channels, int rate); + bool destroyVirtualNode(uint32_t nodeId); uint32_t createLink(uint32_t outputNodeId, uint32_t outputPortId, uint32_t inputNodeId, uint32_t inputPortId); From 453003cb2582da096e0a4931cdde13acc68de9fa Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 28 Jan 2026 11:58:18 -0700 Subject: [PATCH 2/6] Undo deletion --- src/gui/GraphEditorWidget.cpp | 144 +++++++++++++++++++++++++++++++++- src/gui/GraphEditorWidget.h | 2 + 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/gui/GraphEditorWidget.cpp b/src/gui/GraphEditorWidget.cpp index 0f16191..86e0bdf 100644 --- a/src/gui/GraphEditorWidget.cpp +++ b/src/gui/GraphEditorWidget.cpp @@ -74,6 +74,115 @@ private: NodeVolumeState m_next{}; }; +class DeleteVirtualNodeCommand : public QUndoCommand +{ +public: + struct VirtualNodeData { + QString name; + QString description; + Potato::MediaClass mediaClass; + int channels; + int rate; + QPointF position; + NodeVolumeState volumeState; + }; + + DeleteVirtualNodeCommand(GraphEditorWidget *widget, + QtNodes::BasicGraphicsScene *scene, + const QList &nodeIds) + : m_widget(widget) + , m_scene(scene) + { + if (!widget || !scene) { + return; + } + + PipeWireGraphModel *model = widget->m_model; + if (!model) { + return; + } + + for (const auto nodeId : nodeIds) { + const Potato::NodeInfo *info = model->nodeInfo(nodeId); + if (!info || info->type != Potato::NodeType::Virtual) { + continue; + } + + VirtualNodeData data; + data.name = info->name; + data.description = info->description; + data.mediaClass = info->mediaClass; + const int inPortSize = static_cast(info->inputPorts.size()); + const int outPortSize = static_cast(info->outputPorts.size()); + data.channels = (inPortSize > outPortSize) ? inPortSize : outPortSize; + if (data.channels == 0) { + data.channels = 2; + } + data.rate = 48000; + data.position = model->nodeData(nodeId, QtNodes::NodeRole::Position).toPointF(); + + NodeVolumeState volumeState; + if (model->nodeVolumeState(info->id, volumeState)) { + data.volumeState = volumeState; + } + + m_virtualNodes.append(data); + m_nodeIds.append(nodeId); + } + + setText(QString("Delete Virtual Node")); + } + + void undo() override + { + if (!m_widget || m_virtualNodes.isEmpty()) { + return; + } + + auto *controller = m_widget->m_controller; + auto *model = m_widget->m_model; + if (!controller || !model) { + return; + } + + for (const auto &data : m_virtualNodes) { + bool success = false; + if (data.mediaClass == Potato::MediaClass::AudioSource) { + success = controller->createVirtualSource(data.name, data.description, data.channels, data.rate); + } else { + success = controller->createVirtualSink(data.name, data.description, data.channels, data.rate); + } + + if (success) { + m_widget->m_pendingPastePositions.insert(data.name, data.position); + m_widget->m_pendingPasteVolumes.insert(data.name, data.volumeState); + } + } + } + + void redo() override + { + if (!m_widget) { + return; + } + + PipeWireGraphModel *model = m_widget->m_model; + if (!model) { + return; + } + + for (const auto nodeId : m_nodeIds) { + model->deleteNode(nodeId); + } + } + +private: + GraphEditorWidget *m_widget = nullptr; + QtNodes::BasicGraphicsScene *m_scene = nullptr; + QList m_virtualNodes; + QList m_nodeIds; +}; + GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent) : QWidget(parent) , m_controller(controller) @@ -1369,7 +1478,40 @@ void GraphEditorWidget::duplicateSelection() void GraphEditorWidget::deleteSelection() { - if (m_scene) { + if (!m_scene) { + return; + } + + const QList items = m_scene->selectedItems(); + QList virtualNodeIds; + QList otherNodeIds; + + for (auto *item : items) { + if (auto *nodeObj = qgraphicsitem_cast(item)) { + const Potato::NodeInfo *info = m_model->nodeInfo(nodeObj->nodeId()); + if (info && info->type == Potato::NodeType::Virtual) { + virtualNodeIds.append(nodeObj->nodeId()); + } else if (info) { + otherNodeIds.append(nodeObj->nodeId()); + } + } + } + + if (!virtualNodeIds.isEmpty()) { + m_scene->undoStack().push(new DeleteVirtualNodeCommand(this, m_scene, virtualNodeIds)); + } + + if (!otherNodeIds.isEmpty()) { + m_scene->clearSelection(); + for (const auto nodeId : otherNodeIds) { + if (const auto *nodeObj = m_scene->nodeGraphicsObject(nodeId)) { + const_cast(nodeObj)->setSelected(true); + } + } + m_scene->undoStack().push(new QtNodes::DeleteCommand(m_scene)); + } + + if (virtualNodeIds.isEmpty() && otherNodeIds.isEmpty() && !items.isEmpty()) { m_scene->undoStack().push(new QtNodes::DeleteCommand(m_scene)); } } diff --git a/src/gui/GraphEditorWidget.h b/src/gui/GraphEditorWidget.h index 6c1e70e..2580504 100644 --- a/src/gui/GraphEditorWidget.h +++ b/src/gui/GraphEditorWidget.h @@ -27,12 +27,14 @@ class QSplitter; class QTabWidget; class PresetManager; class VolumeChangeCommand; +class DeleteVirtualNodeCommand; class GraphEditorWidget : public QWidget { Q_OBJECT friend class VolumeChangeCommand; + friend class DeleteVirtualNodeCommand; public: explicit GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent = nullptr); From 4796f6f5d72a15ef65acb66c7fa274a0d9cdb5eb Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 28 Jan 2026 12:28:28 -0700 Subject: [PATCH 3/6] Delete --- src/gui/GraphEditorWidget.cpp | 35 ++++++++++++++++++++++++++--- src/pipewire/pipewirecontroller.cpp | 1 - 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/gui/GraphEditorWidget.cpp b/src/gui/GraphEditorWidget.cpp index 86e0bdf..57f4505 100644 --- a/src/gui/GraphEditorWidget.cpp +++ b/src/gui/GraphEditorWidget.cpp @@ -78,6 +78,8 @@ class DeleteVirtualNodeCommand : public QUndoCommand { public: struct VirtualNodeData { + uint32_t pipewireNodeId; + QtNodes::NodeId qtNodeId; QString name; QString description; Potato::MediaClass mediaClass; @@ -109,6 +111,8 @@ public: } VirtualNodeData data; + data.pipewireNodeId = info->id; + data.qtNodeId = nodeId; data.name = info->name; data.description = info->description; data.mediaClass = info->mediaClass; @@ -166,13 +170,38 @@ public: return; } + auto *controller = m_widget->m_controller; PipeWireGraphModel *model = m_widget->m_model; - if (!model) { + if (!controller || !model) { return; } - for (const auto nodeId : m_nodeIds) { - model->deleteNode(nodeId); + for (int i = 0; i < m_virtualNodes.size(); ++i) { + VirtualNodeData &data = m_virtualNodes[i]; + + const Potato::NodeInfo *nodeInfo = model->nodeInfo(data.qtNodeId); + if (nodeInfo && nodeInfo->type == Potato::NodeType::Virtual) { + controller->destroyVirtualNode(data.pipewireNodeId); + } else { + const QVector nodes = controller->nodes(); + for (const auto &node : nodes) { + if (node.name == data.name && node.type == Potato::NodeType::Virtual) { + data.pipewireNodeId = node.id; + + const auto allNodes = model->allNodeIds(); + for (const auto qtId : allNodes) { + const Potato::NodeInfo *info = model->nodeInfo(qtId); + if (info && info->id == node.id) { + data.qtNodeId = qtId; + break; + } + } + + controller->destroyVirtualNode(node.id); + break; + } + } + } } } diff --git a/src/pipewire/pipewirecontroller.cpp b/src/pipewire/pipewirecontroller.cpp index 9c03aaf..5098dd6 100644 --- a/src/pipewire/pipewirecontroller.cpp +++ b/src/pipewire/pipewirecontroller.cpp @@ -104,7 +104,6 @@ bool PipeWireController::createVirtualDevice(const QString &name, { PW_KEY_MEDIA_CLASS, mediaClass }, { PW_KEY_AUDIO_CHANNELS, channelsBytes.constData() }, { PW_KEY_AUDIO_RATE, rateBytes.constData() }, - { "object.linger", "true" }, { PW_KEY_APP_NAME, "Potato-Manager" } }; From b2ef4764457e7b5d8cc6321248df0142b81ecadf Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 28 Jan 2026 12:37:54 -0700 Subject: [PATCH 4/6] Deletion works --- src/pipewire/pipewirecontroller.cpp | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/pipewire/pipewirecontroller.cpp b/src/pipewire/pipewirecontroller.cpp index 5098dd6..e4d0977 100644 --- a/src/pipewire/pipewirecontroller.cpp +++ b/src/pipewire/pipewirecontroller.cpp @@ -104,6 +104,7 @@ bool PipeWireController::createVirtualDevice(const QString &name, { PW_KEY_MEDIA_CLASS, mediaClass }, { PW_KEY_AUDIO_CHANNELS, channelsBytes.constData() }, { PW_KEY_AUDIO_RATE, rateBytes.constData() }, + { "object.linger", "true" }, { PW_KEY_APP_NAME, "Potato-Manager" } }; @@ -572,30 +573,21 @@ bool PipeWireController::destroyVirtualNode(uint32_t nodeId) return false; } - bool destroyed = false; lock(); + for (auto it = m_virtualDevices.begin(); it != m_virtualDevices.end(); ) { struct pw_proxy *proxy = *it; if (proxy && pw_proxy_get_bound_id(proxy) == nodeId) { - pw_proxy_destroy(proxy); it = m_virtualDevices.erase(it); - destroyed = true; } else { ++it; } } - - if (!destroyed) { - auto *proxy = static_cast( - pw_registry_bind(m_registry, nodeId, PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, 0)); - if (proxy) { - pw_proxy_destroy(proxy); - destroyed = true; - } - } + + const int result = pw_registry_destroy(m_registry, nodeId); unlock(); - return destroyed; + return result == 0; } float PipeWireController::nodeMeterPeak(uint32_t nodeId) const From b7cb84bb9baff11c8811af79053e21b627bb35e9 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 28 Jan 2026 12:41:03 -0700 Subject: [PATCH 5/6] Ports --- src/gui/GraphEditorWidget.cpp | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/gui/GraphEditorWidget.cpp b/src/gui/GraphEditorWidget.cpp index 57f4505..42d7037 100644 --- a/src/gui/GraphEditorWidget.cpp +++ b/src/gui/GraphEditorWidget.cpp @@ -719,18 +719,60 @@ void GraphEditorWidget::onConnectionCreated(QtNodes::ConnectionId const connecti } if (connectionId.outPortIndex >= static_cast(outInfo->outputPorts.size())) { + qWarning() << "Output port index out of bounds:" << connectionId.outPortIndex << ">=" << outInfo->outputPorts.size(); + m_model->deleteConnection(connectionId); return; } if (connectionId.inPortIndex >= static_cast(inInfo->inputPorts.size())) { + qWarning() << "Input port index out of bounds:" << connectionId.inPortIndex << ">=" << inInfo->inputPorts.size(); + m_model->deleteConnection(connectionId); return; } const uint32_t outputPortId = outInfo->outputPorts.at(connectionId.outPortIndex).id; const uint32_t inputPortId = inInfo->inputPorts.at(connectionId.inPortIndex).id; + const Potato::NodeInfo freshOutInfo = m_controller->nodeById(outInfo->id); + const Potato::NodeInfo freshInInfo = m_controller->nodeById(inInfo->id); + + if (!freshOutInfo.isValid() || !freshInInfo.isValid()) { + qWarning() << "Node no longer exists in PipeWire"; + m_model->deleteConnection(connectionId); + return; + } + + bool outputPortExists = false; + for (const auto &port : freshOutInfo.outputPorts) { + if (port.id == outputPortId) { + outputPortExists = true; + break; + } + } + + bool inputPortExists = false; + for (const auto &port : freshInInfo.inputPorts) { + if (port.id == inputPortId) { + inputPortExists = true; + break; + } + } + + if (!outputPortExists) { + qWarning() << "Output port" << outputPortId << "does not exist in PipeWire node" << outInfo->id; + m_model->deleteConnection(connectionId); + return; + } + + if (!inputPortExists) { + qWarning() << "Input port" << inputPortId << "does not exist in PipeWire node" << inInfo->id; + m_model->deleteConnection(connectionId); + return; + } + const uint32_t linkId = m_controller->createLink(outInfo->id, outputPortId, inInfo->id, inputPortId); if (linkId == 0) { + qWarning() << "Failed to create link between" << outInfo->id << ":" << outputPortId << "->" << inInfo->id << ":" << inputPortId; m_model->deleteConnection(connectionId); return; } From f4132ee37cf8bc5b04c373f9849e6edc1d90fae9 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 28 Jan 2026 12:55:02 -0700 Subject: [PATCH 6/6] Almost context location --- src/gui/GraphEditorWidget.cpp | 24 ++++++++++++++++++++---- src/gui/GraphEditorWidget.h | 1 + 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/gui/GraphEditorWidget.cpp b/src/gui/GraphEditorWidget.cpp index 42d7037..0963c45 100644 --- a/src/gui/GraphEditorWidget.cpp +++ b/src/gui/GraphEditorWidget.cpp @@ -472,23 +472,33 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi m_view->addAction(loadPresetAction); auto *createVirtualSinkAction = new QAction(QString("Create Virtual Sink"), m_view); + createVirtualSinkAction->setShortcutContext(Qt::WidgetShortcut); connect(createVirtualSinkAction, &QAction::triggered, [this]() { const int index = ++m_virtualSinkCount; const QString name = QString("Potato_Virtual_Sink_%1").arg(index); const QString description = QString("Virtual Sink %1").arg(index); + + m_pendingPastePositions.insert(name, m_lastContextMenuPos); + if (!m_controller->createVirtualSink(name, description, 2, 48000)) { qWarning() << "Failed to create virtual sink" << name; + m_pendingPastePositions.remove(name); } }); m_view->addAction(createVirtualSinkAction); auto *createVirtualSourceAction = new QAction(QString("Create Virtual Source"), m_view); + createVirtualSourceAction->setShortcutContext(Qt::WidgetShortcut); connect(createVirtualSourceAction, &QAction::triggered, [this]() { const int index = ++m_virtualSourceCount; const QString name = QString("Potato_Virtual_Source_%1").arg(index); const QString description = QString("Virtual Source %1").arg(index); + + m_pendingPastePositions.insert(name, m_lastContextMenuPos); + if (!m_controller->createVirtualSource(name, description, 2, 48000)) { qWarning() << "Failed to create virtual source" << name; + m_pendingPastePositions.remove(name); } }); m_view->addAction(createVirtualSourceAction); @@ -1048,12 +1058,18 @@ void GraphEditorWidget::updateNodeMeterLabel(QLabel *label) bool GraphEditorWidget::eventFilter(QObject *object, QEvent *event) { - if (auto *label = qobject_cast(object)) { - if (event->type() == QEvent::Resize || event->type() == QEvent::Show) { - updateNodeMeterLabel(label); + if (object == m_view->viewport()) { + if (event->type() == QEvent::MouseButtonPress) { + QMouseEvent *mouseEvent = static_cast(event); + if (mouseEvent->button() == Qt::MiddleButton) { + m_view->centerOn(m_view->mapToScene(mouseEvent->pos())); + return true; + } + } else if (event->type() == QEvent::ContextMenu) { + QContextMenuEvent *contextEvent = static_cast(event); + m_lastContextMenuPos = m_view->mapToScene(contextEvent->pos()); } } - return QWidget::eventFilter(object, event); } diff --git a/src/gui/GraphEditorWidget.h b/src/gui/GraphEditorWidget.h index 2580504..7f5e37c 100644 --- a/src/gui/GraphEditorWidget.h +++ b/src/gui/GraphEditorWidget.h @@ -134,4 +134,5 @@ private: int m_meterProfileFrames = 0; PresetManager *m_presetManager = nullptr; bool m_ignoreVolumeUndo = false; + QPointF m_lastContextMenuPos; };