warp-pipe/gui/GraphEditorWidget.cpp

759 lines
23 KiB
C++

#include "GraphEditorWidget.h"
#include "WarpGraphModel.h"
#include <QtNodes/BasicGraphicsScene>
#include <QtNodes/ConnectionStyle>
#include <QtNodes/GraphicsView>
#include <QtNodes/internal/NodeGraphicsObject.hpp>
#include <QtNodes/internal/ConnectionGraphicsObject.hpp>
#include <QtNodes/internal/UndoCommands.hpp>
#include <QAction>
#include <QClipboard>
#include <QContextMenuEvent>
#include <QDateTime>
#include <QDir>
#include <QGraphicsItem>
#include <QGuiApplication>
#include <QInputDialog>
#include <QJsonArray>
#include <QJsonDocument>
#include <QMenu>
#include <QMessageBox>
#include <QMimeData>
#include <QPixmap>
#include <QStandardPaths>
#include <QTimer>
#include <QUndoCommand>
#include <QVBoxLayout>
#include <algorithm>
#include <unordered_map>
#include <unordered_set>
class DeleteVirtualNodeCommand : public QUndoCommand {
public:
struct Snapshot {
uint32_t pwNodeId;
QtNodes::NodeId qtNodeId;
std::string name;
std::string mediaClass;
QPointF position;
};
DeleteVirtualNodeCommand(GraphEditorWidget *widget,
const QList<QtNodes::NodeId> &nodeIds)
: m_widget(widget) {
WarpGraphModel *model = widget->m_model;
for (auto nodeId : nodeIds) {
const WarpNodeData *data = model->warpNodeData(nodeId);
if (!data)
continue;
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
if (type != WarpNodeType::kVirtualSink &&
type != WarpNodeType::kVirtualSource)
continue;
Snapshot snap;
snap.pwNodeId = data->info.id.value;
snap.qtNodeId = nodeId;
snap.name = data->info.name;
snap.mediaClass = data->info.media_class;
snap.position =
model->nodeData(nodeId, QtNodes::NodeRole::Position).toPointF();
m_snapshots.push_back(snap);
}
setText(QStringLiteral("Delete Virtual Node"));
}
void undo() override {
if (!m_widget)
return;
auto *client = m_widget->m_client;
auto *model = m_widget->m_model;
if (!client || !model)
return;
for (const auto &snap : m_snapshots) {
model->setPendingPosition(snap.name, snap.position);
bool isSink = snap.mediaClass == "Audio/Sink" ||
snap.mediaClass == "Audio/Duplex";
if (isSink) {
client->CreateVirtualSink(snap.name);
} else {
client->CreateVirtualSource(snap.name);
}
}
model->refreshFromClient();
}
void redo() override {
if (!m_widget)
return;
auto *client = m_widget->m_client;
auto *model = m_widget->m_model;
if (!client || !model)
return;
for (auto &snap : m_snapshots) {
uint32_t currentPwId = model->findPwNodeIdByName(snap.name);
if (currentPwId != 0) {
snap.pwNodeId = currentPwId;
client->RemoveNode(warppipe::NodeId{currentPwId});
}
}
model->refreshFromClient();
}
private:
GraphEditorWidget *m_widget = nullptr;
std::vector<Snapshot> m_snapshots;
};
GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
QWidget *parent)
: QWidget(parent), m_client(client) {
m_layoutPath =
QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) +
QStringLiteral("/layout.json");
m_model = new WarpGraphModel(client, this);
bool hasLayout = m_model->loadLayout(m_layoutPath);
m_scene = new QtNodes::BasicGraphicsScene(*m_model, this);
QtNodes::ConnectionStyle::setConnectionStyle(
R"({"ConnectionStyle": {
"ConstructionColor": "#b4b4c8",
"NormalColor": "#c8c8dc",
"SelectedColor": "#ffa500",
"SelectedHaloColor": "#ffa50040",
"HoveredColor": "#f0c878",
"LineWidth": 2.4,
"ConstructionLineWidth": 1.8,
"PointDiameter": 10.0,
"UseDataDefinedColors": false
}})");
m_view = new QtNodes::GraphicsView(m_scene);
m_view->setFocusPolicy(Qt::StrongFocus);
m_view->viewport()->setFocusPolicy(Qt::StrongFocus);
m_view->viewport()->installEventFilter(this);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(m_view);
m_view->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_view, &QWidget::customContextMenuRequested, this,
&GraphEditorWidget::onContextMenuRequested);
removeDefaultActions();
auto *deleteAction =
new QAction(QStringLiteral("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(QStringLiteral("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(QStringLiteral("Paste Selection"), m_view);
pasteAction->setShortcut(QKeySequence::Paste);
pasteAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
connect(pasteAction, &QAction::triggered, this,
[this]() { pasteSelection(QPointF(30, 30)); });
m_view->addAction(pasteAction);
auto *duplicateAction =
new QAction(QStringLiteral("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 *autoArrangeAction =
new QAction(QStringLiteral("Auto-Arrange"), m_view);
autoArrangeAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_L));
autoArrangeAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
connect(autoArrangeAction, &QAction::triggered, this, [this]() {
m_model->autoArrange();
m_model->saveLayout(m_layoutPath);
});
m_view->addAction(autoArrangeAction);
connect(m_model, &WarpGraphModel::nodePositionUpdated, this,
&GraphEditorWidget::scheduleSaveLayout);
m_saveTimer = new QTimer(this);
m_saveTimer->setSingleShot(true);
m_saveTimer->setInterval(1000);
connect(m_saveTimer, &QTimer::timeout, this, [this]() {
m_model->saveLayout(m_layoutPath);
});
m_model->refreshFromClient();
if (!hasLayout) {
m_model->autoArrange();
}
if (m_model->allNodeIds().size() > 0) {
m_graphReady = true;
Q_EMIT graphReady();
}
m_refreshTimer = new QTimer(this);
connect(m_refreshTimer, &QTimer::timeout, this,
&GraphEditorWidget::onRefreshTimer);
m_refreshTimer->start(500);
}
void GraphEditorWidget::onRefreshTimer() {
m_model->refreshFromClient();
if (!m_graphReady && m_model->allNodeIds().size() > 0) {
m_graphReady = true;
Q_EMIT graphReady();
captureDebugScreenshot("initial_load");
}
}
void GraphEditorWidget::scheduleSaveLayout() {
if (!m_saveTimer->isActive()) {
m_saveTimer->start();
}
}
int GraphEditorWidget::nodeCount() const {
return static_cast<int>(m_model->allNodeIds().size());
}
int GraphEditorWidget::linkCount() const {
int count = 0;
for (auto nodeId : m_model->allNodeIds()) {
count += static_cast<int>(
m_model->allConnectionIds(nodeId).size());
}
return count / 2;
}
void GraphEditorWidget::setDebugScreenshotDir(const QString &dir) {
m_debugScreenshotDir = dir;
QDir d(dir);
if (!d.exists()) {
d.mkpath(".");
}
connect(m_model, &QtNodes::AbstractGraphModel::nodeCreated, this, [this]() {
captureDebugScreenshot("node_added");
});
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this, [this]() {
captureDebugScreenshot("node_removed");
});
connect(m_model, &QtNodes::AbstractGraphModel::connectionCreated, this,
[this]() { captureDebugScreenshot("connection_added"); });
connect(m_model, &QtNodes::AbstractGraphModel::connectionDeleted, this,
[this]() { captureDebugScreenshot("connection_removed"); });
connect(m_model, &QtNodes::AbstractGraphModel::nodeUpdated, this, [this]() {
captureDebugScreenshot("node_updated");
});
if (m_graphReady) {
QTimer::singleShot(200, this, [this]() {
captureDebugScreenshot("initial_load");
});
}
}
void GraphEditorWidget::captureDebugScreenshot(const QString &event) {
if (m_debugScreenshotDir.isEmpty()) {
return;
}
QWidget *win = window();
if (!win) {
return;
}
QPixmap pixmap = win->grab();
if (pixmap.isNull()) {
return;
}
QString timestamp =
QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
QString filename = QString("warppipe_%1_%2.png").arg(timestamp, event);
pixmap.save(m_debugScreenshotDir + "/" + filename);
}
bool GraphEditorWidget::eventFilter(QObject *obj, QEvent *event) {
if (obj == m_view->viewport() &&
event->type() == QEvent::ContextMenu) {
auto *cme = static_cast<QContextMenuEvent *>(event);
m_lastContextMenuScenePos = m_view->mapToScene(cme->pos());
}
return QWidget::eventFilter(obj, event);
}
void GraphEditorWidget::onContextMenuRequested(const QPoint &pos) {
QPointF scenePos = m_view->mapToScene(pos);
m_lastContextMenuScenePos = scenePos;
uint32_t hitPwNodeId = 0;
QtNodes::NodeId hitQtNodeId = 0;
for (auto nodeId : m_model->allNodeIds()) {
const WarpNodeData *data = m_model->warpNodeData(nodeId);
if (!data) {
continue;
}
QPointF nodePos =
m_model->nodeData(nodeId, QtNodes::NodeRole::Position).toPointF();
QSize nodeSize =
m_model->nodeData(nodeId, QtNodes::NodeRole::Size).toSize();
QRectF nodeRect(nodePos, QSizeF(nodeSize));
if (nodeRect.contains(scenePos)) {
hitPwNodeId = data->info.id.value;
hitQtNodeId = nodeId;
break;
}
}
QPoint screenPos = m_view->mapToGlobal(pos);
if (hitPwNodeId != 0) {
showNodeContextMenu(screenPos, hitPwNodeId, hitQtNodeId);
} else {
showCanvasContextMenu(screenPos, scenePos);
}
}
void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
const QPointF &scenePos) {
QMenu menu;
QAction *createSink = menu.addAction(QStringLiteral("Create Virtual Sink"));
QAction *createSource =
menu.addAction(QStringLiteral("Create Virtual Source"));
menu.addSeparator();
QAction *pasteAction = menu.addAction(QStringLiteral("Paste"));
pasteAction->setShortcut(QKeySequence::Paste);
pasteAction->setEnabled(!m_clipboardJson.isEmpty() ||
(QGuiApplication::clipboard()->mimeData() &&
QGuiApplication::clipboard()->mimeData()->hasFormat(
QStringLiteral(
"application/warppipe-virtual-graph"))));
menu.addSeparator();
QAction *autoArrange = menu.addAction(QStringLiteral("Auto-Arrange"));
QAction *chosen = menu.exec(screenPos);
if (chosen == createSink) {
createVirtualNode(true, scenePos);
} else if (chosen == createSource) {
createVirtualNode(false, scenePos);
} else if (chosen == pasteAction) {
pasteSelection(QPointF(0, 0));
} else if (chosen == autoArrange) {
m_model->autoArrange();
}
}
void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos,
uint32_t pwNodeId,
QtNodes::NodeId qtNodeId) {
const WarpNodeData *data = m_model->warpNodeData(qtNodeId);
if (!data) {
return;
}
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
bool isVirtual =
type == WarpNodeType::kVirtualSink || type == WarpNodeType::kVirtualSource;
QMenu menu;
QAction *copyAction = nullptr;
QAction *duplicateAction = nullptr;
QAction *deleteAction = nullptr;
if (isVirtual) {
copyAction = menu.addAction(QStringLiteral("Copy"));
copyAction->setShortcut(QKeySequence::Copy);
duplicateAction = menu.addAction(QStringLiteral("Duplicate"));
duplicateAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D));
menu.addSeparator();
deleteAction = menu.addAction(QStringLiteral("Delete"));
deleteAction->setShortcut(QKeySequence::Delete);
}
QAction *pasteAction = menu.addAction(QStringLiteral("Paste"));
pasteAction->setShortcut(QKeySequence::Paste);
pasteAction->setEnabled(!m_clipboardJson.isEmpty() ||
(QGuiApplication::clipboard()->mimeData() &&
QGuiApplication::clipboard()->mimeData()->hasFormat(
QStringLiteral(
"application/warppipe-virtual-graph"))));
QAction *chosen = menu.exec(screenPos);
if (!chosen) {
return;
}
if (chosen == copyAction) {
copySelection();
} else if (chosen == duplicateAction) {
duplicateSelection();
} else if (chosen == deleteAction && m_client) {
deleteSelection();
} else if (chosen == pasteAction) {
pasteSelection(QPointF(0, 0));
}
}
void GraphEditorWidget::createVirtualNode(bool isSink,
const QPointF &scenePos) {
QString label = isSink ? QStringLiteral("Create Virtual Sink")
: QStringLiteral("Create Virtual Source");
bool ok = false;
QString name = QInputDialog::getText(this, label,
QStringLiteral("Node name:"),
QLineEdit::Normal, QString(), &ok);
if (!ok || name.trimmed().isEmpty()) {
return;
}
std::string nodeName = name.trimmed().toStdString();
m_model->setPendingPosition(nodeName, scenePos);
warppipe::Status status;
if (isSink) {
auto result = m_client->CreateVirtualSink(nodeName);
status = result.status;
} else {
auto result = m_client->CreateVirtualSource(nodeName);
status = result.status;
}
if (!status.ok()) {
QMessageBox::warning(this, QStringLiteral("Error"),
QString::fromStdString(status.message));
return;
}
m_model->refreshFromClient();
}
void GraphEditorWidget::removeDefaultActions() {
const QList<QAction *> actions = m_view->actions();
for (QAction *action : actions) {
const QString text = action->text();
if (text.contains(QStringLiteral("Copy Selection")) ||
text.contains(QStringLiteral("Paste Selection")) ||
text.contains(QStringLiteral("Duplicate Selection")) ||
text.contains(QStringLiteral("Delete Selection"))) {
m_view->removeAction(action);
action->deleteLater();
}
}
}
void GraphEditorWidget::deleteSelection() {
if (!m_scene) {
return;
}
const QList<QGraphicsItem *> items = m_scene->selectedItems();
QList<QtNodes::NodeId> virtualNodeIds;
bool hasSelectedConnections = false;
for (auto *item : items) {
if (auto *nodeObj =
qgraphicsitem_cast<QtNodes::NodeGraphicsObject *>(item)) {
const WarpNodeData *data = m_model->warpNodeData(nodeObj->nodeId());
if (!data)
continue;
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
if (type == WarpNodeType::kVirtualSink ||
type == WarpNodeType::kVirtualSource) {
virtualNodeIds.append(nodeObj->nodeId());
}
} else if (qgraphicsitem_cast<QtNodes::ConnectionGraphicsObject *>(
item)) {
hasSelectedConnections = true;
}
}
if (!virtualNodeIds.isEmpty()) {
m_scene->undoStack().push(
new DeleteVirtualNodeCommand(this, virtualNodeIds));
}
if (virtualNodeIds.isEmpty() && hasSelectedConnections) {
m_scene->undoStack().push(new QtNodes::DeleteCommand(m_scene));
}
}
void GraphEditorWidget::copySelection() {
if (!m_scene || !m_client) {
return;
}
QJsonArray nodesJson;
std::unordered_set<std::string> selectedNames;
QPointF sum;
int count = 0;
const QList<QGraphicsItem *> items = m_scene->selectedItems();
for (auto *item : items) {
auto *nodeObj =
qgraphicsitem_cast<QtNodes::NodeGraphicsObject *>(item);
if (!nodeObj)
continue;
const WarpNodeData *data = m_model->warpNodeData(nodeObj->nodeId());
if (!data)
continue;
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
if (type != WarpNodeType::kVirtualSink &&
type != WarpNodeType::kVirtualSource)
continue;
QJsonObject nodeJson;
nodeJson[QStringLiteral("name")] =
QString::fromStdString(data->info.name);
nodeJson[QStringLiteral("media_class")] =
QString::fromStdString(data->info.media_class);
int channels = static_cast<int>(
std::max(data->inputPorts.size(), data->outputPorts.size()));
nodeJson[QStringLiteral("channels")] = channels > 0 ? channels : 2;
QPointF pos =
m_model->nodeData(nodeObj->nodeId(), QtNodes::NodeRole::Position)
.toPointF();
nodeJson[QStringLiteral("x")] = pos.x();
nodeJson[QStringLiteral("y")] = pos.y();
nodesJson.append(nodeJson);
selectedNames.insert(data->info.name);
sum += pos;
++count;
}
if (nodesJson.isEmpty()) {
return;
}
std::unordered_map<uint32_t, std::pair<std::string, std::string>> portOwner;
for (auto qtId : m_model->allNodeIds()) {
const WarpNodeData *data = m_model->warpNodeData(qtId);
if (!data || selectedNames.find(data->info.name) == selectedNames.end())
continue;
for (const auto &port : data->outputPorts) {
portOwner[port.id.value] = {data->info.name, port.name};
}
for (const auto &port : data->inputPorts) {
portOwner[port.id.value] = {data->info.name, port.name};
}
}
QJsonArray linksJson;
auto linksResult = m_client->ListLinks();
if (linksResult.ok()) {
for (const auto &link : linksResult.value) {
auto outIt = portOwner.find(link.output_port.value);
auto inIt = portOwner.find(link.input_port.value);
if (outIt != portOwner.end() && inIt != portOwner.end()) {
QJsonObject linkJson;
linkJson[QStringLiteral("source")] = QString::fromStdString(
outIt->second.first + ":" + outIt->second.second);
linkJson[QStringLiteral("target")] = QString::fromStdString(
inIt->second.first + ":" + inIt->second.second);
linksJson.append(linkJson);
}
}
}
QJsonObject root;
root[QStringLiteral("nodes")] = nodesJson;
root[QStringLiteral("links")] = linksJson;
root[QStringLiteral("center_x")] = count > 0 ? sum.x() / count : 0.0;
root[QStringLiteral("center_y")] = count > 0 ? sum.y() / count : 0.0;
root[QStringLiteral("version")] = 1;
m_clipboardJson = root;
QJsonDocument doc(root);
auto *mime = new QMimeData();
mime->setData(QStringLiteral("application/warppipe-virtual-graph"),
doc.toJson(QJsonDocument::Compact));
mime->setText(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)));
QGuiApplication::clipboard()->setMimeData(mime);
}
void GraphEditorWidget::pasteSelection(const QPointF &offset) {
if (!m_client || !m_model) {
return;
}
QJsonObject root;
const QMimeData *mime = QGuiApplication::clipboard()->mimeData();
if (mime &&
mime->hasFormat(
QStringLiteral("application/warppipe-virtual-graph"))) {
root = QJsonDocument::fromJson(
mime->data(QStringLiteral(
"application/warppipe-virtual-graph")))
.object();
} else if (!m_clipboardJson.isEmpty()) {
root = m_clipboardJson;
}
if (root.isEmpty()) {
return;
}
std::unordered_set<std::string> existingNames;
auto nodesResult = m_client->ListNodes();
if (nodesResult.ok()) {
for (const auto &node : nodesResult.value) {
existingNames.insert(node.name);
}
}
std::unordered_map<std::string, std::string> nameMap;
const QJsonArray nodesArray =
root[QStringLiteral("nodes")].toArray();
for (const auto &entry : nodesArray) {
QJsonObject nodeObj = entry.toObject();
std::string baseName =
nodeObj[QStringLiteral("name")].toString().toStdString();
std::string mediaClass =
nodeObj[QStringLiteral("media_class")].toString().toStdString();
double x = nodeObj[QStringLiteral("x")].toDouble();
double y = nodeObj[QStringLiteral("y")].toDouble();
if (baseName.empty())
continue;
std::string newName = baseName + " Copy";
int suffix = 2;
while (existingNames.count(newName)) {
newName = baseName + " Copy " + std::to_string(suffix++);
}
existingNames.insert(newName);
nameMap[baseName] = newName;
m_model->setPendingPosition(newName, QPointF(x, y) + offset);
bool isSink =
mediaClass == "Audio/Sink" || mediaClass == "Audio/Duplex";
if (isSink) {
m_client->CreateVirtualSink(newName);
} else {
m_client->CreateVirtualSource(newName);
}
}
const QJsonArray linksArray =
root[QStringLiteral("links")].toArray();
for (const auto &entry : linksArray) {
QJsonObject linkObj = entry.toObject();
std::string source =
linkObj[QStringLiteral("source")].toString().toStdString();
std::string target =
linkObj[QStringLiteral("target")].toString().toStdString();
auto splitKey = [](const std::string &s)
-> std::pair<std::string, std::string> {
auto pos = s.rfind(':');
if (pos == std::string::npos)
return {s, ""};
return {s.substr(0, pos), s.substr(pos + 1)};
};
auto [outName, outPort] = splitKey(source);
auto [inName, inPort] = splitKey(target);
auto outIt = nameMap.find(outName);
auto inIt = nameMap.find(inName);
if (outIt == nameMap.end() || inIt == nameMap.end())
continue;
PendingPasteLink pending;
pending.outNodeName = outIt->second;
pending.outPortName = outPort;
pending.inNodeName = inIt->second;
pending.inPortName = inPort;
m_pendingPasteLinks.push_back(pending);
}
m_model->refreshFromClient();
tryResolvePendingLinks();
}
void GraphEditorWidget::duplicateSelection() {
copySelection();
pasteSelection(QPointF(40, 40));
}
void GraphEditorWidget::tryResolvePendingLinks() {
if (m_pendingPasteLinks.empty() || !m_client) {
return;
}
auto nodesResult = m_client->ListNodes();
if (!nodesResult.ok()) {
return;
}
std::vector<PendingPasteLink> remaining;
for (const auto &pending : m_pendingPasteLinks) {
warppipe::PortId outPortId{0};
warppipe::PortId inPortId{0};
bool foundOut = false;
bool foundIn = false;
for (const auto &node : nodesResult.value) {
if (!foundOut && node.name == pending.outNodeName) {
auto portsResult = m_client->ListPorts(node.id);
if (portsResult.ok()) {
for (const auto &port : portsResult.value) {
if (!port.is_input && port.name == pending.outPortName) {
outPortId = port.id;
foundOut = true;
break;
}
}
}
}
if (!foundIn && node.name == pending.inNodeName) {
auto portsResult = m_client->ListPorts(node.id);
if (portsResult.ok()) {
for (const auto &port : portsResult.value) {
if (port.is_input && port.name == pending.inPortName) {
inPortId = port.id;
foundIn = true;
break;
}
}
}
}
}
if (foundOut && foundIn) {
m_client->CreateLink(outPortId, inPortId, warppipe::LinkOptions{});
} else {
remaining.push_back(pending);
}
}
m_pendingPasteLinks = remaining;
}