GUI Milestone 5

This commit is contained in:
Joey Yakimowich-Payne 2026-01-30 06:30:20 -07:00
commit 79cced017e
6 changed files with 228 additions and 20 deletions

View file

@ -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();
}
}

View file

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

View file

@ -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();

View file

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

View file

@ -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,