GUI Milestone 8a

This commit is contained in:
Joey Yakimowich-Payne 2026-01-30 07:04:40 -07:00
commit a52f82d67b
7 changed files with 586 additions and 31 deletions

View file

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