From 4a248e562246b75e6b02f2b87bcd6b93b604a222 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 08:07:21 -0700 Subject: [PATCH] GUI Milestone 8b --- GUI_PLAN.md | 42 ++--- gui/GraphEditorWidget.cpp | 66 ++++++- gui/GraphEditorWidget.h | 2 + gui/WarpGraphModel.cpp | 293 ++++++++++++++++++++++++++++-- gui/WarpGraphModel.h | 20 +++ tests/gui/warppipe_gui_tests.cpp | 296 +++++++++++++++++++++++++++++++ 6 files changed, 682 insertions(+), 37 deletions(-) diff --git a/GUI_PLAN.md b/GUI_PLAN.md index 1971b1b..6668bec 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -190,26 +190,26 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. - [x] Ctrl+L → auto-arrange + zoom fit - [x] Remove default QtNodes copy/paste actions to avoid conflicts - [x] Add tests for undo/redo command state (push command → undo → verify node re-created → redo → verify deleted) -- [ ] Milestone 8b - View and Layout Enhancements - - [ ] Add "Zoom Fit All" context menu action → `m_view->zoomFitAll()` - - [ ] Add "Zoom Fit Selected" context menu action → `m_view->zoomFitSelected()` - - [ ] Add "Save Layout As..." context menu action - - [ ] `QFileDialog::getSaveFileName()` → save layout JSON to custom path - - [ ] Reuse existing `saveLayout()` serialization, write to chosen path - - [ ] Add "Reset Layout" context menu action - - [ ] Clear saved positions, run `autoArrange()`, save, zoom fit - - [ ] Add "Refresh Graph" context menu action - - [ ] Reset model, re-sync from client, zoom fit - - [ ] Persist view state in layout JSON: - - [ ] Save view scale + center position (`m_view->getScale()`, `m_view->mapToScene(viewport center)`) - - [ ] Restore on load: `m_view->setupScale()` + `m_view->centerOn()` - - [ ] Fallback to `zoomFitAll()` when no saved view state - - [ ] Persist ghost nodes in layout JSON: - - [ ] Serialize ghost node stable_id, name, description, input/output ports (id + name), position - - [ ] Serialize ghost connections (out_stable_id, out_port_index, in_stable_id, in_port_index) - - [ ] Restore ghosts from layout on load (before live sync) - - [ ] Add middle-click center: `eventFilter` on viewport catches `MiddleButton` → `m_view->centerOn(mapToScene(pos))` - - [ ] Add tests for view state save/load round-trip and ghost persistence +- [x] Milestone 8b - View and Layout Enhancements + - [x] Add "Zoom Fit All" context menu action → `m_view->zoomFitAll()` + - [x] Add "Zoom Fit Selected" context menu action → `m_view->zoomFitSelected()` + - [x] Add "Save Layout As..." context menu action + - [x] `QFileDialog::getSaveFileName()` → save layout JSON to custom path + - [x] Reuse existing `saveLayout()` serialization, write to chosen path + - [x] Add "Reset Layout" context menu action + - [x] Clear saved positions, run `autoArrange()`, save, zoom fit + - [x] Add "Refresh Graph" context menu action + - [x] Reset model, re-sync from client, zoom fit + - [x] Persist view state in layout JSON: + - [x] Save view scale + center position (`m_view->getScale()`, `m_view->mapToScene(viewport center)`) + - [x] Restore on load: `m_view->setupScale()` + `m_view->centerOn()` + - [x] Fallback to `zoomFitAll()` when no saved view state + - [x] Persist ghost nodes in layout JSON: + - [x] Serialize ghost node stable_id, name, description, input/output ports (id + name), position + - [x] Serialize ghost connections (out_stable_id, out_port_index, in_stable_id, in_port_index) + - [x] Restore ghosts from layout on load (before live sync) + - [x] Add middle-click center: `eventFilter` on viewport catches `MiddleButton` → `m_view->centerOn(mapToScene(pos))` + - [x] Add tests for view state save/load round-trip and ghost persistence - [ ] Milestone 8c - Sidebar and Preset System - [ ] Add `QSplitter` between graph view and sidebar panel - [ ] Graph view (stretch factor 1) on left, sidebar (stretch factor 0) on right @@ -276,7 +276,7 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. - [ ] Remove meter when node removed or all links removed (`removeNodeMeter()`) - [ ] Skip meter nodes (filter by name prefix) - [ ] Add tests for AudioLevelMeter level clamping, hold/decay logic -- [ ] Milestone 8f (Optional) - Architecture and Routing Rules +- [ ] Milestone 8f - Architecture and Routing Rules - [ ] Event-driven updates: replace 500ms polling with signal/slot if core adds registry callbacks - [ ] `nodeAdded(NodeInfo)`, `nodeRemoved(uint32_t)`, `nodeChanged(NodeInfo)` - [ ] `linkAdded(LinkInfo)`, `linkRemoved(uint32_t)` diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 37e3dbc..9ce3901 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -13,9 +13,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -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(event); m_lastContextMenuScenePos = m_view->mapToScene(cme->pos()); + } else if (event->type() == QEvent::MouseButtonPress) { + auto *me = static_cast(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(); + } +} diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index 9a5dbf3..d8ed3f0 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -58,6 +58,8 @@ private: void duplicateSelection(); void removeDefaultActions(); void tryResolvePendingLinks(); + void saveLayoutWithViewState(); + void restoreViewState(); struct PendingPasteLink { std::string outNodeName; diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp index 4934f2a..fa447d2 100644 --- a/gui/WarpGraphModel.cpp +++ b/gui/WarpGraphModel.cpp @@ -361,6 +361,23 @@ void WarpGraphModel::refreshFromClient() { } if (m_ghostNodes.erase(qtId)) { + std::vector 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 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(i); + break; + } + } + for (size_t i = 0; i < inNodeIt->second.inputPorts.size(); ++i) { + if (inNodeIt->second.inputPorts[i].name == it->inPortName) { + inIdx = static_cast(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(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(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(conn.outPortIndex); + auto inIdx = static_cast(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(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(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; diff --git a/gui/WarpGraphModel.h b/gui/WarpGraphModel.h index 70abc82..5b31437 100644 --- a/gui/WarpGraphModel.h +++ b/gui/WarpGraphModel.h @@ -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 m_positions; std::unordered_map m_sizes; std::unordered_set m_ghostNodes; + std::unordered_set 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 m_pendingPositions; std::unordered_map m_savedPositions; + std::vector m_pendingGhostConnections; + ViewState m_savedViewState{}; }; diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index 7e30fda..eedacd8 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -5,8 +5,11 @@ #include #include +#include +#include #include +#include namespace { @@ -553,3 +556,296 @@ TEST_CASE("findPwNodeIdByName returns 0 for ghost nodes without pw mapping") { REQUIRE(model.findPwNodeIdByName("ghost-lookup") == 100220); } + +TEST_CASE("saveLayout stores and loadLayout restores view state") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100300, "view-state-node", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + WarpGraphModel::ViewState vs; + vs.scale = 1.5; + vs.centerX = 123.4; + vs.centerY = 567.8; + vs.valid = true; + + QString path = QStandardPaths::writableLocation( + QStandardPaths::TempLocation) + + "/warppipe_test_viewstate.json"; + model.saveLayout(path, vs); + + WarpGraphModel model2(tc.client.get()); + bool loaded = model2.loadLayout(path); + REQUIRE(loaded); + + auto restored = model2.savedViewState(); + REQUIRE(restored.valid); + REQUIRE(restored.scale == Catch::Approx(1.5)); + REQUIRE(restored.centerX == Catch::Approx(123.4)); + REQUIRE(restored.centerY == Catch::Approx(567.8)); + + QFile::remove(path); +} + +TEST_CASE("saveLayout without view state omits view key") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100310, "no-view-node", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + QString path = QStandardPaths::writableLocation( + QStandardPaths::TempLocation) + + "/warppipe_test_noview.json"; + model.saveLayout(path); + + WarpGraphModel model2(tc.client.get()); + model2.loadLayout(path); + + auto restored = model2.savedViewState(); + REQUIRE_FALSE(restored.valid); + + QFile::remove(path); +} + +TEST_CASE("ghost nodes persist in layout JSON") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100320, "ghost-persist-app", "Stream/Output/Audio", "TestApp")).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(100321, 100320, "output_FL", false)).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(100322, 100320, "input_FL", true)).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + REQUIRE_FALSE(model.isGhost(model.qtNodeIdForPw(100320))); + + REQUIRE(tc.client->Test_RemoveGlobal(100320).ok()); + model.refreshFromClient(); + + auto ghostQt = model.findPwNodeIdByName("ghost-persist-app"); + REQUIRE(ghostQt == 100320); + + QString path = QStandardPaths::writableLocation( + QStandardPaths::TempLocation) + + "/warppipe_test_ghosts.json"; + model.saveLayout(path); + + WarpGraphModel model2(tc.client.get()); + model2.loadLayout(path); + + auto ids = model2.allNodeIds(); + bool foundGhost = false; + for (auto id : ids) { + const WarpNodeData *d = model2.warpNodeData(id); + if (d && d->info.name == "ghost-persist-app") { + foundGhost = true; + REQUIRE(model2.isGhost(id)); + REQUIRE(d->info.application_name == "TestApp"); + REQUIRE(d->inputPorts.size() == 1); + REQUIRE(d->outputPorts.size() == 1); + REQUIRE(d->inputPorts[0].name == "input_FL"); + REQUIRE(d->outputPorts[0].name == "output_FL"); + break; + } + } + REQUIRE(foundGhost); + + QFile::remove(path); +} + +TEST_CASE("layout version 1 files still load") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + QString path = QStandardPaths::writableLocation( + QStandardPaths::TempLocation) + + "/warppipe_test_v1.json"; + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly)); + file.write(R"({"version":1,"nodes":[{"name":"legacy-node","x":10,"y":20}]})"); + file.close(); + + WarpGraphModel model(tc.client.get()); + REQUIRE(model.loadLayout(path)); + + auto vs = model.savedViewState(); + REQUIRE_FALSE(vs.valid); + + QFile::remove(path); +} + +TEST_CASE("ghost connections preserved when node becomes ghost") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100400, "gc-sink", "Audio/Sink")).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(100401, 100400, "in_FL", true)).ok()); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100402, "gc-app", "Stream/Output/Audio", "GCApp")).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(100403, 100402, "out_FL", false)).ok()); + + REQUIRE(tc.client->Test_InsertLink( + MakeLink(100404, 100403, 100401)).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto sinkQt = model.qtNodeIdForPw(100400); + auto appQt = model.qtNodeIdForPw(100402); + REQUIRE(model.allConnectionIds(appQt).size() == 1); + + REQUIRE(tc.client->Test_RemoveGlobal(100402).ok()); + REQUIRE(tc.client->Test_RemoveGlobal(100404).ok()); + model.refreshFromClient(); + + REQUIRE(model.isGhost(appQt)); + REQUIRE(model.connectionExists( + QtNodes::ConnectionId{appQt, 0, sinkQt, 0})); +} + +TEST_CASE("ghost connections survive save/load round-trip") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100410, "gcrt-sink", "Audio/Sink")).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(100411, 100410, "in_FL", true)).ok()); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100412, "gcrt-app", "Stream/Output/Audio", "GCRTApp")).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(100413, 100412, "out_FL", false)).ok()); + + REQUIRE(tc.client->Test_InsertLink( + MakeLink(100414, 100413, 100411)).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + REQUIRE(tc.client->Test_RemoveGlobal(100412).ok()); + REQUIRE(tc.client->Test_RemoveGlobal(100414).ok()); + model.refreshFromClient(); + + auto appQt = model.qtNodeIdForPw(100412); + REQUIRE(appQt == 0); + + QString path = QStandardPaths::writableLocation( + QStandardPaths::TempLocation) + + "/warppipe_test_ghostconns.json"; + model.saveLayout(path); + + WarpGraphModel model2(tc.client.get()); + model2.loadLayout(path); + model2.refreshFromClient(); + + QtNodes::NodeId sinkQt2 = 0; + QtNodes::NodeId appQt2 = 0; + for (auto id : model2.allNodeIds()) { + const WarpNodeData *d = model2.warpNodeData(id); + if (d && d->info.name == "gcrt-sink") + sinkQt2 = id; + if (d && d->info.name == "gcrt-app") + appQt2 = id; + } + REQUIRE(sinkQt2 != 0); + REQUIRE(appQt2 != 0); + REQUIRE(model2.isGhost(appQt2)); + + auto conns = model2.allConnectionIds(appQt2); + REQUIRE(conns.size() == 1); + auto conn = *conns.begin(); + REQUIRE(conn.outNodeId == appQt2); + REQUIRE(conn.inNodeId == sinkQt2); + + QFile::remove(path); +} + +TEST_CASE("ghost connections cleaned when ghost un-ghosts") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100420, "gcug-sink", "Audio/Sink")).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(100421, 100420, "in_FL", true)).ok()); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100422, "gcug-app", "Stream/Output/Audio", "GCUGApp")).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(100423, 100422, "out_FL", false)).ok()); + + REQUIRE(tc.client->Test_InsertLink( + MakeLink(100424, 100423, 100421)).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto appQt = model.qtNodeIdForPw(100422); + auto sinkQt = model.qtNodeIdForPw(100420); + + REQUIRE(tc.client->Test_RemoveGlobal(100422).ok()); + REQUIRE(tc.client->Test_RemoveGlobal(100424).ok()); + model.refreshFromClient(); + REQUIRE(model.isGhost(appQt)); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100425, "gcug-app", "Stream/Output/Audio", "GCUGApp")).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(100426, 100425, "out_FL", false)).ok()); + model.refreshFromClient(); + + REQUIRE_FALSE(model.isGhost(appQt)); + + auto conns = model.allConnectionIds(appQt); + bool hasOldGhostConn = false; + for (const auto &c : conns) { + if (c.outNodeId == appQt && c.inNodeId == sinkQt) + hasOldGhostConn = true; + } + REQUIRE_FALSE(hasOldGhostConn); +} + +TEST_CASE("clearSavedPositions resets model positions") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100340, "clear-pos-node", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto id = model.qtNodeIdForPw(100340); + REQUIRE(id != 0); + auto posBefore = model.nodeData(id, QtNodes::NodeRole::Position).toPointF(); + + model.clearSavedPositions(); + model.autoArrange(); + auto posAfter = model.nodeData(id, QtNodes::NodeRole::Position).toPointF(); + + REQUIRE(posAfter != QPointF(0, 0)); +}