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] Call `Client::RemoveNode(nodeId)` and remove from graph
|
||||||
- [x] Verify: Can create/delete virtual sinks and sources via right-click
|
- [x] Verify: Can create/delete virtual sinks and sources via right-click
|
||||||
|
|
||||||
- [ ] Milestone 5 - Layout Persistence and Polish
|
- [x] Milestone 5 - Layout Persistence and Polish
|
||||||
- [ ] Implement layout save/load:
|
- [x] Implement layout save/load:
|
||||||
- [ ] Save node positions to JSON file in `~/.config/warppipe-gui/layout.json`
|
- [x] Save node positions to JSON file in `~/.config/warppipe-gui/layout.json`
|
||||||
- [ ] Store by stable ID (use NodeInfo.name as stable key)
|
- [x] Store by stable ID (use NodeInfo.name as stable key)
|
||||||
- [ ] Save on position change (debounced)
|
- [x] Save on position change (debounced)
|
||||||
- [ ] Load on startup and restore positions
|
- [x] Load on startup and restore positions
|
||||||
- [ ] Implement auto-arrange:
|
- [x] Implement auto-arrange:
|
||||||
- [ ] Menu or button to auto-layout nodes (left-to-right: sources → sinks)
|
- [x] Menu or button to auto-layout nodes (left-to-right: sources → sinks)
|
||||||
- [ ] Use simple grid or layered layout algorithm
|
- [x] Use simple grid or layered layout algorithm
|
||||||
- [ ] Add visual polish:
|
- [x] Add visual polish:
|
||||||
- [ ] Connection lines styled (color, width, curvature)
|
- [x] Connection lines styled (color, width, curvature)
|
||||||
- [ ] Highlight connections on hover
|
- [x] Highlight connections on hover
|
||||||
- [ ] Port connection points visible and responsive
|
- [x] Port connection points visible and responsive
|
||||||
- [ ] Add status bar:
|
- [x] Add status bar:
|
||||||
- [ ] Show connection status to PipeWire daemon
|
- [x] Show connection status to PipeWire daemon
|
||||||
- [ ] Show count of nodes, links
|
- [x] Show count of nodes, links
|
||||||
- [ ] Verify: Layout persists across sessions, UI feels responsive and polished
|
- [x] Verify: Layout persists across sessions, UI feels responsive and polished
|
||||||
|
|
||||||
- [ ] Milestone 6 - Screenshot Infrastructure (AI-Assisted Debugging)
|
- [ ] Milestone 6 - Screenshot Infrastructure (AI-Assisted Debugging)
|
||||||
- [ ] Add CLI flags to main.cpp via QCommandLineParser:
|
- [ ] Add CLI flags to main.cpp via QCommandLineParser:
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,41 @@
|
||||||
#include "WarpGraphModel.h"
|
#include "WarpGraphModel.h"
|
||||||
|
|
||||||
#include <QtNodes/BasicGraphicsScene>
|
#include <QtNodes/BasicGraphicsScene>
|
||||||
|
#include <QtNodes/ConnectionStyle>
|
||||||
#include <QtNodes/GraphicsView>
|
#include <QtNodes/GraphicsView>
|
||||||
|
|
||||||
#include <QInputDialog>
|
#include <QInputDialog>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
|
#include <QStandardPaths>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
||||||
QWidget *parent)
|
QWidget *parent)
|
||||||
: QWidget(parent), m_client(client) {
|
: QWidget(parent), m_client(client) {
|
||||||
|
m_layoutPath =
|
||||||
|
QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) +
|
||||||
|
QStringLiteral("/layout.json");
|
||||||
|
|
||||||
m_model = new WarpGraphModel(client, this);
|
m_model = new WarpGraphModel(client, this);
|
||||||
|
bool hasLayout = m_model->loadLayout(m_layoutPath);
|
||||||
|
|
||||||
m_scene = new QtNodes::BasicGraphicsScene(*m_model, this);
|
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);
|
m_view = new QtNodes::GraphicsView(m_scene);
|
||||||
|
|
||||||
auto *layout = new QVBoxLayout(this);
|
auto *layout = new QVBoxLayout(this);
|
||||||
|
|
@ -25,7 +47,20 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
||||||
connect(m_view, &QWidget::customContextMenuRequested, this,
|
connect(m_view, &QWidget::customContextMenuRequested, this,
|
||||||
&GraphEditorWidget::onContextMenuRequested);
|
&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();
|
m_model->refreshFromClient();
|
||||||
|
if (!hasLayout) {
|
||||||
|
m_model->autoArrange();
|
||||||
|
}
|
||||||
|
|
||||||
m_refreshTimer = new QTimer(this);
|
m_refreshTimer = new QTimer(this);
|
||||||
connect(m_refreshTimer, &QTimer::timeout, this,
|
connect(m_refreshTimer, &QTimer::timeout, this,
|
||||||
|
|
@ -35,6 +70,25 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
||||||
|
|
||||||
void GraphEditorWidget::onRefreshTimer() { m_model->refreshFromClient(); }
|
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) {
|
void GraphEditorWidget::onContextMenuRequested(const QPoint &pos) {
|
||||||
QPointF scenePos = m_view->mapToScene(pos);
|
QPointF scenePos = m_view->mapToScene(pos);
|
||||||
|
|
||||||
|
|
@ -69,12 +123,16 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
|
||||||
QAction *createSink = menu.addAction(QStringLiteral("Create Virtual Sink"));
|
QAction *createSink = menu.addAction(QStringLiteral("Create Virtual Sink"));
|
||||||
QAction *createSource =
|
QAction *createSource =
|
||||||
menu.addAction(QStringLiteral("Create Virtual Source"));
|
menu.addAction(QStringLiteral("Create Virtual Source"));
|
||||||
|
menu.addSeparator();
|
||||||
|
QAction *autoArrange = menu.addAction(QStringLiteral("Auto-Arrange"));
|
||||||
|
|
||||||
QAction *chosen = menu.exec(screenPos);
|
QAction *chosen = menu.exec(screenPos);
|
||||||
if (chosen == createSink) {
|
if (chosen == createSink) {
|
||||||
createVirtualNode(true, scenePos);
|
createVirtualNode(true, scenePos);
|
||||||
} else if (chosen == createSource) {
|
} else if (chosen == createSource) {
|
||||||
createVirtualNode(false, scenePos);
|
createVirtualNode(false, scenePos);
|
||||||
|
} else if (chosen == autoArrange) {
|
||||||
|
m_model->autoArrange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
#include <warppipe/warppipe.hpp>
|
#include <warppipe/warppipe.hpp>
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
namespace QtNodes {
|
namespace QtNodes {
|
||||||
|
|
@ -10,6 +11,7 @@ class GraphicsView;
|
||||||
} // namespace QtNodes
|
} // namespace QtNodes
|
||||||
|
|
||||||
class WarpGraphModel;
|
class WarpGraphModel;
|
||||||
|
class QLabel;
|
||||||
class QTimer;
|
class QTimer;
|
||||||
|
|
||||||
class GraphEditorWidget : public QWidget {
|
class GraphEditorWidget : public QWidget {
|
||||||
|
|
@ -19,9 +21,13 @@ public:
|
||||||
explicit GraphEditorWidget(warppipe::Client *client,
|
explicit GraphEditorWidget(warppipe::Client *client,
|
||||||
QWidget *parent = nullptr);
|
QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
int nodeCount() const;
|
||||||
|
int linkCount() const;
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onRefreshTimer();
|
void onRefreshTimer();
|
||||||
void onContextMenuRequested(const QPoint &pos);
|
void onContextMenuRequested(const QPoint &pos);
|
||||||
|
void scheduleSaveLayout();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void showCanvasContextMenu(const QPoint &screenPos, const QPointF &scenePos);
|
void showCanvasContextMenu(const QPoint &screenPos, const QPointF &scenePos);
|
||||||
|
|
@ -33,4 +39,6 @@ private:
|
||||||
QtNodes::BasicGraphicsScene *m_scene = nullptr;
|
QtNodes::BasicGraphicsScene *m_scene = nullptr;
|
||||||
QtNodes::GraphicsView *m_view = nullptr;
|
QtNodes::GraphicsView *m_view = nullptr;
|
||||||
QTimer *m_refreshTimer = nullptr;
|
QTimer *m_refreshTimer = nullptr;
|
||||||
|
QTimer *m_saveTimer = nullptr;
|
||||||
|
QString m_layoutPath;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
#include "WarpGraphModel.h"
|
#include "WarpGraphModel.h"
|
||||||
|
|
||||||
#include <QColor>
|
#include <QColor>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
|
|
||||||
|
|
@ -429,9 +434,14 @@ void WarpGraphModel::refreshFromClient() {
|
||||||
if (pendingIt != m_pendingPositions.end()) {
|
if (pendingIt != m_pendingPositions.end()) {
|
||||||
m_positions.emplace(qtId, pendingIt->second);
|
m_positions.emplace(qtId, pendingIt->second);
|
||||||
m_pendingPositions.erase(pendingIt);
|
m_pendingPositions.erase(pendingIt);
|
||||||
|
} else {
|
||||||
|
auto savedIt = m_savedPositions.find(nodeInfo.name);
|
||||||
|
if (savedIt != m_savedPositions.end()) {
|
||||||
|
m_positions.emplace(qtId, savedIt->second);
|
||||||
} else {
|
} else {
|
||||||
m_positions.emplace(qtId, nextPosition(nodeIt->second));
|
m_positions.emplace(qtId, nextPosition(nodeIt->second));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Q_EMIT nodeCreated(qtId);
|
Q_EMIT nodeCreated(qtId);
|
||||||
}
|
}
|
||||||
|
|
@ -625,6 +635,116 @@ WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) {
|
||||||
return WarpNodeType::kUnknown;
|
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) {
|
QVariant WarpGraphModel::styleForNode(WarpNodeType type, bool ghost) {
|
||||||
QtNodes::NodeStyle style = QtNodes::StyleCollection::nodeStyle();
|
QtNodes::NodeStyle style = QtNodes::StyleCollection::nodeStyle();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,10 @@ public:
|
||||||
void setPendingPosition(const std::string &nodeName, QPointF pos);
|
void setPendingPosition(const std::string &nodeName, QPointF pos);
|
||||||
static WarpNodeType classifyNode(const warppipe::NodeInfo &info);
|
static WarpNodeType classifyNode(const warppipe::NodeInfo &info);
|
||||||
|
|
||||||
|
void saveLayout(const QString &path) const;
|
||||||
|
bool loadLayout(const QString &path);
|
||||||
|
void autoArrange();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static QString captionForNode(const warppipe::NodeInfo &info);
|
static QString captionForNode(const warppipe::NodeInfo &info);
|
||||||
static QVariant styleForNode(WarpNodeType type, bool ghost);
|
static QVariant styleForNode(WarpNodeType type, bool ghost);
|
||||||
|
|
@ -96,4 +100,5 @@ private:
|
||||||
bool m_refreshing = false;
|
bool m_refreshing = false;
|
||||||
|
|
||||||
std::unordered_map<std::string, QPointF> m_pendingPositions;
|
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 <QAction>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QKeySequence>
|
#include <QKeySequence>
|
||||||
|
#include <QLabel>
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
|
#include <QStatusBar>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
|
||||||
|
|
@ -27,12 +30,26 @@ int main(int argc, char *argv[]) {
|
||||||
auto &client = result.value;
|
auto &client = result.value;
|
||||||
|
|
||||||
QMainWindow window;
|
QMainWindow window;
|
||||||
window.setWindowTitle("Warppipe — Audio Router");
|
window.setWindowTitle("Warppipe \u2014 Audio Router");
|
||||||
|
|
||||||
auto *editor = new GraphEditorWidget(client.get(), &window);
|
auto *editor = new GraphEditorWidget(client.get(), &window);
|
||||||
window.setCentralWidget(editor);
|
window.setCentralWidget(editor);
|
||||||
window.resize(1280, 720);
|
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);
|
auto *closeAction = new QAction(&window);
|
||||||
closeAction->setShortcut(QKeySequence::Quit);
|
closeAction->setShortcut(QKeySequence::Quit);
|
||||||
QObject::connect(closeAction, &QAction::triggered, &window,
|
QObject::connect(closeAction, &QAction::triggered, &window,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue