GUI Milestone 8b

This commit is contained in:
Joey Yakimowich-Payne 2026-01-30 08:07:21 -07:00
commit 4a248e5622
6 changed files with 682 additions and 37 deletions

View file

@ -13,9 +13,11 @@
#include <QContextMenuEvent>
#include <QDateTime>
#include <QDir>
#include <QFileDialog>
#include <QGraphicsItem>
#include <QGuiApplication>
#include <QInputDialog>
#include <QMouseEvent>
#include <QJsonArray>
#include <QJsonDocument>
#include <QMenu>
@ -188,7 +190,7 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
autoArrangeAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
connect(autoArrangeAction, &QAction::triggered, this, [this]() {
m_model->autoArrange();
m_model->saveLayout(m_layoutPath);
saveLayoutWithViewState();
});
m_view->addAction(autoArrangeAction);
@ -239,19 +241,26 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
connect(m_model, &WarpGraphModel::nodePositionUpdated, this,
&GraphEditorWidget::scheduleSaveLayout);
connect(m_model, &QtNodes::AbstractGraphModel::nodeCreated, this,
&GraphEditorWidget::scheduleSaveLayout);
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this,
&GraphEditorWidget::scheduleSaveLayout);
connect(m_model, &QtNodes::AbstractGraphModel::nodeUpdated, 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);
});
connect(m_saveTimer, &QTimer::timeout, this,
&GraphEditorWidget::saveLayoutWithViewState);
m_model->refreshFromClient();
if (!hasLayout) {
m_model->autoArrange();
}
QTimer::singleShot(0, this, &GraphEditorWidget::restoreViewState);
if (m_model->allNodeIds().size() > 0) {
m_graphReady = true;
Q_EMIT graphReady();
@ -342,10 +351,18 @@ void GraphEditorWidget::captureDebugScreenshot(const QString &event) {
}
bool GraphEditorWidget::eventFilter(QObject *obj, QEvent *event) {
if (obj == m_view->viewport() &&
event->type() == QEvent::ContextMenu) {
if (obj != m_view->viewport()) {
return QWidget::eventFilter(obj, event);
}
if (event->type() == QEvent::ContextMenu) {
auto *cme = static_cast<QContextMenuEvent *>(event);
m_lastContextMenuScenePos = m_view->mapToScene(cme->pos());
} else if (event->type() == QEvent::MouseButtonPress) {
auto *me = static_cast<QMouseEvent *>(event);
if (me->button() == Qt::MiddleButton) {
m_view->centerOn(m_view->mapToScene(me->pos()));
return true;
}
}
return QWidget::eventFilter(obj, event);
}
@ -410,6 +427,9 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
autoArrange->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_L));
QAction *refreshGraph = menu.addAction(QStringLiteral("Refresh Graph"));
refreshGraph->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
menu.addSeparator();
QAction *saveLayoutAs = menu.addAction(QStringLiteral("Save Layout As..."));
QAction *resetLayout = menu.addAction(QStringLiteral("Reset Layout"));
QAction *chosen = menu.exec(screenPos);
if (chosen == createSink) {
@ -430,8 +450,22 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
m_view->zoomFitSelected();
} else if (chosen == autoArrange) {
m_model->autoArrange();
saveLayoutWithViewState();
} else if (chosen == refreshGraph) {
m_model->refreshFromClient();
} else if (chosen == saveLayoutAs) {
QString path = QFileDialog::getSaveFileName(
this, QStringLiteral("Save Layout As"), QString(),
QStringLiteral("JSON files (*.json)"));
if (!path.isEmpty()) {
saveLayoutWithViewState();
m_model->saveLayout(path);
}
} else if (chosen == resetLayout) {
m_model->clearSavedPositions();
m_model->autoArrange();
m_view->zoomFitAll();
saveLayoutWithViewState();
}
}
@ -827,3 +861,23 @@ void GraphEditorWidget::tryResolvePendingLinks() {
m_pendingPasteLinks = remaining;
}
void GraphEditorWidget::saveLayoutWithViewState() {
WarpGraphModel::ViewState vs;
vs.scale = m_view->getScale();
QPointF center = m_view->mapToScene(m_view->viewport()->rect().center());
vs.centerX = center.x();
vs.centerY = center.y();
vs.valid = true;
m_model->saveLayout(m_layoutPath, vs);
}
void GraphEditorWidget::restoreViewState() {
auto vs = m_model->savedViewState();
if (vs.valid) {
m_view->setupScale(vs.scale);
m_view->centerOn(QPointF(vs.centerX, vs.centerY));
} else {
m_view->zoomFitAll();
}
}

View file

@ -58,6 +58,8 @@ private:
void duplicateSelection();
void removeDefaultActions();
void tryResolvePendingLinks();
void saveLayoutWithViewState();
void restoreViewState();
struct PendingPasteLink {
std::string outNodeName;

View file

@ -361,6 +361,23 @@ void WarpGraphModel::refreshFromClient() {
}
if (m_ghostNodes.erase(qtId)) {
std::vector<QtNodes::ConnectionId> gcToRemove;
for (auto gcIt = m_ghostConnections.begin();
gcIt != m_ghostConnections.end();) {
if (gcIt->outNodeId == qtId || gcIt->inNodeId == qtId) {
gcToRemove.push_back(*gcIt);
gcIt = m_ghostConnections.erase(gcIt);
} else {
++gcIt;
}
}
for (const auto &gc : gcToRemove) {
auto cIt = m_connections.find(gc);
if (cIt != m_connections.end()) {
m_connections.erase(cIt);
Q_EMIT connectionDeleted(gc);
}
}
Q_EMIT nodeUpdated(qtId);
}
continue;
@ -379,6 +396,25 @@ void WarpGraphModel::refreshFromClient() {
if (ghostMatch != 0) {
m_ghostNodes.erase(ghostMatch);
{
std::vector<QtNodes::ConnectionId> gcToRemove;
for (auto gcIt = m_ghostConnections.begin();
gcIt != m_ghostConnections.end();) {
if (gcIt->outNodeId == ghostMatch || gcIt->inNodeId == ghostMatch) {
gcToRemove.push_back(*gcIt);
gcIt = m_ghostConnections.erase(gcIt);
} else {
++gcIt;
}
}
for (const auto &gc : gcToRemove) {
auto cIt = m_connections.find(gc);
if (cIt != m_connections.end()) {
m_connections.erase(cIt);
Q_EMIT connectionDeleted(gc);
}
}
}
m_pwToQt.emplace(nodeInfo.id.value, ghostMatch);
auto &data = m_nodes[ghostMatch];
data.info = nodeInfo;
@ -528,17 +564,73 @@ void WarpGraphModel::refreshFromClient() {
for (uint32_t linkId : staleLinkIds) {
auto it = m_linkIdToConn.find(linkId);
if (it != m_linkIdToConn.end()) {
auto connIt = m_connections.find(it->second);
if (connIt != m_connections.end()) {
QtNodes::ConnectionId connId = it->second;
m_connections.erase(connIt);
Q_EMIT connectionDeleted(connId);
QtNodes::ConnectionId connId = it->second;
bool outIsGhost =
m_ghostNodes.find(connId.outNodeId) != m_ghostNodes.end();
bool inIsGhost =
m_ghostNodes.find(connId.inNodeId) != m_ghostNodes.end();
if (outIsGhost || inIsGhost) {
m_ghostConnections.insert(connId);
} else {
auto connIt = m_connections.find(connId);
if (connIt != m_connections.end()) {
m_connections.erase(connIt);
Q_EMIT connectionDeleted(connId);
}
}
m_linkIdToConn.erase(it);
}
}
}
if (!m_pendingGhostConnections.empty()) {
auto it = m_pendingGhostConnections.begin();
while (it != m_pendingGhostConnections.end()) {
QtNodes::NodeId outQtId = 0;
QtNodes::NodeId inQtId = 0;
for (const auto &[qtId, data] : m_nodes) {
if (data.info.name == it->outNodeName)
outQtId = qtId;
if (data.info.name == it->inNodeName)
inQtId = qtId;
}
if (outQtId == 0 || inQtId == 0) {
++it;
continue;
}
auto outNodeIt = m_nodes.find(outQtId);
auto inNodeIt = m_nodes.find(inQtId);
QtNodes::PortIndex outIdx = -1;
QtNodes::PortIndex inIdx = -1;
for (size_t i = 0; i < outNodeIt->second.outputPorts.size(); ++i) {
if (outNodeIt->second.outputPorts[i].name == it->outPortName) {
outIdx = static_cast<QtNodes::PortIndex>(i);
break;
}
}
for (size_t i = 0; i < inNodeIt->second.inputPorts.size(); ++i) {
if (inNodeIt->second.inputPorts[i].name == it->inPortName) {
inIdx = static_cast<QtNodes::PortIndex>(i);
break;
}
}
if (outIdx < 0 || inIdx < 0) {
++it;
continue;
}
QtNodes::ConnectionId connId{outQtId, outIdx, inQtId, inIdx};
if (m_connections.find(connId) == m_connections.end()) {
m_connections.insert(connId);
m_ghostConnections.insert(connId);
Q_EMIT connectionCreated(connId);
}
it = m_pendingGhostConnections.erase(it);
}
}
m_refreshing = false;
}
@ -644,6 +736,12 @@ WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) {
}
void WarpGraphModel::saveLayout(const QString &path) const {
ViewState vs{};
saveLayout(path, vs);
}
void WarpGraphModel::saveLayout(const QString &path,
const ViewState &viewState) const {
QJsonArray nodesArray;
for (const auto &[qtId, data] : m_nodes) {
auto posIt = m_positions.find(qtId);
@ -657,9 +755,85 @@ void WarpGraphModel::saveLayout(const QString &path) const {
nodesArray.append(nodeObj);
}
QJsonArray ghostsArray;
for (const auto &ghostId : m_ghostNodes) {
auto nodeIt = m_nodes.find(ghostId);
if (nodeIt == m_nodes.end()) {
continue;
}
const auto &data = nodeIt->second;
QJsonObject ghostObj;
ghostObj["name"] = QString::fromStdString(data.info.name);
ghostObj["description"] = QString::fromStdString(data.info.description);
ghostObj["media_class"] = QString::fromStdString(data.info.media_class);
ghostObj["application_name"] =
QString::fromStdString(data.info.application_name);
auto posIt = m_positions.find(ghostId);
if (posIt != m_positions.end()) {
ghostObj["x"] = posIt->second.x();
ghostObj["y"] = posIt->second.y();
}
QJsonArray inPorts;
for (const auto &port : data.inputPorts) {
QJsonObject p;
p["id"] = static_cast<int>(port.id.value);
p["name"] = QString::fromStdString(port.name);
inPorts.append(p);
}
ghostObj["input_ports"] = inPorts;
QJsonArray outPorts;
for (const auto &port : data.outputPorts) {
QJsonObject p;
p["id"] = static_cast<int>(port.id.value);
p["name"] = QString::fromStdString(port.name);
outPorts.append(p);
}
ghostObj["output_ports"] = outPorts;
ghostsArray.append(ghostObj);
}
QJsonArray ghostConnsArray;
for (const auto &conn : m_ghostConnections) {
auto outIt = m_nodes.find(conn.outNodeId);
auto inIt = m_nodes.find(conn.inNodeId);
if (outIt == m_nodes.end() || inIt == m_nodes.end()) {
continue;
}
auto outIdx = static_cast<size_t>(conn.outPortIndex);
auto inIdx = static_cast<size_t>(conn.inPortIndex);
if (outIdx >= outIt->second.outputPorts.size() ||
inIdx >= inIt->second.inputPorts.size()) {
continue;
}
QJsonObject connObj;
connObj["out_node"] =
QString::fromStdString(outIt->second.info.name);
connObj["out_port"] =
QString::fromStdString(outIt->second.outputPorts[outIdx].name);
connObj["in_node"] =
QString::fromStdString(inIt->second.info.name);
connObj["in_port"] =
QString::fromStdString(inIt->second.inputPorts[inIdx].name);
ghostConnsArray.append(connObj);
}
QJsonObject root;
root["version"] = 1;
root["version"] = 2;
root["nodes"] = nodesArray;
root["ghosts"] = ghostsArray;
root["ghost_connections"] = ghostConnsArray;
if (viewState.valid) {
QJsonObject viewObj;
viewObj["scale"] = viewState.scale;
viewObj["center_x"] = viewState.centerX;
viewObj["center_y"] = viewState.centerY;
root["view"] = viewObj;
}
QFileInfo fi(path);
QDir dir = fi.absoluteDir();
@ -673,6 +847,15 @@ void WarpGraphModel::saveLayout(const QString &path) const {
}
}
void WarpGraphModel::clearSavedPositions() {
m_savedPositions.clear();
m_positions.clear();
}
WarpGraphModel::ViewState WarpGraphModel::savedViewState() const {
return m_savedViewState;
}
bool WarpGraphModel::loadLayout(const QString &path) {
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
@ -685,7 +868,8 @@ bool WarpGraphModel::loadLayout(const QString &path) {
}
QJsonObject root = doc.object();
if (root["version"].toInt() != 1) {
int version = root["version"].toInt();
if (version < 1 || version > 2) {
return false;
}
@ -698,7 +882,97 @@ bool WarpGraphModel::loadLayout(const QString &path) {
double y = obj["y"].toDouble();
m_savedPositions[name] = QPointF(x, y);
}
return !m_savedPositions.empty();
m_savedViewState = {};
if (root.contains("view")) {
QJsonObject viewObj = root["view"].toObject();
m_savedViewState.scale = viewObj["scale"].toDouble(1.0);
m_savedViewState.centerX = viewObj["center_x"].toDouble();
m_savedViewState.centerY = viewObj["center_y"].toDouble();
m_savedViewState.valid = true;
}
if (root.contains("ghosts")) {
QJsonArray ghostsArray = root["ghosts"].toArray();
for (const auto &val : ghostsArray) {
QJsonObject obj = val.toObject();
std::string name = obj["name"].toString().toStdString();
bool alreadyExists = false;
for (const auto &[_, data] : m_nodes) {
if (data.info.name == name) {
alreadyExists = true;
break;
}
}
if (alreadyExists) {
continue;
}
warppipe::NodeInfo info;
info.id = warppipe::NodeId{0};
info.name = name;
info.description = obj["description"].toString().toStdString();
info.media_class = obj["media_class"].toString().toStdString();
info.application_name =
obj["application_name"].toString().toStdString();
WarpNodeData data;
data.info = info;
for (const auto &pval : obj["input_ports"].toArray()) {
QJsonObject p = pval.toObject();
warppipe::PortInfo port;
port.id = warppipe::PortId{
static_cast<uint32_t>(p["id"].toInt())};
port.node = info.id;
port.name = p["name"].toString().toStdString();
port.is_input = true;
data.inputPorts.push_back(port);
}
for (const auto &pval : obj["output_ports"].toArray()) {
QJsonObject p = pval.toObject();
warppipe::PortInfo port;
port.id = warppipe::PortId{
static_cast<uint32_t>(p["id"].toInt())};
port.node = info.id;
port.name = p["name"].toString().toStdString();
port.is_input = false;
data.outputPorts.push_back(port);
}
QtNodes::NodeId qtId = newNodeId();
m_nodes.emplace(qtId, std::move(data));
m_ghostNodes.insert(qtId);
if (obj.contains("x") && obj.contains("y")) {
m_positions.emplace(qtId, QPointF(obj["x"].toDouble(),
obj["y"].toDouble()));
}
m_savedPositions[name] =
m_positions.count(qtId)
? m_positions.at(qtId)
: QPointF(0, 0);
Q_EMIT nodeCreated(qtId);
}
}
if (root.contains("ghost_connections")) {
m_pendingGhostConnections.clear();
QJsonArray gcArray = root["ghost_connections"].toArray();
for (const auto &val : gcArray) {
QJsonObject obj = val.toObject();
PendingGhostConnection pgc;
pgc.outNodeName = obj["out_node"].toString().toStdString();
pgc.outPortName = obj["out_port"].toString().toStdString();
pgc.inNodeName = obj["in_node"].toString().toStdString();
pgc.inPortName = obj["in_port"].toString().toStdString();
m_pendingGhostConnections.push_back(std::move(pgc));
}
}
return !m_savedPositions.empty() || !m_ghostNodes.empty();
}
void WarpGraphModel::autoArrange() {
@ -788,7 +1062,6 @@ QVariant WarpGraphModel::styleForNode(WarpNodeType type, bool ghost) {
style.FontColorFaded = QColor(120, 128, 142);
style.ConnectionPointColor = QColor(140, 148, 160);
style.FilledConnectionPointColor = QColor(180, 140, 80);
style.Opacity = 0.6f;
} else {
style.GradientColor0 = base.lighter(120);
style.GradientColor1 = base.lighter(108);
@ -799,9 +1072,9 @@ QVariant WarpGraphModel::styleForNode(WarpNodeType type, bool ghost) {
style.FontColorFaded = QColor(160, 168, 182);
style.ConnectionPointColor = QColor(200, 208, 220);
style.FilledConnectionPointColor = QColor(255, 165, 0);
style.Opacity = 1.0f;
}
style.Opacity = 1.0f;
style.SelectedBoundaryColor = QColor(255, 165, 0);
style.PenWidth = 1.3f;
style.HoveredPenWidth = 2.4f;

View file

@ -69,8 +69,18 @@ public:
uint32_t findPwNodeIdByName(const std::string &name) const;
struct ViewState {
double scale;
double centerX;
double centerY;
bool valid;
};
void saveLayout(const QString &path) const;
void saveLayout(const QString &path, const ViewState &viewState) const;
bool loadLayout(const QString &path);
ViewState savedViewState() const;
void clearSavedPositions();
void autoArrange();
private:
@ -91,6 +101,7 @@ private:
std::unordered_map<QtNodes::NodeId, QPointF> m_positions;
std::unordered_map<QtNodes::NodeId, QSize> m_sizes;
std::unordered_set<QtNodes::NodeId> m_ghostNodes;
std::unordered_set<QtNodes::ConnectionId> m_ghostConnections;
static constexpr double kHorizontalGap = 40.0;
static constexpr double kVerticalGap = 30.0;
@ -101,6 +112,15 @@ private:
bool m_refreshing = false;
struct PendingGhostConnection {
std::string outNodeName;
std::string outPortName;
std::string inNodeName;
std::string inPortName;
};
std::unordered_map<std::string, QPointF> m_pendingPositions;
std::unordered_map<std::string, QPointF> m_savedPositions;
std::vector<PendingGhostConnection> m_pendingGhostConnections;
ViewState m_savedViewState{};
};