GUI Milestone 8c

This commit is contained in:
Joey Yakimowich-Payne 2026-01-30 08:57:33 -07:00
commit fa67dd3708
9 changed files with 458 additions and 22 deletions

View file

@ -1,4 +1,5 @@
#include "GraphEditorWidget.h"
#include "PresetManager.h"
#include "WarpGraphModel.h"
#include <QtNodes/BasicGraphicsScene>
@ -11,6 +12,7 @@
#include <QAction>
#include <QClipboard>
#include <QContextMenuEvent>
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFileDialog>
@ -21,10 +23,16 @@
#include <QJsonArray>
#include <QJsonDocument>
#include <QMenu>
#include <QListWidget>
#include <QMainWindow>
#include <QMessageBox>
#include <QMimeData>
#include <QPixmap>
#include <QPushButton>
#include <QSplitter>
#include <QStandardPaths>
#include <QStatusBar>
#include <QTabWidget>
#include <QTimer>
#include <QUndoCommand>
#include <QVBoxLayout>
@ -142,9 +150,58 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
m_view->viewport()->setFocusPolicy(Qt::StrongFocus);
m_view->viewport()->installEventFilter(this);
m_presetDir =
QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) +
QStringLiteral("/presets");
m_sidebar = new QTabWidget();
m_sidebar->setTabPosition(QTabWidget::North);
m_sidebar->setDocumentMode(true);
m_sidebar->setStyleSheet(QStringLiteral(
"QTabWidget::pane { border: none; background: #1a1a1e; }"
"QTabBar::tab { background: #24242a; color: #a0a8b6; padding: 8px 16px;"
" border: none; border-bottom: 2px solid transparent; }"
"QTabBar::tab:selected { color: #ecf0f6;"
" border-bottom: 2px solid #ffa500; }"
"QTabBar::tab:hover { background: #2e2e36; }"));
auto *presetsTab = new QWidget();
auto *presetsLayout = new QVBoxLayout(presetsTab);
presetsLayout->setContentsMargins(8, 8, 8, 8);
presetsLayout->setSpacing(6);
auto *savePresetBtn = new QPushButton(QStringLiteral("Save Preset..."));
savePresetBtn->setStyleSheet(QStringLiteral(
"QPushButton { background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;"
" border-radius: 4px; padding: 6px 12px; }"
"QPushButton:hover { background: #3a3a44; }"
"QPushButton:pressed { background: #44444e; }"));
connect(savePresetBtn, &QPushButton::clicked, this,
&GraphEditorWidget::savePreset);
auto *loadPresetBtn = new QPushButton(QStringLiteral("Load Preset..."));
loadPresetBtn->setStyleSheet(savePresetBtn->styleSheet());
connect(loadPresetBtn, &QPushButton::clicked, this,
&GraphEditorWidget::loadPreset);
presetsLayout->addWidget(savePresetBtn);
presetsLayout->addWidget(loadPresetBtn);
presetsLayout->addStretch();
m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS"));
m_splitter = new QSplitter(Qt::Horizontal);
m_splitter->addWidget(m_view);
m_splitter->addWidget(m_sidebar);
m_splitter->setStretchFactor(0, 1);
m_splitter->setStretchFactor(1, 0);
m_splitter->setSizes({1200, 320});
m_splitter->setStyleSheet(QStringLiteral(
"QSplitter::handle { background: #2a2a32; width: 2px; }"));
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(m_view);
layout->addWidget(m_splitter);
m_view->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_view, &QWidget::customContextMenuRequested, this,
@ -266,6 +323,9 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
Q_EMIT graphReady();
}
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this,
&GraphEditorWidget::saveLayoutWithViewState);
m_refreshTimer = new QTimer(this);
connect(m_refreshTimer, &QTimer::timeout, this,
&GraphEditorWidget::onRefreshTimer);
@ -430,6 +490,9 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
menu.addSeparator();
QAction *saveLayoutAs = menu.addAction(QStringLiteral("Save Layout As..."));
QAction *resetLayout = menu.addAction(QStringLiteral("Reset Layout"));
menu.addSeparator();
QAction *savePresetAction = menu.addAction(QStringLiteral("Save Preset..."));
QAction *loadPresetAction = menu.addAction(QStringLiteral("Load Preset..."));
QAction *chosen = menu.exec(screenPos);
if (chosen == createSink) {
@ -466,6 +529,10 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
m_model->autoArrange();
m_view->zoomFitAll();
saveLayoutWithViewState();
} else if (chosen == savePresetAction) {
savePreset();
} else if (chosen == loadPresetAction) {
loadPreset();
}
}
@ -868,6 +935,9 @@ void GraphEditorWidget::saveLayoutWithViewState() {
QPointF center = m_view->mapToScene(m_view->viewport()->rect().center());
vs.centerX = center.x();
vs.centerY = center.y();
QList<int> sizes = m_splitter->sizes();
vs.splitterGraph = sizes.value(0, 1200);
vs.splitterSidebar = sizes.value(1, 320);
vs.valid = true;
m_model->saveLayout(m_layoutPath, vs);
}
@ -877,7 +947,50 @@ void GraphEditorWidget::restoreViewState() {
if (vs.valid) {
m_view->setupScale(vs.scale);
m_view->centerOn(QPointF(vs.centerX, vs.centerY));
if (vs.splitterGraph > 0 || vs.splitterSidebar > 0) {
m_splitter->setSizes({vs.splitterGraph, vs.splitterSidebar});
}
} else {
m_view->zoomFitAll();
}
}
void GraphEditorWidget::savePreset() {
QDir dir(m_presetDir);
if (!dir.exists())
dir.mkpath(".");
QString path = QFileDialog::getSaveFileName(
this, QStringLiteral("Save Preset"), m_presetDir,
QStringLiteral("JSON files (*.json)"));
if (path.isEmpty())
return;
if (!path.endsWith(QStringLiteral(".json"), Qt::CaseInsensitive))
path += QStringLiteral(".json");
if (PresetManager::savePreset(path, m_client, m_model)) {
if (auto *mw = qobject_cast<QMainWindow *>(window()))
mw->statusBar()->showMessage(
QStringLiteral("Preset saved: ") + QFileInfo(path).fileName(), 4000);
} else {
QMessageBox::warning(this, QStringLiteral("Error"),
QStringLiteral("Failed to save preset."));
}
}
void GraphEditorWidget::loadPreset() {
QString path = QFileDialog::getOpenFileName(
this, QStringLiteral("Load Preset"), m_presetDir,
QStringLiteral("JSON files (*.json)"));
if (path.isEmpty())
return;
if (PresetManager::loadPreset(path, m_client, m_model)) {
if (auto *mw = qobject_cast<QMainWindow *>(window()))
mw->statusBar()->showMessage(
QStringLiteral("Preset loaded: ") + QFileInfo(path).fileName(), 4000);
} else {
QMessageBox::warning(this, QStringLiteral("Error"),
QStringLiteral("Failed to load preset."));
}
}

