|
|
|
|
@ -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"
|
|
|
|
|
|
|
|
|
|
@ -65,6 +74,144 @@ private:
|
|
|
|
|
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)
|
|
|
|
|
: QWidget(parent)
|
|
|
|
|
, m_controller(controller)
|
|
|
|
|
@ -106,6 +253,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 +367,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);
|
|
|
|
|
@ -295,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);
|
|
|
|
|
@ -392,6 +579,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 +593,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 +625,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)
|
|
|
|
|
@ -526,18 +729,60 @@ void GraphEditorWidget::onConnectionCreated(QtNodes::ConnectionId const connecti
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
@ -813,12 +1058,18 @@ void GraphEditorWidget::updateNodeMeterLabel(QLabel *label)
|
|
|
|
|
|
|
|
|
|
bool GraphEditorWidget::eventFilter(QObject *object, QEvent *event)
|
|
|
|
|
{
|
|
|
|
|
if (auto *label = qobject_cast<QLabel*>(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<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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -1061,3 +1312,320 @@ 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) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|