GUI Milestone 5
This commit is contained in:
parent
a369381b6c
commit
79cced017e
6 changed files with 228 additions and 20 deletions
34
GUI_PLAN.md
34
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:
|
||||
|
|
|
|||
|
|
@ -2,19 +2,41 @@
|
|||
#include "WarpGraphModel.h"
|
||||
|
||||
#include <QtNodes/BasicGraphicsScene>
|
||||
#include <QtNodes/ConnectionStyle>
|
||||
#include <QtNodes/GraphicsView>
|
||||
|
||||
#include <QInputDialog>
|
||||
#include <QMenu>
|
||||
#include <QMessageBox>
|
||||
#include <QStandardPaths>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
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<int>(m_model->allNodeIds().size());
|
||||
}
|
||||
|
||||
int GraphEditorWidget::linkCount() const {
|
||||
int count = 0;
|
||||
for (auto nodeId : m_model->allNodeIds()) {
|
||||
count += static_cast<int>(
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include <warppipe/warppipe.hpp>
|
||||
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
#include "WarpGraphModel.h"
|
||||
|
||||
#include <QColor>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QVariant>
|
||||
|
||||
|
|
@ -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<QtNodes::NodeId> 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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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<std::string, QPointF> m_pendingPositions;
|
||||
std::unordered_map<std::string, QPointF> m_savedPositions;
|
||||
};
|
||||
|
|
|
|||
19
gui/main.cpp
19
gui/main.cpp
|
|
@ -5,7 +5,10 @@
|
|||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QKeySequence>
|
||||
#include <QLabel>
|
||||
#include <QMainWindow>
|
||||
#include <QStatusBar>
|
||||
#include <QTimer>
|
||||
|
||||
#include <iostream>
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue