Compare commits

...

6 commits

Author SHA1 Message Date
f4132ee37c Almost context location 2026-01-28 12:55:02 -07:00
b7cb84bb9b Ports 2026-01-28 12:41:03 -07:00
b2ef476445 Deletion works 2026-01-28 12:37:54 -07:00
4796f6f5d7 Delete 2026-01-28 12:28:28 -07:00
453003cb25 Undo deletion 2026-01-28 11:58:18 -07:00
05d6c06603 Add delete 2026-01-28 11:19:34 -07:00
6 changed files with 656 additions and 10 deletions

View file

@ -1240,9 +1240,9 @@ private:
**Estimated Time:** 1-2 weeks **Estimated Time:** 1-2 weeks
- [x] Integrate QUndoStack for all graph operations - [x] Integrate QUndoStack for all graph operations
- [x] Implement command classes for link, volume, node operations - [x] Implement command classes for link, volume, node operations
- [ ] Add keyboard shortcuts (Delete, Ctrl+D, Ctrl+Z, etc.) - [x] Add keyboard shortcuts (Delete, Ctrl+D, Ctrl+Z, etc.)
- [ ] Implement context menus for nodes/canvas - [x] Implement context menus for nodes/canvas
- [ ] Add copy/paste/duplicate functionality - [x] Add copy/paste/duplicate functionality
- [ ] **Acceptance Criteria:** Full undo/redo history, keyboard shortcuts work - [ ] **Acceptance Criteria:** Full undo/redo history, keyboard shortcuts work
### Milestone 7: Error Handling & Edge Cases ### Milestone 7: Error Handling & Edge Cases

View file

