Add delete

This commit is contained in:
Joey Yakimowich-Payne 2026-01-28 11:19:34 -07:00
commit 05d6c06603
6 changed files with 429 additions and 6 deletions

View file

@ -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

View file

@ -17,15 +17,24 @@
#include <QTimer>
#include <QUndoCommand>
#include <QEvent>
#include <QGraphicsItem>
#include <QVBoxLayout>
#include <QScrollArea>
#include <QSizePolicy>
#include <QElapsedTimer>
#include <QClipboard>
#include <QGuiApplication>
#include <QJsonArray>
#include <QJsonDocument>
#include <QMimeData>
#include <algorithm>
#include <cmath>
#include <QtNodes/GraphicsViewStyle>
#include <QtNodes/NodeStyle>
#include <QtNodes/internal/NodeGraphicsObject.hpp>
#include <QtNodes/internal/ConnectionGraphicsObject.hpp>
#include <QtNodes/internal/UndoCommands.hpp>
#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<QAction*> 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<Potato::NodeInfo> 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<QString> selectedStableIds;
QPointF sum;
int count = 0;
const QList<QGraphicsItem*> items = m_scene->selectedItems();
for (auto *item : items) {
if (auto *nodeObj = qgraphicsitem_cast<QtNodes::NodeGraphicsObject*>(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<Potato::LinkInfo> 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<QString, QString> stableMap;
QSet<QString> 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<float>(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<PendingPasteLink> 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;
}

View file

@ -11,6 +11,9 @@
#include <QMap>
#include <QElapsedTimer>
#include <cstdint>
#include <QHash>
#include <QJsonObject>
#include <QVector>
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<uint32_t, NodeVolumeState> m_mixerStartState;
QMap<uint32_t, NodeVolumeState> m_mixerLastState;
QSet<uint32_t> m_mixerSoloNodes;
QJsonObject m_clipboardJson;
QHash<QString, QPointF> m_pendingPastePositions;
QHash<QString, NodeVolumeState> m_pendingPasteVolumes;
QVector<PendingPasteLink> m_pendingPasteLinks;
QSet<QString> m_ignoreCreate;
QSet<QString> m_ignoreDelete;

View file

@ -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<QtNodes::ConnectionId> 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<qint64>(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 &)

View file

@ -13,6 +13,7 @@
#include <pipewire/pipewire.h>
#include <pipewire/keys.h>
#include <pipewire/properties.h>
#include <pipewire/proxy.h>
#include <pipewire/stream.h>
#include <pipewire/node.h>
#include <spa/param/props.h>
@ -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<struct pw_proxy*>(
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);

View file

@ -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);