This commit is contained in:
Joey Yakimowich-Payne 2026-01-30 09:24:46 -07:00
commit a07f94c93d
10 changed files with 718 additions and 25 deletions

View file

@ -1,5 +1,6 @@
#include "GraphEditorWidget.h"
#include "PresetManager.h"
#include "VolumeWidgets.h"
#include "WarpGraphModel.h"
#include <QtNodes/BasicGraphicsScene>
@ -19,6 +20,7 @@
#include <QGraphicsItem>
#include <QGuiApplication>
#include <QInputDialog>
#include <QLabel>
#include <QMouseEvent>
#include <QJsonArray>
#include <QJsonDocument>
@ -29,6 +31,7 @@
#include <QMimeData>
#include <QPixmap>
#include <QPushButton>
#include <QScrollArea>
#include <QSplitter>
#include <QStandardPaths>
#include <QStatusBar>
@ -120,6 +123,32 @@ private:
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,
QWidget *parent)
: QWidget(parent), m_client(client) {
@ -190,6 +219,22 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
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->addWidget(m_view);
m_splitter->addWidget(m_sidebar);
@ -305,6 +350,17 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
connect(m_model, &QtNodes::AbstractGraphModel::nodeUpdated, this,
&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->setSingleShot(true);
m_saveTimer->setInterval(1000);
@ -994,3 +1050,165 @@ void GraphEditorWidget::loadPreset() {
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();
}

View file

@ -8,6 +8,7 @@
#include <QWidget>
#include <string>
#include <unordered_map>
#include <vector>
namespace QtNodes {
@ -17,7 +18,9 @@ class GraphicsView;
} // namespace QtNodes
class WarpGraphModel;
class NodeVolumeWidget;
class QLabel;
class QScrollArea;
class QSplitter;
class QTabWidget;
class QTimer;
@ -64,6 +67,8 @@ private:
void restoreViewState();
void savePreset();
void loadPreset();
void wireVolumeWidget(QtNodes::NodeId nodeId);
void rebuildMixerStrips();
struct PendingPasteLink {
std::string outNodeName;
@ -87,4 +92,7 @@ private:
QJsonObject m_clipboardJson;
std::vector<PendingPasteLink> m_pendingPasteLinks;
QPointF m_lastContextMenuScenePos;
QWidget *m_mixerContainer = nullptr;
QScrollArea *m_mixerScroll = nullptr;
std::unordered_map<QtNodes::NodeId, QWidget *> m_mixerStrips;
};

View file

@ -84,11 +84,28 @@ bool PresetManager::savePreset(const QString &path, warppipe::Client *client,
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;
root["version"] = 1;
root["virtual_devices"] = devicesArray;
root["routing"] = routingArray;
root["layout"] = layoutArray;
if (!volumesArray.isEmpty())
root["volumes"] = volumesArray;
QFileInfo fi(path);
QDir dir = fi.absoluteDir();
@ -173,5 +190,27 @@ bool PresetManager::loadPreset(const QString &path, warppipe::Client *client,
}
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;
}

105
gui/VolumeWidgets.cpp Normal file
View 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
View 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;
};

View file

@ -1,4 +1,5 @@
#include "WarpGraphModel.h"
#include "VolumeWidgets.h"
#include <QColor>
#include <QDir>
@ -178,6 +179,12 @@ QVariant WarpGraphModel::nodeData(QtNodes::NodeId nodeId,
WarpNodeType type = classifyNode(data.info);
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:
return QVariant();
}
@ -290,6 +297,12 @@ bool WarpGraphModel::deleteNode(QtNodes::NodeId const nodeId) {
m_nodes.erase(nodeId);
m_positions.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);
return true;
}
@ -457,6 +470,10 @@ void WarpGraphModel::refreshFromClient() {
}
}
auto *volumeWidget = new NodeVolumeWidget();
m_volumeWidgets[qtId] = volumeWidget;
m_volumeStates[qtId] = {};
Q_EMIT nodeCreated(qtId);
}
@ -713,6 +730,45 @@ WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) {
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 {
ViewState vs{};
saveLayout(path, vs);
@ -938,6 +994,10 @@ bool WarpGraphModel::loadLayout(const QString &path) {
? m_positions.at(qtId)
: QPointF(0, 0);
auto *volumeWidget = new NodeVolumeWidget();
m_volumeWidgets[qtId] = volumeWidget;
m_volumeStates[qtId] = {};
Q_EMIT nodeCreated(qtId);
}
}

View file

@ -69,6 +69,19 @@ public:
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 {
double scale;
double centerX;
@ -125,4 +138,7 @@ private:
std::unordered_map<std::string, QPointF> m_savedPositions;
std::vector<PendingGhostConnection> m_pendingGhostConnections;
ViewState m_savedViewState{};
std::unordered_map<QtNodes::NodeId, NodeVolumeState> m_volumeStates;
std::unordered_map<QtNodes::NodeId, QWidget *> m_volumeWidgets;
};