GUI Milestone 8c
This commit is contained in:
parent
befe57530a
commit
fa67dd3708
9 changed files with 458 additions and 22 deletions
|
|
@ -82,6 +82,7 @@ if(WARPPIPE_BUILD_GUI)
|
||||||
gui/main.cpp
|
gui/main.cpp
|
||||||
gui/WarpGraphModel.cpp
|
gui/WarpGraphModel.cpp
|
||||||
gui/GraphEditorWidget.cpp
|
gui/GraphEditorWidget.cpp
|
||||||
|
gui/PresetManager.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(warppipe-gui PRIVATE
|
target_link_libraries(warppipe-gui PRIVATE
|
||||||
|
|
@ -96,6 +97,7 @@ if(WARPPIPE_BUILD_GUI)
|
||||||
tests/gui/warppipe_gui_tests.cpp
|
tests/gui/warppipe_gui_tests.cpp
|
||||||
gui/WarpGraphModel.cpp
|
gui/WarpGraphModel.cpp
|
||||||
gui/GraphEditorWidget.cpp
|
gui/GraphEditorWidget.cpp
|
||||||
|
gui/PresetManager.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING)
|
target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING)
|
||||||
|
|
|
||||||
42
GUI_PLAN.md
42
GUI_PLAN.md
|
|
@ -210,27 +210,27 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library.
|
||||||
- [x] Restore ghosts from layout on load (before live sync)
|
- [x] Restore ghosts from layout on load (before live sync)
|
||||||
- [x] Add middle-click center: `eventFilter` on viewport catches `MiddleButton` → `m_view->centerOn(mapToScene(pos))`
|
- [x] Add middle-click center: `eventFilter` on viewport catches `MiddleButton` → `m_view->centerOn(mapToScene(pos))`
|
||||||
- [x] Add tests for view state save/load round-trip and ghost persistence
|
- [x] Add tests for view state save/load round-trip and ghost persistence
|
||||||
- [ ] Milestone 8c - Sidebar and Preset System
|
- [x] Milestone 8c - Sidebar and Preset System
|
||||||
- [ ] Add `QSplitter` between graph view and sidebar panel
|
- [x] Add `QSplitter` between graph view and sidebar panel
|
||||||
- [ ] Graph view (stretch factor 1) on left, sidebar (stretch factor 0) on right
|
- [x] Graph view (stretch factor 1) on left, sidebar (stretch factor 0) on right
|
||||||
- [ ] Persist splitter sizes in layout JSON, restore on load
|
- [x] Persist splitter sizes in layout JSON, restore on load
|
||||||
- [ ] Default sizes: graph 1200, sidebar 320
|
- [x] Default sizes: graph 1200, sidebar 320
|
||||||
- [ ] Add `QTabWidget` sidebar with styled tabs (dark theme)
|
- [x] Add `QTabWidget` sidebar with styled tabs (dark theme)
|
||||||
- [ ] Tab styling: dark background, selected tab has accent underline
|
- [x] Tab styling: dark background, selected tab has accent underline
|
||||||
- [ ] Initially one tab: "PRESETS" (meters/mixer tabs added in M8d/M8e)
|
- [x] Initially one tab: "PRESETS" (meters/mixer tabs added in M8d/M8e)
|
||||||
- [ ] Implement `PresetManager` class:
|
- [x] Implement `PresetManager` class:
|
||||||
- [ ] `savePreset(path)` → serialize to JSON:
|
- [x] `savePreset(path)` → serialize to JSON:
|
||||||
- [ ] Virtual devices: name, description, media_class, channels, rate
|
- [x] Virtual devices: name, description, media_class, channels, rate
|
||||||
- [ ] Routing: links by stable_id:port_name pairs
|
- [x] Routing: links by stable_id:port_name pairs
|
||||||
- [ ] UI layout: node positions, view state
|
- [x] UI layout: node positions, view state
|
||||||
- [ ] `loadPreset(path)` → apply from JSON:
|
- [x] `loadPreset(path)` → apply from JSON:
|
||||||
- [ ] Create missing virtual devices
|
- [x] Create missing virtual devices
|
||||||
- [ ] Re-create links from routing entries
|
- [x] Re-create links from routing entries
|
||||||
- [ ] Apply layout positions
|
- [x] Apply layout positions
|
||||||
- [ ] Save on quit via `QCoreApplication::aboutToQuit` signal
|
- [x] Save on quit via `QCoreApplication::aboutToQuit` signal
|
||||||
- [ ] Add "Save Preset..." context menu action → `QFileDialog::getSaveFileName()`
|
- [x] Add "Save Preset..." context menu action → `QFileDialog::getSaveFileName()`
|
||||||
- [ ] Add "Load Preset..." context menu action → `QFileDialog::getOpenFileName()`
|
- [x] Add "Load Preset..." context menu action → `QFileDialog::getOpenFileName()`
|
||||||
- [ ] Add tests for preset save/load round-trip
|
- [x] Add tests for preset save/load round-trip
|
||||||
- [ ] Milestone 8d - Volume/Mute Controls (requires core API: `SetNodeVolume()`)
|
- [ ] Milestone 8d - Volume/Mute Controls (requires core API: `SetNodeVolume()`)
|
||||||
- [ ] Add `NodeVolumeState` struct: `{ float volume; bool mute; }`
|
- [ ] Add `NodeVolumeState` struct: `{ float volume; bool mute; }`
|
||||||
- [ ] Add `ClickSlider : QSlider` — click jumps to position instead of page-stepping
|
- [ ] Add `ClickSlider : QSlider` — click jumps to position instead of page-stepping
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#include "GraphEditorWidget.h"
|
#include "GraphEditorWidget.h"
|
||||||
|
#include "PresetManager.h"
|
||||||
#include "WarpGraphModel.h"
|
#include "WarpGraphModel.h"
|
||||||
|
|
||||||
#include <QtNodes/BasicGraphicsScene>
|
#include <QtNodes/BasicGraphicsScene>
|
||||||
|
|
@ -11,6 +12,7 @@
|
||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
#include <QContextMenuEvent>
|
#include <QContextMenuEvent>
|
||||||
|
#include <QCoreApplication>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
|
|
@ -21,10 +23,16 @@
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
|
#include <QListWidget>
|
||||||
|
#include <QMainWindow>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QMimeData>
|
#include <QMimeData>
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QSplitter>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
|
#include <QStatusBar>
|
||||||
|
#include <QTabWidget>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QUndoCommand>
|
#include <QUndoCommand>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
@ -142,9 +150,58 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
||||||
m_view->viewport()->setFocusPolicy(Qt::StrongFocus);
|
m_view->viewport()->setFocusPolicy(Qt::StrongFocus);
|
||||||
m_view->viewport()->installEventFilter(this);
|
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);
|
auto *layout = new QVBoxLayout(this);
|
||||||
layout->setContentsMargins(0, 0, 0, 0);
|
layout->setContentsMargins(0, 0, 0, 0);
|
||||||
layout->addWidget(m_view);
|
layout->addWidget(m_splitter);
|
||||||
|
|
||||||
m_view->setContextMenuPolicy(Qt::CustomContextMenu);
|
m_view->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||||
connect(m_view, &QWidget::customContextMenuRequested, this,
|
connect(m_view, &QWidget::customContextMenuRequested, this,
|
||||||
|
|
@ -266,6 +323,9 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
||||||
Q_EMIT graphReady();
|
Q_EMIT graphReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this,
|
||||||
|
&GraphEditorWidget::saveLayoutWithViewState);
|
||||||
|
|
||||||
m_refreshTimer = new QTimer(this);
|
m_refreshTimer = new QTimer(this);
|
||||||
connect(m_refreshTimer, &QTimer::timeout, this,
|
connect(m_refreshTimer, &QTimer::timeout, this,
|
||||||
&GraphEditorWidget::onRefreshTimer);
|
&GraphEditorWidget::onRefreshTimer);
|
||||||
|
|
@ -430,6 +490,9 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
QAction *saveLayoutAs = menu.addAction(QStringLiteral("Save Layout As..."));
|
QAction *saveLayoutAs = menu.addAction(QStringLiteral("Save Layout As..."));
|
||||||
QAction *resetLayout = menu.addAction(QStringLiteral("Reset Layout"));
|
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);
|
QAction *chosen = menu.exec(screenPos);
|
||||||
if (chosen == createSink) {
|
if (chosen == createSink) {
|
||||||
|
|
@ -466,6 +529,10 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
|
||||||
m_model->autoArrange();
|
m_model->autoArrange();
|
||||||
m_view->zoomFitAll();
|
m_view->zoomFitAll();
|
||||||
saveLayoutWithViewState();
|
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());
|
QPointF center = m_view->mapToScene(m_view->viewport()->rect().center());
|
||||||
vs.centerX = center.x();
|
vs.centerX = center.x();
|
||||||
vs.centerY = center.y();
|
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;
|
vs.valid = true;
|
||||||
m_model->saveLayout(m_layoutPath, vs);
|
m_model->saveLayout(m_layoutPath, vs);
|
||||||
}
|
}
|
||||||
|
|
@ -877,7 +947,50 @@ void GraphEditorWidget::restoreViewState() {
|
||||||
if (vs.valid) {
|
if (vs.valid) {
|
||||||
m_view->setupScale(vs.scale);
|
m_view->setupScale(vs.scale);
|
||||||
m_view->centerOn(QPointF(vs.centerX, vs.centerY));
|
m_view->centerOn(QPointF(vs.centerX, vs.centerY));
|
||||||
|
if (vs.splitterGraph > 0 || vs.splitterSidebar > 0) {
|
||||||
|
m_splitter->setSizes({vs.splitterGraph, vs.splitterSidebar});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
m_view->zoomFitAll();
|
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."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ class GraphicsView;
|
||||||
|
|
||||||
class WarpGraphModel;
|
class WarpGraphModel;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
|
class QSplitter;
|
||||||
|
class QTabWidget;
|
||||||
class QTimer;
|
class QTimer;
|
||||||
class DeleteVirtualNodeCommand;
|
class DeleteVirtualNodeCommand;
|
||||||
|
|
||||||
|
|
@ -60,6 +62,8 @@ private:
|
||||||
void tryResolvePendingLinks();
|
void tryResolvePendingLinks();
|
||||||
void saveLayoutWithViewState();
|
void saveLayoutWithViewState();
|
||||||
void restoreViewState();
|
void restoreViewState();
|
||||||
|
void savePreset();
|
||||||
|
void loadPreset();
|
||||||
|
|
||||||
struct PendingPasteLink {
|
struct PendingPasteLink {
|
||||||
std::string outNodeName;
|
std::string outNodeName;
|
||||||
|
|
@ -72,9 +76,12 @@ private:
|
||||||
WarpGraphModel *m_model = nullptr;
|
WarpGraphModel *m_model = nullptr;
|
||||||
QtNodes::BasicGraphicsScene *m_scene = nullptr;
|
QtNodes::BasicGraphicsScene *m_scene = nullptr;
|
||||||
QtNodes::GraphicsView *m_view = nullptr;
|
QtNodes::GraphicsView *m_view = nullptr;
|
||||||
|
QSplitter *m_splitter = nullptr;
|
||||||
|
QTabWidget *m_sidebar = nullptr;
|
||||||
QTimer *m_refreshTimer = nullptr;
|
QTimer *m_refreshTimer = nullptr;
|
||||||
QTimer *m_saveTimer = nullptr;
|
QTimer *m_saveTimer = nullptr;
|
||||||
QString m_layoutPath;
|
QString m_layoutPath;
|
||||||
|
QString m_presetDir;
|
||||||
QString m_debugScreenshotDir;
|
QString m_debugScreenshotDir;
|
||||||
bool m_graphReady = false;
|
bool m_graphReady = false;
|
||||||
QJsonObject m_clipboardJson;
|
QJsonObject m_clipboardJson;
|
||||||
|
|
|
||||||
177
gui/PresetManager.cpp
Normal file
177
gui/PresetManager.cpp
Normal 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
15
gui/PresetManager.h
Normal 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);
|
||||||
|
};
|
||||||
|
|
@ -810,6 +810,10 @@ void WarpGraphModel::saveLayout(const QString &path,
|
||||||
viewObj["scale"] = viewState.scale;
|
viewObj["scale"] = viewState.scale;
|
||||||
viewObj["center_x"] = viewState.centerX;
|
viewObj["center_x"] = viewState.centerX;
|
||||||
viewObj["center_y"] = viewState.centerY;
|
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;
|
root["view"] = viewObj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -867,6 +871,8 @@ bool WarpGraphModel::loadLayout(const QString &path) {
|
||||||
m_savedViewState.scale = viewObj["scale"].toDouble(1.0);
|
m_savedViewState.scale = viewObj["scale"].toDouble(1.0);
|
||||||
m_savedViewState.centerX = viewObj["center_x"].toDouble();
|
m_savedViewState.centerX = viewObj["center_x"].toDouble();
|
||||||
m_savedViewState.centerY = viewObj["center_y"].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;
|
m_savedViewState.valid = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@ public:
|
||||||
double scale;
|
double scale;
|
||||||
double centerX;
|
double centerX;
|
||||||
double centerY;
|
double centerY;
|
||||||
|
int splitterGraph;
|
||||||
|
int splitterSidebar;
|
||||||
bool valid;
|
bool valid;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
#include <warppipe/warppipe.hpp>
|
#include <warppipe/warppipe.hpp>
|
||||||
|
|
||||||
#include "../../gui/GraphEditorWidget.h"
|
#include "../../gui/GraphEditorWidget.h"
|
||||||
|
#include "../../gui/PresetManager.h"
|
||||||
#include "../../gui/WarpGraphModel.h"
|
#include "../../gui/WarpGraphModel.h"
|
||||||
|
|
||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
|
|
||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
|
@ -849,3 +853,113 @@ TEST_CASE("clearSavedPositions resets model positions") {
|
||||||
|
|
||||||
REQUIRE(posAfter != QPointF(0, 0));
|
REQUIRE(posAfter != QPointF(0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("preset save/load round-trip preserves virtual devices and layout") {
|
||||||
|
auto tc = TestClient::Create();
|
||||||
|
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||||
|
ensureApp();
|
||||||
|
|
||||||
|
REQUIRE(tc.client->Test_InsertNode(
|
||||||
|
MakeNode(100500, "preset-vsink", "Audio/Sink", {}, {}, true)).ok());
|
||||||
|
REQUIRE(tc.client->Test_InsertPort(
|
||||||
|
MakePort(100501, 100500, "FL", true)).ok());
|
||||||
|
REQUIRE(tc.client->Test_InsertPort(
|
||||||
|
MakePort(100502, 100500, "FR", true)).ok());
|
||||||
|
|
||||||
|
REQUIRE(tc.client->Test_InsertNode(
|
||||||
|
MakeNode(100503, "preset-src", "Audio/Source")).ok());
|
||||||
|
REQUIRE(tc.client->Test_InsertPort(
|
||||||
|
MakePort(100504, 100503, "out_FL", false)).ok());
|
||||||
|
|
||||||
|
REQUIRE(tc.client->Test_InsertLink(
|
||||||
|
MakeLink(100505, 100504, 100501)).ok());
|
||||||
|
|
||||||
|
WarpGraphModel model(tc.client.get());
|
||||||
|
model.refreshFromClient();
|
||||||
|
model.setNodeData(model.qtNodeIdForPw(100500),
|
||||||
|
QtNodes::NodeRole::Position, QPointF(300, 400));
|
||||||
|
|
||||||
|
QString path = QStandardPaths::writableLocation(
|
||||||
|
QStandardPaths::TempLocation) +
|
||||||
|
"/warppipe_test_preset.json";
|
||||||
|
REQUIRE(PresetManager::savePreset(path, tc.client.get(), &model));
|
||||||
|
|
||||||
|
QFile file(path);
|
||||||
|
REQUIRE(file.open(QIODevice::ReadOnly));
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||||
|
file.close();
|
||||||
|
REQUIRE(doc.isObject());
|
||||||
|
|
||||||
|
QJsonObject root = doc.object();
|
||||||
|
REQUIRE(root["version"].toInt() == 1);
|
||||||
|
REQUIRE(root["virtual_devices"].toArray().size() >= 1);
|
||||||
|
REQUIRE(root["routing"].toArray().size() >= 1);
|
||||||
|
REQUIRE(root["layout"].toArray().size() >= 2);
|
||||||
|
|
||||||
|
bool foundVsink = false;
|
||||||
|
for (const auto &val : root["virtual_devices"].toArray()) {
|
||||||
|
if (val.toObject()["name"].toString() == "preset-vsink") {
|
||||||
|
foundVsink = true;
|
||||||
|
REQUIRE(val.toObject()["channels"].toInt() == 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
REQUIRE(foundVsink);
|
||||||
|
|
||||||
|
bool foundRoute = false;
|
||||||
|
for (const auto &val : root["routing"].toArray()) {
|
||||||
|
QJsonObject route = val.toObject();
|
||||||
|
if (route["out_node"].toString() == "preset-src" &&
|
||||||
|
route["in_node"].toString() == "preset-vsink") {
|
||||||
|
foundRoute = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
REQUIRE(foundRoute);
|
||||||
|
|
||||||
|
bool foundLayout = false;
|
||||||
|
for (const auto &val : root["layout"].toArray()) {
|
||||||
|
QJsonObject obj = val.toObject();
|
||||||
|
if (obj["name"].toString() == "preset-vsink") {
|
||||||
|
foundLayout = true;
|
||||||
|
REQUIRE(obj["x"].toDouble() == Catch::Approx(300.0));
|
||||||
|
REQUIRE(obj["y"].toDouble() == Catch::Approx(400.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
REQUIRE(foundLayout);
|
||||||
|
|
||||||
|
QFile::remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("splitter sizes persist in layout JSON") {
|
||||||
|
auto tc = TestClient::Create();
|
||||||
|
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||||
|
ensureApp();
|
||||||
|
|
||||||
|
REQUIRE(tc.client->Test_InsertNode(
|
||||||
|
MakeNode(100510, "splitter-node", "Audio/Sink")).ok());
|
||||||
|
|
||||||
|
WarpGraphModel model(tc.client.get());
|
||||||
|
model.refreshFromClient();
|
||||||
|
|
||||||
|
WarpGraphModel::ViewState vs;
|
||||||
|
vs.scale = 1.0;
|
||||||
|
vs.centerX = 0.0;
|
||||||
|
vs.centerY = 0.0;
|
||||||
|
vs.splitterGraph = 900;
|
||||||
|
vs.splitterSidebar = 400;
|
||||||
|
vs.valid = true;
|
||||||
|
|
||||||
|
QString path = QStandardPaths::writableLocation(
|
||||||
|
QStandardPaths::TempLocation) +
|
||||||
|
"/warppipe_test_splitter.json";
|
||||||
|
model.saveLayout(path, vs);
|
||||||
|
|
||||||
|
WarpGraphModel model2(tc.client.get());
|
||||||
|
model2.loadLayout(path);
|
||||||
|
|
||||||
|
auto restored = model2.savedViewState();
|
||||||
|
REQUIRE(restored.valid);
|
||||||
|
REQUIRE(restored.splitterGraph == 900);
|
||||||
|
REQUIRE(restored.splitterSidebar == 400);
|
||||||
|
|
||||||
|
QFile::remove(path);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue