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

@ -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)`

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

View file

@ -5,8 +5,11 @@
#include <QAction>
#include <QApplication>
#include <QFile>
#include <QStandardPaths>
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
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));
}