@ -17,15 +17,24 @@
#include <QTimer> #include <QTimer>
#include <QUndoCommand> #include <QUndoCommand>
#include <QEvent> #include <QEvent>
#include <QGraphicsItem>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QScrollArea> #include <QScrollArea>
#include <QSizePolicy> #include <QSizePolicy>
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QClipboard>
#include <QGuiApplication>
#include <QJsonArray>
#include <QJsonDocument>
#include <QMimeData>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <QtNodes/GraphicsViewStyle> #include <QtNodes/GraphicsViewStyle>
#include <QtNodes/NodeStyle> #include <QtNodes/NodeStyle>
#include <QtNodes/internal/NodeGraphicsObject.hpp>
#include <QtNodes/internal/ConnectionGraphicsObject.hpp>
#include <QtNodes/internal/UndoCommands.hpp>
#include "gui/ClickSlider.h" #include "gui/ClickSlider.h"
@ -65,6 +74,144 @@ private:
NodeVolumeState m_next{}; NodeVolumeState m_next{};
}; };
class DeleteVirtualNodeCommand : public QUndoCommand
{
public:
struct VirtualNodeData {
uint32_t pipewireNodeId;
QtNodes::NodeId qtNodeId;
QString name;
QString description;
Potato::MediaClass mediaClass;
int channels;
int rate;
QPointF position;
NodeVolumeState volumeState;
};
DeleteVirtualNodeCommand(GraphEditorWidget *widget,
QtNodes::BasicGraphicsScene *scene,
const QList<QtNodes::NodeId> &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.pipewireNodeId = info->id;
data.qtNodeId = nodeId;
data.name = info->name;
data.description = info->description;
data.mediaClass = info->mediaClass;
const int inPortSize = static_cast<int>(info->inputPorts.size());
const int outPortSize = static_cast<int>(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;
}
auto *controller = m_widget->m_controller;
PipeWireGraphModel *model = m_widget->m_model;
if (!controller || !model) {
return;
}
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<Potato::NodeInfo> 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;
}
}
}
}
}
private:
GraphEditorWidget *m_widget = nullptr;
QtNodes::BasicGraphicsScene *m_scene = nullptr;
QList<VirtualNodeData> m_virtualNodes;
QList<QtNodes::NodeId> m_nodeIds;
};
GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent) GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent)
: QWidget(parent) : QWidget(parent)
, m_controller(controller) , m_controller(controller)
@ -106,6 +253,8 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
m_model->loadLayout(); m_model->loadLayout();
m_scene = new QtNodes::BasicGraphicsScene(*m_model, this); m_scene = new QtNodes::BasicGraphicsScene(*m_model, this);
m_view = new QtNodes::GraphicsView(m_scene); m_view = new QtNodes::GraphicsView(m_scene);
m_view->setFocusPolicy(Qt::StrongFocus);
m_view->viewport()->setFocusPolicy(Qt::StrongFocus);
m_scene->setBackgroundBrush(QColor(28, 30, 34)); m_scene->setBackgroundBrush(QColor(28, 30, 34));
m_splitter = new QSplitter(this); m_splitter = new QSplitter(this);
@ -218,10 +367,38 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
m_view->setContextMenuPolicy(Qt::ActionsContextMenu); m_view->setContextMenuPolicy(Qt::ActionsContextMenu);
removeDefaultCopyPasteActions();
auto *refreshAction = new QAction(QString("Refresh Graph"), m_view); auto *refreshAction = new QAction(QString("Refresh Graph"), m_view);
connect(refreshAction, &QAction::triggered, this, &GraphEditorWidget::refreshGraph); connect(refreshAction, &QAction::triggered, this, &GraphEditorWidget::refreshGraph);
m_view->addAction(refreshAction); 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); auto *zoomFitAllAction = new QAction(QString("Zoom Fit All"), m_view);
connect(zoomFitAllAction, &QAction::triggered, m_view, &QtNodes::GraphicsView::zoomFitAll); connect(zoomFitAllAction, &QAction::triggered, m_view, &QtNodes::GraphicsView::zoomFitAll);
m_view->addAction(zoomFitAllAction); m_view->addAction(zoomFitAllAction);
@ -295,23 +472,33 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
m_view->addAction(loadPresetAction); m_view->addAction(loadPresetAction);
auto *createVirtualSinkAction = new QAction(QString("Create Virtual Sink"), m_view); auto *createVirtualSinkAction = new QAction(QString("Create Virtual Sink"), m_view);
createVirtualSinkAction->setShortcutContext(Qt::WidgetShortcut);
connect(createVirtualSinkAction, &QAction::triggered, [this]() { connect(createVirtualSinkAction, &QAction::triggered, [this]() {
const int index = ++m_virtualSinkCount; const int index = ++m_virtualSinkCount;
const QString name = QString("Potato_Virtual_Sink_%1").arg(index); const QString name = QString("Potato_Virtual_Sink_%1").arg(index);
const QString description = QString("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)) { if (!m_controller->createVirtualSink(name, description, 2, 48000)) {
qWarning() << "Failed to create virtual sink" << name; qWarning() << "Failed to create virtual sink" << name;
m_pendingPastePositions.remove(name);
} }
}); });
m_view->addAction(createVirtualSinkAction); m_view->addAction(createVirtualSinkAction);
auto *createVirtualSourceAction = new QAction(QString("Create Virtual Source"), m_view); auto *createVirtualSourceAction = new QAction(QString("Create Virtual Source"), m_view);
createVirtualSourceAction->setShortcutContext(Qt::WidgetShortcut);
connect(createVirtualSourceAction, &QAction::triggered, [this]() { connect(createVirtualSourceAction, &QAction::triggered, [this]() {
const int index = ++m_virtualSourceCount; const int index = ++m_virtualSourceCount;
const QString name = QString("Potato_Virtual_Source_%1").arg(index); const QString name = QString("Potato_Virtual_Source_%1").arg(index);
const QString description = QString("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)) { if (!m_controller->createVirtualSource(name, description, 2, 48000)) {
qWarning() << "Failed to create virtual source" << name; qWarning() << "Failed to create virtual source" << name;
m_pendingPastePositions.remove(name);
} }
}); });
m_view->addAction(createVirtualSourceAction); m_view->addAction(createVirtualSourceAction);
@ -392,6 +579,9 @@ void GraphEditorWidget::refreshGraph()
m_linkIdToConnection.clear(); m_linkIdToConnection.clear();
m_nodeLinkCounts.clear(); m_nodeLinkCounts.clear();
m_linksById.clear(); m_linksById.clear();
m_pendingPastePositions.clear();
m_pendingPasteVolumes.clear();
m_pendingPasteLinks.clear();
m_model->reset(); m_model->reset();
syncGraph(); syncGraph();
@ -403,9 +593,21 @@ void GraphEditorWidget::refreshGraph()
void GraphEditorWidget::onNodeAdded(const Potato::NodeInfo &node) void GraphEditorWidget::onNodeAdded(const Potato::NodeInfo &node)
{ {
if (isAudioEndpoint(node)) { if (isAudioEndpoint(node)) {
m_model->addPipeWireNode(node); const QtNodes::NodeId nodeId = m_model->addPipeWireNode(node);
refreshNodeMeter(node.id, node); refreshNodeMeter(node.id, node);
refreshMixerStrip(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 +625,7 @@ void GraphEditorWidget::onNodeChanged(const Potato::NodeInfo &node)
updateNodeMeterState(node.id, node); updateNodeMeterState(node.id, node);
refreshMixerStrip(node.id, node); refreshMixerStrip(node.id, node);
updateMixerState(node.id, node); updateMixerState(node.id, node);
tryResolvePendingLinks();
} }
void GraphEditorWidget::onNodeRemoved(uint32_t nodeId) void GraphEditorWidget::onNodeRemoved(uint32_t nodeId)
@ -526,18 +729,60 @@ void GraphEditorWidget::onConnectionCreated(QtNodes::ConnectionId const connecti
} }
if (connectionId.outPortIndex >= static_cast<QtNodes::PortIndex>(outInfo->outputPorts.size())) { if (connectionId.outPortIndex >= static_cast<QtNodes::PortIndex>(outInfo->outputPorts.size())) {
qWarning() << "Output port index out of bounds:" << connectionId.outPortIndex << ">=" << outInfo->outputPorts.size();
m_model->deleteConnection(connectionId);
return; return;
} }
if (connectionId.inPortIndex >= static_cast<QtNodes::PortIndex>(inInfo->inputPorts.size())) { if (connectionId.inPortIndex >= static_cast<QtNodes::PortIndex>(inInfo->inputPorts.size())) {
qWarning() << "Input port index out of bounds:" << connectionId.inPortIndex << ">=" << inInfo->inputPorts.size();
m_model->deleteConnection(connectionId);
return; return;
} }
const uint32_t outputPortId = outInfo->outputPorts.at(connectionId.outPortIndex).id; const uint32_t outputPortId = outInfo->outputPorts.at(connectionId.outPortIndex).id;
const uint32_t inputPortId = inInfo->inputPorts.at(connectionId.inPortIndex).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); const uint32_t linkId = m_controller->createLink(outInfo->id, outputPortId, inInfo->id, inputPortId);
if (linkId == 0) { if (linkId == 0) {
qWarning() << "Failed to create link between" << outInfo->id << ":" << outputPortId << "->" << inInfo->id << ":" << inputPortId;
m_model->deleteConnection(connectionId); m_model->deleteConnection(connectionId);
return; return;
} }
@ -813,12 +1058,18 @@ void GraphEditorWidget::updateNodeMeterLabel(QLabel *label)
bool GraphEditorWidget::eventFilter(QObject *object, QEvent *event) bool GraphEditorWidget::eventFilter(QObject *object, QEvent *event)
{ {
if (auto *label = qobject_cast<QLabel*>(object)) { if (object == m_view->viewport()) {
if (event->type() == QEvent::Resize || event->type() == QEvent::Show) { if (event->type() == QEvent::MouseButtonPress) {
updateNodeMeterLabel(label); QMouseEvent *mouseEvent = static_cast<QMouseEvent*>(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<QContextMenuEvent*>(event);
m_lastContextMenuPos = m_view->mapToScene(contextEvent->pos());
} }
} }
return QWidget::eventFilter(object, event); return QWidget::eventFilter(object, event);
} }
@ -1061,3 +1312,320 @@ void GraphEditorWidget::pushVolumeCommand(uint32_t nodeId, const NodeVolumeState
} }
m_scene->undoStack().push(new VolumeChangeCommand(this, nodeId, previous, next)); 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) {
return;
}
const QList<QGraphicsItem*> items = m_scene->selectedItems();
QList<QtNodes::NodeId> virtualNodeIds;
QList<QtNodes::NodeId> otherNodeIds;
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) {
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<QtNodes::NodeGraphicsObject*>(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));
}
}
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 <QMap>
#include <QElapsedTimer> #include <QElapsedTimer>
#include <cstdint> #include <cstdint>
#include <QHash>
#include <QJsonObject>
#include <QVector>
class AudioLevelMeter; class AudioLevelMeter;
class QLabel; class QLabel;
@ -24,12 +27,14 @@ class QSplitter;
class QTabWidget; class QTabWidget;
class PresetManager; class PresetManager;
class VolumeChangeCommand; class VolumeChangeCommand;
class DeleteVirtualNodeCommand;
class GraphEditorWidget : public QWidget class GraphEditorWidget : public QWidget
{ {
Q_OBJECT Q_OBJECT
friend class VolumeChangeCommand; friend class VolumeChangeCommand;
friend class DeleteVirtualNodeCommand;
public: public:
explicit GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent = nullptr); explicit GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent = nullptr);
@ -57,6 +62,16 @@ private:
void applySoloState(); void applySoloState();
void applyVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool updateMixer); void applyVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool updateMixer);
void pushVolumeCommand(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &next); 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); void handleLinkRemoved(uint32_t linkId);
bool isMeterNode(uint32_t nodeId) const; bool isMeterNode(uint32_t nodeId) const;
int activeLinkCount(uint32_t nodeId) const; int activeLinkCount(uint32_t nodeId) const;
@ -65,6 +80,13 @@ private:
bool eventFilter(QObject *object, QEvent *event) override; bool eventFilter(QObject *object, QEvent *event) override;
Potato::PipeWireController *m_controller = nullptr; Potato::PipeWireController *m_controller = nullptr;
struct PendingPasteLink {
QString outStableId;
QString outPortName;
QString inStableId;
QString inPortName;
};
PipeWireGraphModel *m_model = nullptr; PipeWireGraphModel *m_model = nullptr;
QtNodes::BasicGraphicsScene *m_scene = nullptr; QtNodes::BasicGraphicsScene *m_scene = nullptr;
QtNodes::GraphicsView *m_view = nullptr; QtNodes::GraphicsView *m_view = nullptr;
@ -83,6 +105,10 @@ private:
QMap<uint32_t, NodeVolumeState> m_mixerStartState; QMap<uint32_t, NodeVolumeState> m_mixerStartState;
QMap<uint32_t, NodeVolumeState> m_mixerLastState; QMap<uint32_t, NodeVolumeState> m_mixerLastState;
QSet<uint32_t> m_mixerSoloNodes; 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_ignoreCreate;
QSet<QString> m_ignoreDelete; QSet<QString> m_ignoreDelete;
@ -108,4 +134,5 @@ private:
int m_meterProfileFrames = 0; int m_meterProfileFrames = 0;
PresetManager *m_presetManager = nullptr; PresetManager *m_presetManager = nullptr;
bool m_ignoreVolumeUndo = false; bool m_ignoreVolumeUndo = false;
QPointF m_lastContextMenuPos;
}; };

