diff --git a/GUI_PLAN.md b/GUI_PLAN.md index cc562d0..4ddfcca 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -89,23 +89,23 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. - [x] Call `Client::RemoveNode(nodeId)` and remove from graph - [x] Verify: Can create/delete virtual sinks and sources via right-click -- [ ] Milestone 5 - Layout Persistence and Polish - - [ ] Implement layout save/load: - - [ ] Save node positions to JSON file in `~/.config/warppipe-gui/layout.json` - - [ ] Store by stable ID (use NodeInfo.name as stable key) - - [ ] Save on position change (debounced) - - [ ] Load on startup and restore positions - - [ ] Implement auto-arrange: - - [ ] Menu or button to auto-layout nodes (left-to-right: sources → sinks) - - [ ] Use simple grid or layered layout algorithm - - [ ] Add visual polish: - - [ ] Connection lines styled (color, width, curvature) - - [ ] Highlight connections on hover - - [ ] Port connection points visible and responsive - - [ ] Add status bar: - - [ ] Show connection status to PipeWire daemon - - [ ] Show count of nodes, links - - [ ] Verify: Layout persists across sessions, UI feels responsive and polished +- [x] Milestone 5 - Layout Persistence and Polish + - [x] Implement layout save/load: + - [x] Save node positions to JSON file in `~/.config/warppipe-gui/layout.json` + - [x] Store by stable ID (use NodeInfo.name as stable key) + - [x] Save on position change (debounced) + - [x] Load on startup and restore positions + - [x] Implement auto-arrange: + - [x] Menu or button to auto-layout nodes (left-to-right: sources → sinks) + - [x] Use simple grid or layered layout algorithm + - [x] Add visual polish: + - [x] Connection lines styled (color, width, curvature) + - [x] Highlight connections on hover + - [x] Port connection points visible and responsive + - [x] Add status bar: + - [x] Show connection status to PipeWire daemon + - [x] Show count of nodes, links + - [x] Verify: Layout persists across sessions, UI feels responsive and polished - [ ] Milestone 6 - Screenshot Infrastructure (AI-Assisted Debugging) - [ ] Add CLI flags to main.cpp via QCommandLineParser: diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index dc26cca..8c88851 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -2,19 +2,41 @@ #include "WarpGraphModel.h" #include +#include #include #include #include #include +#include #include #include GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, QWidget *parent) : QWidget(parent), m_client(client) { + m_layoutPath = + QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) + + QStringLiteral("/layout.json"); + m_model = new WarpGraphModel(client, this); + bool hasLayout = m_model->loadLayout(m_layoutPath); + m_scene = new QtNodes::BasicGraphicsScene(*m_model, this); + + QtNodes::ConnectionStyle::setConnectionStyle( + R"({"ConnectionStyle": { + "ConstructionColor": "#b4b4c8", + "NormalColor": "#c8c8dc", + "SelectedColor": "#ffa500", + "SelectedHaloColor": "#ffa50040", + "HoveredColor": "#f0c878", + "LineWidth": 2.4, + "ConstructionLineWidth": 1.8, + "PointDiameter": 10.0, + "UseDataDefinedColors": false + }})"); + m_view = new QtNodes::GraphicsView(m_scene); auto *layout = new QVBoxLayout(this); @@ -25,7 +47,20 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, connect(m_view, &QWidget::customContextMenuRequested, this, &GraphEditorWidget::onContextMenuRequested); + connect(m_model, &WarpGraphModel::nodePositionUpdated, 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); + }); + m_model->refreshFromClient(); + if (!hasLayout) { + m_model->autoArrange(); + } m_refreshTimer = new QTimer(this); connect(m_refreshTimer, &QTimer::timeout, this, @@ -35,6 +70,25 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, void GraphEditorWidget::onRefreshTimer() { m_model->refreshFromClient(); } +void GraphEditorWidget::scheduleSaveLayout() { + if (!m_saveTimer->isActive()) { + m_saveTimer->start(); + } +} + +int GraphEditorWidget::nodeCount() const { + return static_cast(m_model->allNodeIds().size()); +} + +int GraphEditorWidget::linkCount() const { + int count = 0; + for (auto nodeId : m_model->allNodeIds()) { + count += static_cast( + m_model->allConnectionIds(nodeId).size()); + } + return count / 2; +} + void GraphEditorWidget::onContextMenuRequested(const QPoint &pos) { QPointF scenePos = m_view->mapToScene(pos); @@ -64,17 +118,21 @@ void GraphEditorWidget::onContextMenuRequested(const QPoint &pos) { } void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos, - const QPointF &scenePos) { + const QPointF &scenePos) { QMenu menu; QAction *createSink = menu.addAction(QStringLiteral("Create Virtual Sink")); QAction *createSource = menu.addAction(QStringLiteral("Create Virtual Source")); + menu.addSeparator(); + QAction *autoArrange = menu.addAction(QStringLiteral("Auto-Arrange")); QAction *chosen = menu.exec(screenPos); if (chosen == createSink) { createVirtualNode(true, scenePos); } else if (chosen == createSource) { createVirtualNode(false, scenePos); + } else if (chosen == autoArrange) { + m_model->autoArrange(); } } diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index f210faf..45ffacf 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -2,6 +2,7 @@ #include +#include #include namespace QtNodes { @@ -10,6 +11,7 @@ class GraphicsView; } // namespace QtNodes class WarpGraphModel; +class QLabel; class QTimer; class GraphEditorWidget : public QWidget { @@ -19,9 +21,13 @@ public: explicit GraphEditorWidget(warppipe::Client *client, QWidget *parent = nullptr); + int nodeCount() const; + int linkCount() const; + private slots: void onRefreshTimer(); void onContextMenuRequested(const QPoint &pos); + void scheduleSaveLayout(); private: void showCanvasContextMenu(const QPoint &screenPos, const QPointF &scenePos); @@ -33,4 +39,6 @@ private: QtNodes::BasicGraphicsScene *m_scene = nullptr; QtNodes::GraphicsView *m_view = nullptr; QTimer *m_refreshTimer = nullptr; + QTimer *m_saveTimer = nullptr; + QString m_layoutPath; }; diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp index f0f0de5..3148a14 100644 --- a/gui/WarpGraphModel.cpp +++ b/gui/WarpGraphModel.cpp @@ -1,6 +1,11 @@ #include "WarpGraphModel.h" #include +#include +#include +#include +#include +#include #include #include @@ -430,7 +435,12 @@ void WarpGraphModel::refreshFromClient() { m_positions.emplace(qtId, pendingIt->second); m_pendingPositions.erase(pendingIt); } else { - m_positions.emplace(qtId, nextPosition(nodeIt->second)); + auto savedIt = m_savedPositions.find(nodeInfo.name); + if (savedIt != m_savedPositions.end()) { + m_positions.emplace(qtId, savedIt->second); + } else { + m_positions.emplace(qtId, nextPosition(nodeIt->second)); + } } Q_EMIT nodeCreated(qtId); @@ -625,6 +635,116 @@ WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) { return WarpNodeType::kUnknown; } +void WarpGraphModel::saveLayout(const QString &path) const { + QJsonArray nodesArray; + for (const auto &[qtId, data] : m_nodes) { + auto posIt = m_positions.find(qtId); + if (posIt == m_positions.end()) { + continue; + } + QJsonObject nodeObj; + nodeObj["name"] = QString::fromStdString(data.info.name); + nodeObj["x"] = posIt->second.x(); + nodeObj["y"] = posIt->second.y(); + nodesArray.append(nodeObj); + } + + QJsonObject root; + root["version"] = 1; + root["nodes"] = nodesArray; + + QFileInfo fi(path); + QDir dir = fi.absoluteDir(); + if (!dir.exists()) { + dir.mkpath("."); + } + + QFile file(path); + if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + file.write(QJsonDocument(root).toJson(QJsonDocument::Compact)); + } +} + +bool WarpGraphModel::loadLayout(const QString &path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + if (!doc.isObject()) { + return false; + } + + QJsonObject root = doc.object(); + if (root["version"].toInt() != 1) { + return false; + } + + m_savedPositions.clear(); + QJsonArray nodesArray = root["nodes"].toArray(); + for (const auto &val : nodesArray) { + QJsonObject obj = val.toObject(); + std::string name = obj["name"].toString().toStdString(); + double x = obj["x"].toDouble(); + double y = obj["y"].toDouble(); + m_savedPositions[name] = QPointF(x, y); + } + return !m_savedPositions.empty(); +} + +void WarpGraphModel::autoArrange() { + struct Column { + std::vector ids; + double maxWidth = 0.0; + }; + + Column sources; + Column apps; + Column sinks; + + for (const auto &[qtId, data] : m_nodes) { + WarpNodeType type = classifyNode(data.info); + QSize sz = estimateNodeSize(data); + double w = sz.width(); + + switch (type) { + case WarpNodeType::kHardwareSource: + case WarpNodeType::kVirtualSource: + sources.ids.push_back(qtId); + sources.maxWidth = std::max(sources.maxWidth, w); + break; + case WarpNodeType::kApplication: + apps.ids.push_back(qtId); + apps.maxWidth = std::max(apps.maxWidth, w); + break; + default: + sinks.ids.push_back(qtId); + sinks.maxWidth = std::max(sinks.maxWidth, w); + break; + } + } + + auto layoutColumn = [&](Column &col, double xOffset) { + double y = 0.0; + for (QtNodes::NodeId id : col.ids) { + auto it = m_nodes.find(id); + if (it == m_nodes.end()) continue; + QSize sz = estimateNodeSize(it->second); + m_positions[id] = QPointF(xOffset, y); + Q_EMIT nodePositionUpdated(id); + y += sz.height() + kVerticalGap; + } + }; + + double x = 0.0; + layoutColumn(sources, x); + x += sources.maxWidth + kHorizontalGap * 3; + layoutColumn(apps, x); + x += apps.maxWidth + kHorizontalGap * 3; + layoutColumn(sinks, x); +} + QVariant WarpGraphModel::styleForNode(WarpNodeType type, bool ghost) { QtNodes::NodeStyle style = QtNodes::StyleCollection::nodeStyle(); diff --git a/gui/WarpGraphModel.h b/gui/WarpGraphModel.h index a3f68cf..3c04055 100644 --- a/gui/WarpGraphModel.h +++ b/gui/WarpGraphModel.h @@ -67,6 +67,10 @@ public: void setPendingPosition(const std::string &nodeName, QPointF pos); static WarpNodeType classifyNode(const warppipe::NodeInfo &info); + void saveLayout(const QString &path) const; + bool loadLayout(const QString &path); + void autoArrange(); + private: static QString captionForNode(const warppipe::NodeInfo &info); static QVariant styleForNode(WarpNodeType type, bool ghost); @@ -96,4 +100,5 @@ private: bool m_refreshing = false; std::unordered_map m_pendingPositions; + std::unordered_map m_savedPositions; }; diff --git a/gui/main.cpp b/gui/main.cpp index ac16edb..3cc67f6 100644 --- a/gui/main.cpp +++ b/gui/main.cpp @@ -5,7 +5,10 @@ #include #include #include +#include #include +#include +#include #include @@ -27,12 +30,26 @@ int main(int argc, char *argv[]) { auto &client = result.value; QMainWindow window; - window.setWindowTitle("Warppipe — Audio Router"); + window.setWindowTitle("Warppipe \u2014 Audio Router"); auto *editor = new GraphEditorWidget(client.get(), &window); window.setCentralWidget(editor); window.resize(1280, 720); + auto *statusLabel = new QLabel(&window); + window.statusBar()->addWidget(statusLabel); + + auto *statusTimer = new QTimer(&window); + QObject::connect(statusTimer, &QTimer::timeout, [&]() { + int nodes = editor->nodeCount(); + int links = editor->linkCount(); + statusLabel->setText( + QString("PipeWire connected | %1 nodes | %2 links") + .arg(nodes) + .arg(links)); + }); + statusTimer->start(500); + auto *closeAction = new QAction(&window); closeAction->setShortcut(QKeySequence::Quit); QObject::connect(closeAction, &QAction::triggered, &window,