From 05d6c06603ad9ea18d40aa229c7991340a8aa22e Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 28 Jan 2026 11:19:34 -0700 Subject: [PATCH] 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);