From fa67dd37083902e84eaab7396bc580528f4fa97a Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 08:57:33 -0700 Subject: [PATCH] GUI Milestone 8c --- CMakeLists.txt | 2 + GUI_PLAN.md | 42 ++++---- gui/GraphEditorWidget.cpp | 115 +++++++++++++++++++- gui/GraphEditorWidget.h | 7 ++ gui/PresetManager.cpp | 177 +++++++++++++++++++++++++++++++ gui/PresetManager.h | 15 +++ gui/WarpGraphModel.cpp | 6 ++ gui/WarpGraphModel.h | 2 + tests/gui/warppipe_gui_tests.cpp | 114 ++++++++++++++++++++ 9 files changed, 458 insertions(+), 22 deletions(-) create mode 100644 gui/PresetManager.cpp create mode 100644 gui/PresetManager.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 23580ff..49afc73 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,6 +82,7 @@ if(WARPPIPE_BUILD_GUI) gui/main.cpp gui/WarpGraphModel.cpp gui/GraphEditorWidget.cpp + gui/PresetManager.cpp ) target_link_libraries(warppipe-gui PRIVATE @@ -96,6 +97,7 @@ if(WARPPIPE_BUILD_GUI) tests/gui/warppipe_gui_tests.cpp gui/WarpGraphModel.cpp gui/GraphEditorWidget.cpp + gui/PresetManager.cpp ) target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING) diff --git a/GUI_PLAN.md b/GUI_PLAN.md index 6668bec..d2af7ad 100644 --- a/GUI_PLAN.md +++ b/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] 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 -- [ ] Milestone 8c - Sidebar and Preset System - - [ ] Add `QSplitter` between graph view and sidebar panel - - [ ] Graph view (stretch factor 1) on left, sidebar (stretch factor 0) on right - - [ ] Persist splitter sizes in layout JSON, restore on load - - [ ] Default sizes: graph 1200, sidebar 320 - - [ ] Add `QTabWidget` sidebar with styled tabs (dark theme) - - [ ] Tab styling: dark background, selected tab has accent underline - - [ ] Initially one tab: "PRESETS" (meters/mixer tabs added in M8d/M8e) - - [ ] Implement `PresetManager` class: - - [ ] `savePreset(path)` → serialize to JSON: - - [ ] Virtual devices: name, description, media_class, channels, rate - - [ ] Routing: links by stable_id:port_name pairs - - [ ] UI layout: node positions, view state - - [ ] `loadPreset(path)` → apply from JSON: - - [ ] Create missing virtual devices - - [ ] Re-create links from routing entries - - [ ] Apply layout positions - - [ ] Save on quit via `QCoreApplication::aboutToQuit` signal - - [ ] Add "Save Preset..." context menu action → `QFileDialog::getSaveFileName()` - - [ ] Add "Load Preset..." context menu action → `QFileDialog::getOpenFileName()` - - [ ] Add tests for preset save/load round-trip +- [x] Milestone 8c - Sidebar and Preset System + - [x] Add `QSplitter` between graph view and sidebar panel + - [x] Graph view (stretch factor 1) on left, sidebar (stretch factor 0) on right + - [x] Persist splitter sizes in layout JSON, restore on load + - [x] Default sizes: graph 1200, sidebar 320 + - [x] Add `QTabWidget` sidebar with styled tabs (dark theme) + - [x] Tab styling: dark background, selected tab has accent underline + - [x] Initially one tab: "PRESETS" (meters/mixer tabs added in M8d/M8e) + - [x] Implement `PresetManager` class: + - [x] `savePreset(path)` → serialize to JSON: + - [x] Virtual devices: name, description, media_class, channels, rate + - [x] Routing: links by stable_id:port_name pairs + - [x] UI layout: node positions, view state + - [x] `loadPreset(path)` → apply from JSON: + - [x] Create missing virtual devices + - [x] Re-create links from routing entries + - [x] Apply layout positions + - [x] Save on quit via `QCoreApplication::aboutToQuit` signal + - [x] Add "Save Preset..." context menu action → `QFileDialog::getSaveFileName()` + - [x] Add "Load Preset..." context menu action → `QFileDialog::getOpenFileName()` + - [x] Add tests for preset save/load round-trip - [ ] Milestone 8d - Volume/Mute Controls (requires core API: `SetNodeVolume()`) - [ ] Add `NodeVolumeState` struct: `{ float volume; bool mute; }` - [ ] Add `ClickSlider : QSlider` — click jumps to position instead of page-stepping diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 9ce3901..72c7a9d 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -1,4 +1,5 @@ #include "GraphEditorWidget.h" +#include "PresetManager.h" #include "WarpGraphModel.h" #include @@ -11,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -21,10 +23,16 @@ #include #include #include +#include +#include #include #include #include +#include +#include #include +#include +#include #include #include #include @@ -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 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(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(window())) + mw->statusBar()->showMessage( + QStringLiteral("Preset loaded: ") + QFileInfo(path).fileName(), 4000); + } else { + QMessageBox::warning(this, QStringLiteral("Error"), + QStringLiteral("Failed to load preset.")); + } +} diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index d8ed3f0..d70bdaa 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -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; diff --git a/gui/PresetManager.cpp b/gui/PresetManager.cpp new file mode 100644 index 0000000..c507d3d --- /dev/null +++ b/gui/PresetManager.cpp @@ -0,0 +1,177 @@ +#include "PresetManager.h" +#include "WarpGraphModel.h" + +#include +#include +#include +#include +#include +#include + +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> 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 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(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; +} diff --git a/gui/PresetManager.h b/gui/PresetManager.h new file mode 100644 index 0000000..d39f54c --- /dev/null +++ b/gui/PresetManager.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +#include + +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); +}; diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp index 0b8d5e1..9db3c33 100644 --- a/gui/WarpGraphModel.cpp +++ b/gui/WarpGraphModel.cpp @@ -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; } diff --git a/gui/WarpGraphModel.h b/gui/WarpGraphModel.h index 5b31437..f8658a5 100644 --- a/gui/WarpGraphModel.h +++ b/gui/WarpGraphModel.h @@ -73,6 +73,8 @@ public: double scale; double centerX; double centerY; + int splitterGraph; + int splitterSidebar; bool valid; }; diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index eedacd8..94473ae 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -1,11 +1,15 @@ #include #include "../../gui/GraphEditorWidget.h" +#include "../../gui/PresetManager.h" #include "../../gui/WarpGraphModel.h" #include #include #include +#include +#include +#include #include #include @@ -849,3 +853,113 @@ TEST_CASE("clearSavedPositions resets model positions") { 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); +}