View file

@ -18,6 +18,8 @@ class GraphicsView;
class WarpGraphModel;
class QLabel;
class QSplitter;
class QTabWidget;
class QTimer;
class DeleteVirtualNodeCommand;
@ -60,6 +62,8 @@ private:
void tryResolvePendingLinks();
void saveLayoutWithViewState();
void restoreViewState();
void savePreset();
void loadPreset();
struct PendingPasteLink {
std::string outNodeName;
@ -72,9 +76,12 @@ private:
WarpGraphModel *m_model = nullptr;
QtNodes::BasicGraphicsScene *m_scene = nullptr;
QtNodes::GraphicsView *m_view = nullptr;
QSplitter *m_splitter = nullptr;
QTabWidget *m_sidebar = nullptr;
QTimer *m_refreshTimer = nullptr;
QTimer *m_saveTimer = nullptr;
QString m_layoutPath;
QString m_presetDir;
QString m_debugScreenshotDir;
bool m_graphReady = false;
QJsonObject m_clipboardJson;

177
gui/PresetManager.cpp Normal file
View file

@ -0,0 +1,177 @@
#include "PresetManager.h"
#include "WarpGraphModel.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
bool PresetManager::savePreset(const QString &path, warppipe::Client *client,
const WarpGraphModel *model) {
if (!client || !model)
return false;
QJsonArray devicesArray;
auto nodesResult = client->ListNodes();
if (nodesResult.ok()) {
for (const auto &node : nodesResult.value) {
if (!node.is_virtual)
continue;
QJsonObject dev;
dev["name"] = QString::fromStdString(node.name);
dev["description"] = QString::fromStdString(node.description);
dev["media_class"] = QString::fromStdString(node.media_class);
auto portsResult = client->ListPorts(node.id);
int channels = 0;
if (portsResult.ok()) {
for (const auto &port : portsResult.value) {
if (port.is_input)
++channels;
}
if (channels == 0) {
for (const auto &port : portsResult.value) {
if (!port.is_input)
++channels;
}
}
}
dev["channels"] = channels > 0 ? channels : 2;
devicesArray.append(dev);
}
}
QJsonArray routingArray;
auto linksResult = client->ListLinks();
if (linksResult.ok() && nodesResult.ok()) {
std::unordered_map<uint32_t, std::pair<std::string, std::string>> portMap;
for (const auto &node : nodesResult.value) {
auto portsResult = client->ListPorts(node.id);
if (!portsResult.ok())
continue;
for (const auto &port : portsResult.value) {
portMap[port.id.value] = {node.name, port.name};
}
}
for (const auto &link : linksResult.value) {
auto outIt = portMap.find(link.output_port.value);
auto inIt = portMap.find(link.input_port.value);
if (outIt == portMap.end() || inIt == portMap.end())
continue;
QJsonObject route;
route["out_node"] = QString::fromStdString(outIt->second.first);
route["out_port"] = QString::fromStdString(outIt->second.second);
route["in_node"] = QString::fromStdString(inIt->second.first);
route["in_port"] = QString::fromStdString(inIt->second.second);
routingArray.append(route);
}
}
QJsonArray layoutArray;
for (auto qtId : model->allNodeIds()) {
const WarpNodeData *data = model->warpNodeData(qtId);
if (!data)
continue;
QPointF pos =
model->nodeData(qtId, QtNodes::NodeRole::Position).toPointF();
QJsonObject nodeLayout;
nodeLayout["name"] = QString::fromStdString(data->info.name);
nodeLayout["x"] = pos.x();
nodeLayout["y"] = pos.y();
layoutArray.append(nodeLayout);
}
QJsonObject root;
root["version"] = 1;
root["virtual_devices"] = devicesArray;
root["routing"] = routingArray;
root["layout"] = layoutArray;
QFileInfo fi(path);
QDir dir = fi.absoluteDir();
if (!dir.exists())
dir.mkpath(".");
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
return false;
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
return true;
}
bool PresetManager::loadPreset(const QString &path, warppipe::Client *client,
WarpGraphModel *model) {
if (!client || !model)
return false;
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();
int version = root["version"].toInt();
if (version < 1 || version > 1)
return false;
auto existingNodes = client->ListNodes();
std::unordered_set<std::string> existingNames;
if (existingNodes.ok()) {
for (const auto &node : existingNodes.value) {
existingNames.insert(node.name);
}
}
QJsonArray devicesArray = root["virtual_devices"].toArray();
for (const auto &val : devicesArray) {
QJsonObject dev = val.toObject();
std::string name = dev["name"].toString().toStdString();
std::string mediaClass = dev["media_class"].toString().toStdString();
int channels = dev["channels"].toInt(2);
if (existingNames.count(name))
continue;
warppipe::VirtualNodeOptions opts;
opts.format.channels = static_cast<uint32_t>(channels);
bool isSink = mediaClass == "Audio/Sink" || mediaClass == "Audio/Duplex";
if (isSink) {
client->CreateVirtualSink(name, opts);
} else {
client->CreateVirtualSource(name, opts);
}
}
QJsonArray layoutArray = root["layout"].toArray();
for (const auto &val : layoutArray) {
QJsonObject obj = val.toObject();
std::string name = obj["name"].toString().toStdString();
double x = obj["x"].toDouble();
double y = obj["y"].toDouble();
model->setPendingPosition(name, QPointF(x, y));
}
model->refreshFromClient();
QJsonArray routingArray = root["routing"].toArray();
for (const auto &val : routingArray) {
QJsonObject route = val.toObject();
std::string outNode = route["out_node"].toString().toStdString();
std::string outPort = route["out_port"].toString().toStdString();
std::string inNode = route["in_node"].toString().toStdString();
std::string inPort = route["in_port"].toString().toStdString();
client->CreateLinkByName(outNode, outPort, inNode, inPort,
warppipe::LinkOptions{});
}
model->refreshFromClient();
return true;
}

15
gui/PresetManager.h Normal file
View file

@ -0,0 +1,15 @@
#pragma once
#include <warppipe/warppipe.hpp>
#include <QString>
class WarpGraphModel;
class PresetManager {
public:
static bool savePreset(const QString &path, warppipe::Client *client,
const WarpGraphModel *model);
static bool loadPreset(const QString &path, warppipe::Client *client,
WarpGraphModel *model);
};

View file

@ -810,6 +810,10 @@ void WarpGraphModel::saveLayout(const QString &path,
viewObj["scale"] = viewState.scale;
viewObj["center_x"] = viewState.centerX;
viewObj["center_y"] = viewState.centerY;
if (viewState.splitterGraph > 0 || viewState.splitterSidebar > 0) {
viewObj["splitter_graph"] = viewState.splitterGraph;
viewObj["splitter_sidebar"] = viewState.splitterSidebar;
}
root["view"] = viewObj;
}
@ -867,6 +871,8 @@ bool WarpGraphModel::loadLayout(const QString &path) {
m_savedViewState.scale = viewObj["scale"].toDouble(1.0);
m_savedViewState.centerX = viewObj["center_x"].toDouble();
m_savedViewState.centerY = viewObj["center_y"].toDouble();
m_savedViewState.splitterGraph = viewObj["splitter_graph"].toInt(0);
m_savedViewState.splitterSidebar = viewObj["splitter_sidebar"].toInt(0);
m_savedViewState.valid = true;
}

View file

@ -73,6 +73,8 @@ public:
double scale;
double centerX;
double centerY;
int splitterGraph;
int splitterSidebar;
bool valid;
};