View file

@ -498,6 +498,16 @@ bool PipeWireGraphModel::deleteNode(QtNodes::NodeId const nodeId)
return false; 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; std::vector<QtNodes::ConnectionId> toRemove;
for (const auto &conn : m_connections) { for (const auto &conn : m_connections) {
if (conn.outNodeId == nodeId || conn.inNodeId == nodeId) { if (conn.outNodeId == nodeId || conn.inNodeId == nodeId) {
@ -517,9 +527,16 @@ bool PipeWireGraphModel::deleteNode(QtNodes::NodeId const nodeId)
return true; 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 &) void PipeWireGraphModel::loadNode(QJsonObject const &)

View file

@ -13,6 +13,7 @@
#include <pipewire/pipewire.h> #include <pipewire/pipewire.h>
#include <pipewire/keys.h> #include <pipewire/keys.h>
#include <pipewire/properties.h> #include <pipewire/properties.h>
#include <pipewire/proxy.h>
#include <pipewire/stream.h> #include <pipewire/stream.h>
#include <pipewire/node.h> #include <pipewire/node.h>
#include <spa/param/props.h> #include <spa/param/props.h>
@ -557,6 +558,38 @@ bool PipeWireController::createVirtualSource(const QString &name, const QString
return createVirtualDevice(name, description, "support.null-audio-sink", "Audio/Source", channels, rate); 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;
}
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) {
it = m_virtualDevices.erase(it);
} else {
++it;
}
}
const int result = pw_registry_destroy(m_registry, nodeId);
unlock();
return result == 0;
}
float PipeWireController::nodeMeterPeak(uint32_t nodeId) const float PipeWireController::nodeMeterPeak(uint32_t nodeId) const
{ {
QMutexLocker lock(&m_meterMutex); QMutexLocker lock(&m_meterMutex);

View file

@ -47,6 +47,7 @@ public:
bool setNodeVolume(uint32_t nodeId, float volume, bool mute); bool setNodeVolume(uint32_t nodeId, float volume, bool mute);
bool createVirtualSink(const QString &name, const QString &description, int channels, int rate); 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 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 createLink(uint32_t outputNodeId, uint32_t outputPortId,
uint32_t inputNodeId, uint32_t inputPortId); uint32_t inputNodeId, uint32_t inputPortId);