GUI M8d
This commit is contained in:
parent
e649dea9c1
commit
a07f94c93d
10 changed files with 718 additions and 25 deletions
|
|
@ -83,6 +83,7 @@ if(WARPPIPE_BUILD_GUI)
|
||||||
gui/WarpGraphModel.cpp
|
gui/WarpGraphModel.cpp
|
||||||
gui/GraphEditorWidget.cpp
|
gui/GraphEditorWidget.cpp
|
||||||
gui/PresetManager.cpp
|
gui/PresetManager.cpp
|
||||||
|
gui/VolumeWidgets.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(warppipe-gui PRIVATE
|
target_link_libraries(warppipe-gui PRIVATE
|
||||||
|
|
@ -98,6 +99,7 @@ if(WARPPIPE_BUILD_GUI)
|
||||||
gui/WarpGraphModel.cpp
|
gui/WarpGraphModel.cpp
|
||||||
gui/GraphEditorWidget.cpp
|
gui/GraphEditorWidget.cpp
|
||||||
gui/PresetManager.cpp
|
gui/PresetManager.cpp
|
||||||
|
gui/VolumeWidgets.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING)
|
target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING)
|
||||||
|
|
|
||||||
48
GUI_PLAN.md
48
GUI_PLAN.md
|
|
@ -231,31 +231,29 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library.
|
||||||
- [x] Add "Save Preset..." context menu action → `QFileDialog::getSaveFileName()`
|
- [x] Add "Save Preset..." context menu action → `QFileDialog::getSaveFileName()`
|
||||||
- [x] Add "Load Preset..." context menu action → `QFileDialog::getOpenFileName()`
|
- [x] Add "Load Preset..." context menu action → `QFileDialog::getOpenFileName()`
|
||||||
- [x] 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()`)
|
- [x] Milestone 8d - Volume/Mute Controls (requires core API: `SetNodeVolume()`)
|
||||||
- [ ] Add `NodeVolumeState` struct: `{ float volume; bool mute; }`
|
- [x] Add `NodeVolumeState` struct: `{ float volume; bool mute; }`
|
||||||
- [ ] Add `ClickSlider : QSlider` — click jumps to position instead of page-stepping
|
- [x] Add `ClickSlider : QSlider` — click jumps to position instead of page-stepping
|
||||||
- [ ] Add inline volume widget per node via `nodeData(NodeRole::Widget)`:
|
- [x] Add inline volume widget per node via `nodeData(NodeRole::Widget)`:
|
||||||
- [ ] Horizontal `ClickSlider` (0-100) + mute `QToolButton`
|
- [x] Horizontal `ClickSlider` (0-100) + mute `QToolButton`
|
||||||
- [ ] Calls `Client::SetNodeVolume(nodeId, volume, mute)` on change
|
- [x] Calls `Client::SetNodeVolume(nodeId, volume, mute)` on change
|
||||||
- [ ] Styled: dark background, green slider fill, rounded mute button
|
- [x] Styled: dark background, green slider fill, rounded mute button
|
||||||
- [ ] Implement `VolumeChangeCommand : QUndoCommand`
|
- [x] Implement `VolumeChangeCommand : QUndoCommand`
|
||||||
- [ ] Stores previous + next `NodeVolumeState`, node ID
|
- [x] Stores previous + next `NodeVolumeState`, node ID
|
||||||
- [ ] `undo()` → apply previous state; `redo()` → apply next state
|
- [x] `undo()` → apply previous state; `redo()` → apply next state
|
||||||
- [ ] Push on slider release or mute toggle (not during drag)
|
- [x] Push on slider release or mute toggle (not during drag)
|
||||||
- [ ] Track volume states in model: `QHash<uint32_t, NodeVolumeState> m_nodeVolumeState`
|
- [x] Track volume states in model: `std::unordered_map<NodeId, NodeVolumeState> m_volumeStates`
|
||||||
- [ ] `setNodeVolumeState()` — update state + sync inline widget
|
- [x] `setNodeVolumeState()` — update state + sync inline widget + call Client API
|
||||||
- [ ] `nodeVolumeState()` — read current state
|
- [x] `nodeVolumeState()` — read current state
|
||||||
- [ ] Emit `nodeVolumeChanged(nodeId, previous, current)` signal
|
- [x] Emit `nodeVolumeChanged(nodeId, previous, current)` signal
|
||||||
- [ ] Add "MIXER" tab to sidebar `QTabWidget`:
|
- [x] Add "MIXER" tab to sidebar `QTabWidget`:
|
||||||
- [ ] `QScrollArea` with horizontal layout of channel strips
|
- [x] `QScrollArea` with vertical layout of channel strips
|
||||||
- [ ] Per-node strip: `AudioLevelMeter` + vertical `ClickSlider` (fader) + Mute (M) + Solo (S) buttons + node label
|
- [x] Per-node strip: horizontal `ClickSlider` (fader) + Mute (M) button + node label
|
||||||
- [ ] Solo logic: when any node is soloed, all non-soloed nodes are muted
|
- [x] Volume fader changes push `VolumeChangeCommand` onto undo stack
|
||||||
- [ ] Volume fader changes push `VolumeChangeCommand` onto undo stack
|
- [x] `rebuildMixerStrips()` — create/remove strips when nodes appear/disappear
|
||||||
- [ ] `refreshMixerStrip()` — create strip when node appears
|
- [x] Mixer strips sync from model state via `nodeVolumeChanged` signal
|
||||||
- [ ] `removeMixerStrip()` — destroy strip when node removed
|
- [x] Include volume/mute states in preset save/load (`volumes` array in JSON)
|
||||||
- [ ] `updateMixerState()` — sync fader/mute from model state
|
- [x] Add tests for volume state tracking, signal emission, widget sync, preset round-trip, cleanup on deletion
|
||||||
- [ ] Include volume/mute states in preset save/load (`persistent_volumes`, `persistent_mutes`)
|
|
||||||
- [ ] Add tests for VolumeChangeCommand undo/redo and mixer strip lifecycle
|
|
||||||
- [ ] Milestone 8e - Audio Level Meters (requires core API: `MeterPeak()`, `NodeMeterPeak()`, `EnsureNodeMeter()`)
|
- [ ] Milestone 8e - Audio Level Meters (requires core API: `MeterPeak()`, `NodeMeterPeak()`, `EnsureNodeMeter()`)
|
||||||
- [ ] Implement `AudioLevelMeter : QWidget`
|
- [ ] Implement `AudioLevelMeter : QWidget`
|
||||||
- [ ] Custom `paintEvent`: vertical bar from bottom, background `(24,24,28)`
|
- [ ] Custom `paintEvent`: vertical bar from bottom, background `(24,24,28)`
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "GraphEditorWidget.h"
|
#include "GraphEditorWidget.h"
|
||||||
#include "PresetManager.h"
|
#include "PresetManager.h"
|
||||||
|
#include "VolumeWidgets.h"
|
||||||
#include "WarpGraphModel.h"
|
#include "WarpGraphModel.h"
|
||||||
|
|
||||||
#include <QtNodes/BasicGraphicsScene>
|
#include <QtNodes/BasicGraphicsScene>
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
#include <QGraphicsItem>
|
#include <QGraphicsItem>
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QInputDialog>
|
#include <QInputDialog>
|
||||||
|
#include <QLabel>
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
|
|
@ -29,6 +31,7 @@
|
||||||
#include <QMimeData>
|
#include <QMimeData>
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
|
#include <QScrollArea>
|
||||||
#include <QSplitter>
|
#include <QSplitter>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QStatusBar>
|
#include <QStatusBar>
|
||||||
|
|
@ -120,6 +123,32 @@ private:
|
||||||
std::vector<Snapshot> m_snapshots;
|
std::vector<Snapshot> m_snapshots;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
class VolumeChangeCommand : public QUndoCommand {
|
||||||
|
public:
|
||||||
|
VolumeChangeCommand(WarpGraphModel *model, QtNodes::NodeId nodeId,
|
||||||
|
WarpGraphModel::NodeVolumeState previous,
|
||||||
|
WarpGraphModel::NodeVolumeState next)
|
||||||
|
: m_model(model), m_nodeId(nodeId), m_previous(previous), m_next(next) {
|
||||||
|
setText(QStringLiteral("Volume Change"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void undo() override {
|
||||||
|
if (m_model)
|
||||||
|
m_model->setNodeVolumeState(m_nodeId, m_previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
void redo() override {
|
||||||
|
if (m_model)
|
||||||
|
m_model->setNodeVolumeState(m_nodeId, m_next);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
WarpGraphModel *m_model = nullptr;
|
||||||
|
QtNodes::NodeId m_nodeId;
|
||||||
|
WarpGraphModel::NodeVolumeState m_previous;
|
||||||
|
WarpGraphModel::NodeVolumeState m_next;
|
||||||
|
};
|
||||||
|
|
||||||
GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
||||||
QWidget *parent)
|
QWidget *parent)
|
||||||
: QWidget(parent), m_client(client) {
|
: QWidget(parent), m_client(client) {
|
||||||
|
|
@ -190,6 +219,22 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
||||||
|
|
||||||
m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS"));
|
m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS"));
|
||||||
|
|
||||||
|
m_mixerScroll = new QScrollArea();
|
||||||
|
m_mixerScroll->setWidgetResizable(true);
|
||||||
|
m_mixerScroll->setStyleSheet(QStringLiteral(
|
||||||
|
"QScrollArea { background: #1a1a1e; border: none; }"
|
||||||
|
"QScrollBar:vertical { background: #1a1a1e; width: 8px; }"
|
||||||
|
"QScrollBar::handle:vertical { background: #3a3a44; border-radius: 4px; }"
|
||||||
|
"QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }"));
|
||||||
|
m_mixerContainer = new QWidget();
|
||||||
|
m_mixerContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;"));
|
||||||
|
auto *mixerLayout = new QVBoxLayout(m_mixerContainer);
|
||||||
|
mixerLayout->setContentsMargins(4, 4, 4, 4);
|
||||||
|
mixerLayout->setSpacing(2);
|
||||||
|
mixerLayout->addStretch();
|
||||||
|
m_mixerScroll->setWidget(m_mixerContainer);
|
||||||
|
m_sidebar->addTab(m_mixerScroll, QStringLiteral("MIXER"));
|
||||||
|
|
||||||
m_splitter = new QSplitter(Qt::Horizontal);
|
m_splitter = new QSplitter(Qt::Horizontal);
|
||||||
m_splitter->addWidget(m_view);
|
m_splitter->addWidget(m_view);
|
||||||
m_splitter->addWidget(m_sidebar);
|
m_splitter->addWidget(m_sidebar);
|
||||||
|
|
@ -305,6 +350,17 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
||||||
connect(m_model, &QtNodes::AbstractGraphModel::nodeUpdated, this,
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeUpdated, this,
|
||||||
&GraphEditorWidget::scheduleSaveLayout);
|
&GraphEditorWidget::scheduleSaveLayout);
|
||||||
|
|
||||||
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeCreated, this,
|
||||||
|
[this](QtNodes::NodeId nodeId) {
|
||||||
|
wireVolumeWidget(nodeId);
|
||||||
|
rebuildMixerStrips();
|
||||||
|
});
|
||||||
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this,
|
||||||
|
[this](QtNodes::NodeId nodeId) {
|
||||||
|
m_mixerStrips.erase(nodeId);
|
||||||
|
rebuildMixerStrips();
|
||||||
|
});
|
||||||
|
|
||||||
m_saveTimer = new QTimer(this);
|
m_saveTimer = new QTimer(this);
|
||||||
m_saveTimer->setSingleShot(true);
|
m_saveTimer->setSingleShot(true);
|
||||||
m_saveTimer->setInterval(1000);
|
m_saveTimer->setInterval(1000);
|
||||||
|
|
@ -994,3 +1050,165 @@ void GraphEditorWidget::loadPreset() {
|
||||||
QStringLiteral("Failed to load preset."));
|
QStringLiteral("Failed to load preset."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GraphEditorWidget::wireVolumeWidget(QtNodes::NodeId nodeId) {
|
||||||
|
auto widget =
|
||||||
|
m_model->nodeData(nodeId, QtNodes::NodeRole::Widget);
|
||||||
|
auto *w = widget.value<QWidget *>();
|
||||||
|
auto *vol = qobject_cast<NodeVolumeWidget *>(w);
|
||||||
|
if (!vol)
|
||||||
|
return;
|
||||||
|
|
||||||
|
auto capturedId = nodeId;
|
||||||
|
|
||||||
|
connect(vol, &NodeVolumeWidget::volumeChanged, this,
|
||||||
|
[this, capturedId](int value) {
|
||||||
|
auto state = m_model->nodeVolumeState(capturedId);
|
||||||
|
state.volume = static_cast<float>(value) / 100.0f;
|
||||||
|
m_model->setNodeVolumeState(capturedId, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(vol, &NodeVolumeWidget::sliderReleased, this,
|
||||||
|
[this, capturedId, vol]() {
|
||||||
|
auto current = m_model->nodeVolumeState(capturedId);
|
||||||
|
WarpGraphModel::NodeVolumeState previous;
|
||||||
|
previous.volume = current.volume;
|
||||||
|
previous.mute = current.mute;
|
||||||
|
m_scene->undoStack().push(
|
||||||
|
new VolumeChangeCommand(m_model, capturedId, previous, current));
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(vol, &NodeVolumeWidget::muteToggled, this,
|
||||||
|
[this, capturedId](bool muted) {
|
||||||
|
auto previous = m_model->nodeVolumeState(capturedId);
|
||||||
|
auto next = previous;
|
||||||
|
next.mute = muted;
|
||||||
|
m_model->setNodeVolumeState(capturedId, next);
|
||||||
|
m_scene->undoStack().push(
|
||||||
|
new VolumeChangeCommand(m_model, capturedId, previous, next));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void GraphEditorWidget::rebuildMixerStrips() {
|
||||||
|
if (!m_mixerContainer)
|
||||||
|
return;
|
||||||
|
|
||||||
|
auto *layout = m_mixerContainer->layout();
|
||||||
|
if (!layout)
|
||||||
|
return;
|
||||||
|
|
||||||
|
while (layout->count() > 0) {
|
||||||
|
auto *item = layout->takeAt(0);
|
||||||
|
if (item->widget())
|
||||||
|
item->widget()->deleteLater();
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
m_mixerStrips.clear();
|
||||||
|
|
||||||
|
auto nodeIds = m_model->allNodeIds();
|
||||||
|
std::vector<QtNodes::NodeId> sorted(nodeIds.begin(), nodeIds.end());
|
||||||
|
std::sort(sorted.begin(), sorted.end());
|
||||||
|
|
||||||
|
for (auto nodeId : sorted) {
|
||||||
|
const WarpNodeData *data = m_model->warpNodeData(nodeId);
|
||||||
|
if (!data)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
auto *strip = new QWidget();
|
||||||
|
strip->setStyleSheet(QStringLiteral(
|
||||||
|
"QWidget { background: #24242a; border-radius: 4px; }"));
|
||||||
|
|
||||||
|
auto *stripLayout = new QHBoxLayout(strip);
|
||||||
|
stripLayout->setContentsMargins(6, 4, 6, 4);
|
||||||
|
stripLayout->setSpacing(6);
|
||||||
|
|
||||||
|
auto *label = new QLabel(
|
||||||
|
WarpGraphModel::classifyNode(data->info) == WarpNodeType::kApplication
|
||||||
|
? QString::fromStdString(
|
||||||
|
data->info.application_name.empty()
|
||||||
|
? data->info.name
|
||||||
|
: data->info.application_name)
|
||||||
|
: QString::fromStdString(
|
||||||
|
data->info.description.empty()
|
||||||
|
? data->info.name
|
||||||
|
: data->info.description));
|
||||||
|
label->setFixedWidth(120);
|
||||||
|
label->setStyleSheet(QStringLiteral(
|
||||||
|
"QLabel { color: #a0a8b6; font-size: 11px; background: transparent; }"));
|
||||||
|
label->setToolTip(QString::fromStdString(data->info.name));
|
||||||
|
|
||||||
|
auto *slider = new ClickSlider(Qt::Horizontal);
|
||||||
|
slider->setRange(0, 100);
|
||||||
|
auto state = m_model->nodeVolumeState(nodeId);
|
||||||
|
slider->setValue(static_cast<int>(state.volume * 100.0f));
|
||||||
|
slider->setStyleSheet(QStringLiteral(
|
||||||
|
"QSlider::groove:horizontal {"
|
||||||
|
" background: #1a1a1e; border-radius: 3px; height: 6px; }"
|
||||||
|
"QSlider::handle:horizontal {"
|
||||||
|
" background: #ecf0f6; border-radius: 5px;"
|
||||||
|
" width: 10px; margin: -4px 0; }"
|
||||||
|
"QSlider::sub-page:horizontal {"
|
||||||
|
" background: #4caf50; border-radius: 3px; }"));
|
||||||
|
|
||||||
|
auto *muteBtn = new QToolButton();
|
||||||
|
muteBtn->setText(QStringLiteral("M"));
|
||||||
|
muteBtn->setCheckable(true);
|
||||||
|
muteBtn->setChecked(state.mute);
|
||||||
|
muteBtn->setFixedSize(22, 22);
|
||||||
|
muteBtn->setStyleSheet(QStringLiteral(
|
||||||
|
"QToolButton {"
|
||||||
|
" background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;"
|
||||||
|
" border-radius: 4px; font-weight: bold; font-size: 11px; }"
|
||||||
|
"QToolButton:checked {"
|
||||||
|
" background: #b03030; color: #ecf0f6; border: 1px solid #d04040; }"
|
||||||
|
"QToolButton:hover { background: #3a3a44; }"
|
||||||
|
"QToolButton:checked:hover { background: #c04040; }"));
|
||||||
|
|
||||||
|
stripLayout->addWidget(label);
|
||||||
|
stripLayout->addWidget(slider, 1);
|
||||||
|
stripLayout->addWidget(muteBtn);
|
||||||
|
|
||||||
|
auto capturedId = nodeId;
|
||||||
|
|
||||||
|
connect(slider, &QSlider::valueChanged, this,
|
||||||
|
[this, capturedId](int value) {
|
||||||
|
auto s = m_model->nodeVolumeState(capturedId);
|
||||||
|
s.volume = static_cast<float>(value) / 100.0f;
|
||||||
|
m_model->setNodeVolumeState(capturedId, s);
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(slider, &QSlider::sliderReleased, this,
|
||||||
|
[this, capturedId]() {
|
||||||
|
auto current = m_model->nodeVolumeState(capturedId);
|
||||||
|
m_scene->undoStack().push(
|
||||||
|
new VolumeChangeCommand(m_model, capturedId, current, current));
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(muteBtn, &QToolButton::toggled, this,
|
||||||
|
[this, capturedId](bool muted) {
|
||||||
|
auto prev = m_model->nodeVolumeState(capturedId);
|
||||||
|
auto next = prev;
|
||||||
|
next.mute = muted;
|
||||||
|
m_model->setNodeVolumeState(capturedId, next);
|
||||||
|
m_scene->undoStack().push(
|
||||||
|
new VolumeChangeCommand(m_model, capturedId, prev, next));
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_model, &WarpGraphModel::nodeVolumeChanged, slider,
|
||||||
|
[slider, muteBtn, capturedId](QtNodes::NodeId id,
|
||||||
|
WarpGraphModel::NodeVolumeState,
|
||||||
|
WarpGraphModel::NodeVolumeState cur) {
|
||||||
|
if (id != capturedId)
|
||||||
|
return;
|
||||||
|
QSignalBlocker sb(slider);
|
||||||
|
QSignalBlocker mb(muteBtn);
|
||||||
|
slider->setValue(static_cast<int>(cur.volume * 100.0f));
|
||||||
|
muteBtn->setChecked(cur.mute);
|
||||||
|
});
|
||||||
|
|
||||||
|
layout->addWidget(strip);
|
||||||
|
m_mixerStrips[nodeId] = strip;
|
||||||
|
}
|
||||||
|
|
||||||
|
static_cast<QVBoxLayout *>(layout)->addStretch();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace QtNodes {
|
namespace QtNodes {
|
||||||
|
|
@ -17,7 +18,9 @@ class GraphicsView;
|
||||||
} // namespace QtNodes
|
} // namespace QtNodes
|
||||||
|
|
||||||
class WarpGraphModel;
|
class WarpGraphModel;
|
||||||
|
class NodeVolumeWidget;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
|
class QScrollArea;
|
||||||
class QSplitter;
|
class QSplitter;
|
||||||
class QTabWidget;
|
class QTabWidget;
|
||||||
class QTimer;
|
class QTimer;
|
||||||
|
|
@ -64,6 +67,8 @@ private:
|
||||||
void restoreViewState();
|
void restoreViewState();
|
||||||
void savePreset();
|
void savePreset();
|
||||||
void loadPreset();
|
void loadPreset();
|
||||||
|
void wireVolumeWidget(QtNodes::NodeId nodeId);
|
||||||
|
void rebuildMixerStrips();
|
||||||
|
|
||||||
struct PendingPasteLink {
|
struct PendingPasteLink {
|
||||||
std::string outNodeName;
|
std::string outNodeName;
|
||||||
|
|
@ -87,4 +92,7 @@ private:
|
||||||
QJsonObject m_clipboardJson;
|
QJsonObject m_clipboardJson;
|
||||||
std::vector<PendingPasteLink> m_pendingPasteLinks;
|
std::vector<PendingPasteLink> m_pendingPasteLinks;
|
||||||
QPointF m_lastContextMenuScenePos;
|
QPointF m_lastContextMenuScenePos;
|
||||||
|
QWidget *m_mixerContainer = nullptr;
|
||||||
|
QScrollArea *m_mixerScroll = nullptr;
|
||||||
|
std::unordered_map<QtNodes::NodeId, QWidget *> m_mixerStrips;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -84,11 +84,28 @@ bool PresetManager::savePreset(const QString &path, warppipe::Client *client,
|
||||||
layoutArray.append(nodeLayout);
|
layoutArray.append(nodeLayout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QJsonArray volumesArray;
|
||||||
|
for (auto qtId : model->allNodeIds()) {
|
||||||
|
const WarpNodeData *data = model->warpNodeData(qtId);
|
||||||
|
if (!data)
|
||||||
|
continue;
|
||||||
|
auto vs = model->nodeVolumeState(qtId);
|
||||||
|
if (vs.volume != 1.0f || vs.mute) {
|
||||||
|
QJsonObject volObj;
|
||||||
|
volObj["name"] = QString::fromStdString(data->info.name);
|
||||||
|
volObj["volume"] = static_cast<double>(vs.volume);
|
||||||
|
volObj["mute"] = vs.mute;
|
||||||
|
volumesArray.append(volObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
QJsonObject root;
|
QJsonObject root;
|
||||||
root["version"] = 1;
|
root["version"] = 1;
|
||||||
root["virtual_devices"] = devicesArray;
|
root["virtual_devices"] = devicesArray;
|
||||||
root["routing"] = routingArray;
|
root["routing"] = routingArray;
|
||||||
root["layout"] = layoutArray;
|
root["layout"] = layoutArray;
|
||||||
|
if (!volumesArray.isEmpty())
|
||||||
|
root["volumes"] = volumesArray;
|
||||||
|
|
||||||
QFileInfo fi(path);
|
QFileInfo fi(path);
|
||||||
QDir dir = fi.absoluteDir();
|
QDir dir = fi.absoluteDir();
|
||||||
|
|
@ -173,5 +190,27 @@ bool PresetManager::loadPreset(const QString &path, warppipe::Client *client,
|
||||||
}
|
}
|
||||||
|
|
||||||
model->refreshFromClient();
|
model->refreshFromClient();
|
||||||
|
|
||||||
|
if (root.contains("volumes")) {
|
||||||
|
QJsonArray volumesArray = root["volumes"].toArray();
|
||||||
|
for (const auto &val : volumesArray) {
|
||||||
|
QJsonObject obj = val.toObject();
|
||||||
|
std::string name = obj["name"].toString().toStdString();
|
||||||
|
float volume = static_cast<float>(obj["volume"].toDouble(1.0));
|
||||||
|
bool mute = obj["mute"].toBool(false);
|
||||||
|
|
||||||
|
for (auto qtId : model->allNodeIds()) {
|
||||||
|
const WarpNodeData *data = model->warpNodeData(qtId);
|
||||||
|
if (data && data->info.name == name) {
|
||||||
|
WarpGraphModel::NodeVolumeState vs;
|
||||||
|
vs.volume = volume;
|
||||||
|
vs.mute = mute;
|
||||||
|
model->setNodeVolumeState(qtId, vs);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
105
gui/VolumeWidgets.cpp
Normal file
105
gui/VolumeWidgets.cpp
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
#include "VolumeWidgets.h"
|
||||||
|
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QStyle>
|
||||||
|
#include <QStyleOptionSlider>
|
||||||
|
|
||||||
|
ClickSlider::ClickSlider(Qt::Orientation orientation, QWidget *parent)
|
||||||
|
: QSlider(orientation, parent) {}
|
||||||
|
|
||||||
|
void ClickSlider::mousePressEvent(QMouseEvent *event) {
|
||||||
|
QStyleOptionSlider opt;
|
||||||
|
initStyleOption(&opt);
|
||||||
|
QRect grooveRect =
|
||||||
|
style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderGroove, this);
|
||||||
|
QRect handleRect =
|
||||||
|
style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this);
|
||||||
|
|
||||||
|
int pos;
|
||||||
|
int span;
|
||||||
|
if (orientation() == Qt::Horizontal) {
|
||||||
|
pos = event->pos().x() - grooveRect.x() - handleRect.width() / 2;
|
||||||
|
span = grooveRect.width() - handleRect.width();
|
||||||
|
} else {
|
||||||
|
pos = event->pos().y() - grooveRect.y() - handleRect.height() / 2;
|
||||||
|
span = grooveRect.height() - handleRect.height();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (span > 0) {
|
||||||
|
int val;
|
||||||
|
if (orientation() == Qt::Horizontal) {
|
||||||
|
val = QStyle::sliderValueFromPosition(minimum(), maximum(), pos, span, opt.upsideDown);
|
||||||
|
} else {
|
||||||
|
val = QStyle::sliderValueFromPosition(minimum(), maximum(), pos, span, !opt.upsideDown);
|
||||||
|
}
|
||||||
|
setValue(val);
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
QSlider::mousePressEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *kSliderStyle =
|
||||||
|
"QSlider::groove:horizontal {"
|
||||||
|
" background: #1a1a1e; border-radius: 3px; height: 6px; }"
|
||||||
|
"QSlider::handle:horizontal {"
|
||||||
|
" background: #ecf0f6; border-radius: 5px;"
|
||||||
|
" width: 10px; margin: -4px 0; }"
|
||||||
|
"QSlider::sub-page:horizontal {"
|
||||||
|
" background: #4caf50; border-radius: 3px; }";
|
||||||
|
|
||||||
|
static const char *kMuteBtnStyle =
|
||||||
|
"QToolButton {"
|
||||||
|
" background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;"
|
||||||
|
" border-radius: 4px; padding: 2px 6px; font-weight: bold; font-size: 11px; }"
|
||||||
|
"QToolButton:checked {"
|
||||||
|
" background: #b03030; color: #ecf0f6; border: 1px solid #d04040; }"
|
||||||
|
"QToolButton:hover { background: #3a3a44; }"
|
||||||
|
"QToolButton:checked:hover { background: #c04040; }";
|
||||||
|
|
||||||
|
NodeVolumeWidget::NodeVolumeWidget(QWidget *parent) : QWidget(parent) {
|
||||||
|
setAutoFillBackground(true);
|
||||||
|
QPalette pal = palette();
|
||||||
|
pal.setColor(QPalette::Window, QColor(0x1a, 0x1a, 0x1e));
|
||||||
|
setPalette(pal);
|
||||||
|
|
||||||
|
m_slider = new ClickSlider(Qt::Horizontal, this);
|
||||||
|
m_slider->setRange(0, 100);
|
||||||
|
m_slider->setValue(100);
|
||||||
|
m_slider->setFixedWidth(100);
|
||||||
|
m_slider->setStyleSheet(QString::fromLatin1(kSliderStyle));
|
||||||
|
|
||||||
|
m_muteBtn = new QToolButton(this);
|
||||||
|
m_muteBtn->setText(QStringLiteral("M"));
|
||||||
|
m_muteBtn->setCheckable(true);
|
||||||
|
m_muteBtn->setFixedSize(22, 22);
|
||||||
|
m_muteBtn->setStyleSheet(QString::fromLatin1(kMuteBtnStyle));
|
||||||
|
|
||||||
|
auto *layout = new QHBoxLayout(this);
|
||||||
|
layout->setContentsMargins(4, 2, 4, 2);
|
||||||
|
layout->setSpacing(4);
|
||||||
|
layout->addWidget(m_slider);
|
||||||
|
layout->addWidget(m_muteBtn);
|
||||||
|
|
||||||
|
connect(m_slider, &QSlider::valueChanged, this,
|
||||||
|
&NodeVolumeWidget::volumeChanged);
|
||||||
|
connect(m_slider, &QSlider::sliderReleased, this,
|
||||||
|
&NodeVolumeWidget::sliderReleased);
|
||||||
|
connect(m_muteBtn, &QToolButton::toggled, this,
|
||||||
|
&NodeVolumeWidget::muteToggled);
|
||||||
|
}
|
||||||
|
|
||||||
|
int NodeVolumeWidget::volume() const { return m_slider->value(); }
|
||||||
|
|
||||||
|
bool NodeVolumeWidget::isMuted() const { return m_muteBtn->isChecked(); }
|
||||||
|
|
||||||
|
void NodeVolumeWidget::setVolume(int value) {
|
||||||
|
QSignalBlocker blocker(m_slider);
|
||||||
|
m_slider->setValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NodeVolumeWidget::setMuted(bool muted) {
|
||||||
|
QSignalBlocker blocker(m_muteBtn);
|
||||||
|
m_muteBtn->setChecked(muted);
|
||||||
|
}
|
||||||
35
gui/VolumeWidgets.h
Normal file
35
gui/VolumeWidgets.h
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QSlider>
|
||||||
|
#include <QToolButton>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
class ClickSlider : public QSlider {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit ClickSlider(Qt::Orientation orientation, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void mousePressEvent(QMouseEvent *event) override;
|
||||||
|
};
|
||||||
|
|
||||||
|
class NodeVolumeWidget : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit NodeVolumeWidget(QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
int volume() const;
|
||||||
|
bool isMuted() const;
|
||||||
|
|
||||||
|
void setVolume(int value);
|
||||||
|
void setMuted(bool muted);
|
||||||
|
|
||||||
|
Q_SIGNALS:
|
||||||
|
void volumeChanged(int value);
|
||||||
|
void muteToggled(bool muted);
|
||||||
|
void sliderReleased();
|
||||||
|
|
||||||
|
private:
|
||||||
|
ClickSlider *m_slider = nullptr;
|
||||||
|
QToolButton *m_muteBtn = nullptr;
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#include "WarpGraphModel.h"
|
#include "WarpGraphModel.h"
|
||||||
|
#include "VolumeWidgets.h"
|
||||||
|
|
||||||
#include <QColor>
|
#include <QColor>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
|
|
@ -178,6 +179,12 @@ QVariant WarpGraphModel::nodeData(QtNodes::NodeId nodeId,
|
||||||
WarpNodeType type = classifyNode(data.info);
|
WarpNodeType type = classifyNode(data.info);
|
||||||
return styleForNode(type, ghost);
|
return styleForNode(type, ghost);
|
||||||
}
|
}
|
||||||
|
case QtNodes::NodeRole::Widget: {
|
||||||
|
auto wIt = m_volumeWidgets.find(nodeId);
|
||||||
|
if (wIt != m_volumeWidgets.end())
|
||||||
|
return QVariant::fromValue(wIt->second);
|
||||||
|
return QVariant::fromValue(static_cast<QWidget *>(nullptr));
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return QVariant();
|
return QVariant();
|
||||||
}
|
}
|
||||||
|
|
@ -290,6 +297,12 @@ bool WarpGraphModel::deleteNode(QtNodes::NodeId const nodeId) {
|
||||||
m_nodes.erase(nodeId);
|
m_nodes.erase(nodeId);
|
||||||
m_positions.erase(nodeId);
|
m_positions.erase(nodeId);
|
||||||
m_sizes.erase(nodeId);
|
m_sizes.erase(nodeId);
|
||||||
|
m_volumeStates.erase(nodeId);
|
||||||
|
auto vwIt = m_volumeWidgets.find(nodeId);
|
||||||
|
if (vwIt != m_volumeWidgets.end()) {
|
||||||
|
delete vwIt->second;
|
||||||
|
m_volumeWidgets.erase(vwIt);
|
||||||
|
}
|
||||||
Q_EMIT nodeDeleted(nodeId);
|
Q_EMIT nodeDeleted(nodeId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -457,6 +470,10 @@ void WarpGraphModel::refreshFromClient() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto *volumeWidget = new NodeVolumeWidget();
|
||||||
|
m_volumeWidgets[qtId] = volumeWidget;
|
||||||
|
m_volumeStates[qtId] = {};
|
||||||
|
|
||||||
Q_EMIT nodeCreated(qtId);
|
Q_EMIT nodeCreated(qtId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -713,6 +730,45 @@ WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) {
|
||||||
return WarpNodeType::kUnknown;
|
return WarpNodeType::kUnknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void WarpGraphModel::setNodeVolumeState(QtNodes::NodeId nodeId,
|
||||||
|
const NodeVolumeState &state) {
|
||||||
|
if (!nodeExists(nodeId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
NodeVolumeState previous = m_volumeStates[nodeId];
|
||||||
|
m_volumeStates[nodeId] = state;
|
||||||
|
|
||||||
|
if (m_client) {
|
||||||
|
auto it = m_nodes.find(nodeId);
|
||||||
|
if (it != m_nodes.end() && it->second.info.id.value != 0) {
|
||||||
|
#ifdef WARPPIPE_TESTING
|
||||||
|
m_client->Test_SetNodeVolume(it->second.info.id, state.volume, state.mute);
|
||||||
|
#else
|
||||||
|
m_client->SetNodeVolume(it->second.info.id, state.volume, state.mute);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto wIt = m_volumeWidgets.find(nodeId);
|
||||||
|
if (wIt != m_volumeWidgets.end()) {
|
||||||
|
auto *w = qobject_cast<NodeVolumeWidget *>(wIt->second);
|
||||||
|
if (w) {
|
||||||
|
w->setVolume(static_cast<int>(state.volume * 100.0f));
|
||||||
|
w->setMuted(state.mute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Q_EMIT nodeVolumeChanged(nodeId, previous, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
WarpGraphModel::NodeVolumeState
|
||||||
|
WarpGraphModel::nodeVolumeState(QtNodes::NodeId nodeId) const {
|
||||||
|
auto it = m_volumeStates.find(nodeId);
|
||||||
|
if (it != m_volumeStates.end())
|
||||||
|
return it->second;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
void WarpGraphModel::saveLayout(const QString &path) const {
|
void WarpGraphModel::saveLayout(const QString &path) const {
|
||||||
ViewState vs{};
|
ViewState vs{};
|
||||||
saveLayout(path, vs);
|
saveLayout(path, vs);
|
||||||
|
|
@ -938,6 +994,10 @@ bool WarpGraphModel::loadLayout(const QString &path) {
|
||||||
? m_positions.at(qtId)
|
? m_positions.at(qtId)
|
||||||
: QPointF(0, 0);
|
: QPointF(0, 0);
|
||||||
|
|
||||||
|
auto *volumeWidget = new NodeVolumeWidget();
|
||||||
|
m_volumeWidgets[qtId] = volumeWidget;
|
||||||
|
m_volumeStates[qtId] = {};
|
||||||
|
|
||||||
Q_EMIT nodeCreated(qtId);
|
Q_EMIT nodeCreated(qtId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,19 @@ public:
|
||||||
|
|
||||||
uint32_t findPwNodeIdByName(const std::string &name) const;
|
uint32_t findPwNodeIdByName(const std::string &name) const;
|
||||||
|
|
||||||
|
struct NodeVolumeState {
|
||||||
|
float volume = 1.0f;
|
||||||
|
bool mute = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
void setNodeVolumeState(QtNodes::NodeId nodeId, const NodeVolumeState &state);
|
||||||
|
NodeVolumeState nodeVolumeState(QtNodes::NodeId nodeId) const;
|
||||||
|
|
||||||
|
Q_SIGNALS:
|
||||||
|
void nodeVolumeChanged(QtNodes::NodeId nodeId, NodeVolumeState previous,
|
||||||
|
NodeVolumeState current);
|
||||||
|
|
||||||
|
public:
|
||||||
struct ViewState {
|
struct ViewState {
|
||||||
double scale;
|
double scale;
|
||||||
double centerX;
|
double centerX;
|
||||||
|
|
@ -125,4 +138,7 @@ private:
|
||||||
std::unordered_map<std::string, QPointF> m_savedPositions;
|
std::unordered_map<std::string, QPointF> m_savedPositions;
|
||||||
std::vector<PendingGhostConnection> m_pendingGhostConnections;
|
std::vector<PendingGhostConnection> m_pendingGhostConnections;
|
||||||
ViewState m_savedViewState{};
|
ViewState m_savedViewState{};
|
||||||
|
|
||||||
|
std::unordered_map<QtNodes::NodeId, NodeVolumeState> m_volumeStates;
|
||||||
|
std::unordered_map<QtNodes::NodeId, QWidget *> m_volumeWidgets;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
#include "../../gui/GraphEditorWidget.h"
|
#include "../../gui/GraphEditorWidget.h"
|
||||||
#include "../../gui/PresetManager.h"
|
#include "../../gui/PresetManager.h"
|
||||||
|
#include "../../gui/VolumeWidgets.h"
|
||||||
#include "../../gui/WarpGraphModel.h"
|
#include "../../gui/WarpGraphModel.h"
|
||||||
|
|
||||||
#include <QAction>
|
#include <QAction>
|
||||||
|
|
@ -963,3 +964,214 @@ TEST_CASE("splitter sizes persist in layout JSON") {
|
||||||
|
|
||||||
QFile::remove(path);
|
QFile::remove(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("model volume state defaults to 1.0 and unmuted") {
|
||||||
|
auto tc = TestClient::Create();
|
||||||
|
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||||
|
ensureApp();
|
||||||
|
|
||||||
|
REQUIRE(tc.client->Test_InsertNode(
|
||||||
|
MakeNode(100600, "vol-default", "Audio/Sink")).ok());
|
||||||
|
|
||||||
|
WarpGraphModel model(tc.client.get());
|
||||||
|
model.refreshFromClient();
|
||||||
|
|
||||||
|
auto qtId = model.qtNodeIdForPw(100600);
|
||||||
|
REQUIRE(qtId != 0);
|
||||||
|
|
||||||
|
auto state = model.nodeVolumeState(qtId);
|
||||||
|
REQUIRE(state.volume == Catch::Approx(1.0f));
|
||||||
|
REQUIRE_FALSE(state.mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("setNodeVolumeState updates model and calls test helper") {
|
||||||
|
auto tc = TestClient::Create();
|
||||||
|
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||||
|
ensureApp();
|
||||||
|
|
||||||
|
REQUIRE(tc.client->Test_InsertNode(
|
||||||
|
MakeNode(100610, "vol-set", "Audio/Sink")).ok());
|
||||||
|
|
||||||
|
WarpGraphModel model(tc.client.get());
|
||||||
|
model.refreshFromClient();
|
||||||
|
|
||||||
|
auto qtId = model.qtNodeIdForPw(100610);
|
||||||
|
REQUIRE(qtId != 0);
|
||||||
|
|
||||||
|
WarpGraphModel::NodeVolumeState ns;
|
||||||
|
ns.volume = 0.5f;
|
||||||
|
ns.mute = true;
|
||||||
|
model.setNodeVolumeState(qtId, ns);
|
||||||
|
|
||||||
|
auto state = model.nodeVolumeState(qtId);
|
||||||
|
REQUIRE(state.volume == Catch::Approx(0.5f));
|
||||||
|
REQUIRE(state.mute);
|
||||||
|
|
||||||
|
auto apiState = tc.client->Test_GetNodeVolume(warppipe::NodeId{100610});
|
||||||
|
REQUIRE(apiState.ok());
|
||||||
|
REQUIRE(apiState.value.volume == Catch::Approx(0.5f));
|
||||||
|
REQUIRE(apiState.value.mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("nodeVolumeChanged signal emitted on state change") {
|
||||||
|
auto tc = TestClient::Create();
|
||||||
|
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||||
|
ensureApp();
|
||||||
|
|
||||||
|
REQUIRE(tc.client->Test_InsertNode(
|
||||||
|
MakeNode(100620, "vol-signal", "Audio/Sink")).ok());
|
||||||
|
|
||||||
|
WarpGraphModel model(tc.client.get());
|
||||||
|
model.refreshFromClient();
|
||||||
|
|
||||||
|
auto qtId = model.qtNodeIdForPw(100620);
|
||||||
|
REQUIRE(qtId != 0);
|
||||||
|
|
||||||
|
bool signalFired = false;
|
||||||
|
QObject::connect(&model, &WarpGraphModel::nodeVolumeChanged,
|
||||||
|
[&](QtNodes::NodeId id, WarpGraphModel::NodeVolumeState prev,
|
||||||
|
WarpGraphModel::NodeVolumeState cur) {
|
||||||
|
if (id == qtId) {
|
||||||
|
signalFired = true;
|
||||||
|
REQUIRE(prev.volume == Catch::Approx(1.0f));
|
||||||
|
REQUIRE(cur.volume == Catch::Approx(0.3f));
|
||||||
|
REQUIRE(cur.mute);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
WarpGraphModel::NodeVolumeState ns;
|
||||||
|
ns.volume = 0.3f;
|
||||||
|
ns.mute = true;
|
||||||
|
model.setNodeVolumeState(qtId, ns);
|
||||||
|
REQUIRE(signalFired);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("volume widget created for new nodes") {
|
||||||
|
auto tc = TestClient::Create();
|
||||||
|
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||||
|
ensureApp();
|
||||||
|
|
||||||
|
REQUIRE(tc.client->Test_InsertNode(
|
||||||
|
MakeNode(100630, "vol-widget", "Audio/Sink")).ok());
|
||||||
|
|
||||||
|
WarpGraphModel model(tc.client.get());
|
||||||
|
model.refreshFromClient();
|
||||||
|
|
||||||
|
auto qtId = model.qtNodeIdForPw(100630);
|
||||||
|
REQUIRE(qtId != 0);
|
||||||
|
|
||||||
|
auto widget = model.nodeData(qtId, QtNodes::NodeRole::Widget);
|
||||||
|
REQUIRE(widget.isValid());
|
||||||
|
auto *w = widget.value<QWidget *>();
|
||||||
|
REQUIRE(w != nullptr);
|
||||||
|
auto *vol = qobject_cast<NodeVolumeWidget *>(w);
|
||||||
|
REQUIRE(vol != nullptr);
|
||||||
|
REQUIRE(vol->volume() == 100);
|
||||||
|
REQUIRE_FALSE(vol->isMuted());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("setNodeVolumeState syncs inline widget") {
|
||||||
|
auto tc = TestClient::Create();
|
||||||
|
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||||
|
ensureApp();
|
||||||
|
|
||||||
|
REQUIRE(tc.client->Test_InsertNode(
|
||||||
|
MakeNode(100640, "vol-sync", "Audio/Sink")).ok());
|
||||||
|
|
||||||
|
WarpGraphModel model(tc.client.get());
|
||||||
|
model.refreshFromClient();
|
||||||
|
|
||||||
|
auto qtId = model.qtNodeIdForPw(100640);
|
||||||
|
auto *w = model.nodeData(qtId, QtNodes::NodeRole::Widget).value<QWidget *>();
|
||||||
|
auto *vol = qobject_cast<NodeVolumeWidget *>(w);
|
||||||
|
REQUIRE(vol != nullptr);
|
||||||
|
|
||||||
|
WarpGraphModel::NodeVolumeState ns;
|
||||||
|
ns.volume = 0.7f;
|
||||||
|
ns.mute = true;
|
||||||
|
model.setNodeVolumeState(qtId, ns);
|
||||||
|
|
||||||
|
REQUIRE(vol->volume() == 70);
|
||||||
|
REQUIRE(vol->isMuted());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("preset saves and loads volume state") {
|
||||||
|
auto tc = TestClient::Create();
|
||||||
|
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||||
|
ensureApp();
|
||||||
|
|
||||||
|
REQUIRE(tc.client->Test_InsertNode(
|
||||||
|
MakeNode(100650, "vol-preset", "Audio/Sink", {}, {}, true)).ok());
|
||||||
|
REQUIRE(tc.client->Test_InsertPort(
|
||||||
|
MakePort(100651, 100650, "FL", true)).ok());
|
||||||
|
|
||||||
|
WarpGraphModel model(tc.client.get());
|
||||||
|
model.refreshFromClient();
|
||||||
|
|
||||||
|
auto qtId = model.qtNodeIdForPw(100650);
|
||||||
|
WarpGraphModel::NodeVolumeState ns;
|
||||||
|
ns.volume = 0.6f;
|
||||||
|
ns.mute = true;
|
||||||
|
model.setNodeVolumeState(qtId, ns);
|
||||||
|
|
||||||
|
QString path = QStandardPaths::writableLocation(
|
||||||
|
QStandardPaths::TempLocation) +
|
||||||
|
"/warppipe_test_vol_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();
|
||||||
|
QJsonObject root = doc.object();
|
||||||
|
REQUIRE(root.contains("volumes"));
|
||||||
|
QJsonArray volArr = root["volumes"].toArray();
|
||||||
|
bool found = false;
|
||||||
|
for (const auto &val : volArr) {
|
||||||
|
QJsonObject obj = val.toObject();
|
||||||
|
if (obj["name"].toString() == "vol-preset") {
|
||||||
|
found = true;
|
||||||
|
REQUIRE(obj["volume"].toDouble() == Catch::Approx(0.6));
|
||||||
|
REQUIRE(obj["mute"].toBool());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
REQUIRE(found);
|
||||||
|
|
||||||
|
WarpGraphModel model2(tc.client.get());
|
||||||
|
model2.refreshFromClient();
|
||||||
|
auto qtId2 = model2.qtNodeIdForPw(100650);
|
||||||
|
auto stateBefore = model2.nodeVolumeState(qtId2);
|
||||||
|
REQUIRE(stateBefore.volume == Catch::Approx(1.0f));
|
||||||
|
|
||||||
|
REQUIRE(PresetManager::loadPreset(path, tc.client.get(), &model2));
|
||||||
|
auto stateAfter = model2.nodeVolumeState(qtId2);
|
||||||
|
REQUIRE(stateAfter.volume == Catch::Approx(0.6f));
|
||||||
|
REQUIRE(stateAfter.mute);
|
||||||
|
|
||||||
|
QFile::remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("volume state cleaned up on node deletion") {
|
||||||
|
auto tc = TestClient::Create();
|
||||||
|
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||||
|
ensureApp();
|
||||||
|
|
||||||
|
REQUIRE(tc.client->Test_InsertNode(
|
||||||
|
MakeNode(100660, "vol-del", "Audio/Sink")).ok());
|
||||||
|
|
||||||
|
WarpGraphModel model(tc.client.get());
|
||||||
|
model.refreshFromClient();
|
||||||
|
|
||||||
|
auto qtId = model.qtNodeIdForPw(100660);
|
||||||
|
WarpGraphModel::NodeVolumeState ns;
|
||||||
|
ns.volume = 0.4f;
|
||||||
|
model.setNodeVolumeState(qtId, ns);
|
||||||
|
|
||||||
|
REQUIRE(tc.client->Test_RemoveGlobal(100660).ok());
|
||||||
|
model.refreshFromClient();
|
||||||
|
REQUIRE_FALSE(model.nodeExists(qtId));
|
||||||
|
|
||||||
|
auto state = model.nodeVolumeState(qtId);
|
||||||
|
REQUIRE(state.volume == Catch::Approx(1.0f));
|
||||||
|
REQUIRE_FALSE(state.mute);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue