GUI Milestone 8a
This commit is contained in:
parent
d178e8765b
commit
a52f82d67b
7 changed files with 586 additions and 31 deletions
|
|
@ -4,17 +4,111 @@
|
|||
#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 <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) {
|
||||
|
|
@ -50,6 +144,50 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
|||
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);
|
||||
|
||||
|
|
@ -259,3 +397,312 @@ void GraphEditorWidget::createVirtualNode(bool isSink,
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@
|
|||
|
||||
#include <warppipe/warppipe.hpp>
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace QtNodes {
|
||||
class BasicGraphicsScene;
|
||||
class GraphicsView;
|
||||
|
|
@ -13,10 +17,13 @@ class GraphicsView;
|
|||
class WarpGraphModel;
|
||||
class QLabel;
|
||||
class QTimer;
|
||||
class DeleteVirtualNodeCommand;
|
||||
|
||||
class GraphEditorWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
friend class DeleteVirtualNodeCommand;
|
||||
|
||||
public:
|
||||
explicit GraphEditorWidget(warppipe::Client *client,
|
||||
QWidget *parent = nullptr);
|
||||
|
|
@ -40,6 +47,20 @@ private:
|
|||
void createVirtualNode(bool isSink, const QPointF &scenePos);
|
||||
void captureDebugScreenshot(const QString &event);
|
||||
|
||||
void deleteSelection();
|
||||
void copySelection();
|
||||
void pasteSelection(const QPointF &offset);
|
||||
void duplicateSelection();
|
||||
void removeDefaultActions();
|
||||
void tryResolvePendingLinks();
|
||||
|
||||
struct PendingPasteLink {
|
||||
std::string outNodeName;
|
||||
std::string outPortName;
|
||||
std::string inNodeName;
|
||||
std::string inPortName;
|
||||
};
|
||||
|
||||
warppipe::Client *m_client = nullptr;
|
||||
WarpGraphModel *m_model = nullptr;
|
||||
QtNodes::BasicGraphicsScene *m_scene = nullptr;
|
||||
|
|
@ -49,4 +70,6 @@ private:
|
|||
QString m_layoutPath;
|
||||
QString m_debugScreenshotDir;
|
||||
bool m_graphReady = false;
|
||||
QJsonObject m_clipboardJson;
|
||||
std::vector<PendingPasteLink> m_pendingPasteLinks;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -615,6 +615,15 @@ bool WarpGraphModel::isGhost(QtNodes::NodeId nodeId) const {
|
|||
return m_ghostNodes.find(nodeId) != m_ghostNodes.end();
|
||||
}
|
||||
|
||||
uint32_t WarpGraphModel::findPwNodeIdByName(const std::string &name) const {
|
||||
for (const auto &[qtId, data] : m_nodes) {
|
||||
if (data.info.name == name) {
|
||||
return data.info.id.value;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
WarpNodeType
|
||||
WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) {
|
||||
const std::string &mc = info.media_class;
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ public:
|
|||
void setPendingPosition(const std::string &nodeName, QPointF pos);
|
||||
static WarpNodeType classifyNode(const warppipe::NodeInfo &info);
|
||||
|
||||
uint32_t findPwNodeIdByName(const std::string &name) const;
|
||||
|
||||
void saveLayout(const QString &path) const;
|
||||
bool loadLayout(const QString &path);
|
||||
void autoArrange();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue