1375 lines
46 KiB
C++
1375 lines
46 KiB
C++
#include "AudioLevelMeter.h"
|
|
#include "GraphEditorWidget.h"
|
|
#include "PresetManager.h"
|
|
#include "VolumeWidgets.h"
|
|
#include "WarpGraphModel.h"
|
|
|
|
#include <QtNodes/BasicGraphicsScene>
|
|
#include <QtNodes/ConnectionStyle>
|
|
#include <QtNodes/GraphicsView>
|
|
#include <QtNodes/internal/NodeGraphicsObject.hpp>
|
|
#include <QtNodes/internal/ConnectionGraphicsObject.hpp>
|
|
#include <QtNodes/internal/UndoCommands.hpp>
|
|
|
|
#include <QAction>
|
|
#include <QClipboard>
|
|
#include <QContextMenuEvent>
|
|
#include <QCoreApplication>
|
|
#include <QDateTime>
|
|
#include <QDir>
|
|
#include <QFileDialog>
|
|
#include <QGraphicsItem>
|
|
#include <QGuiApplication>
|
|
#include <QInputDialog>
|
|
#include <QLabel>
|
|
#include <QMouseEvent>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QMenu>
|
|
#include <QListWidget>
|
|
#include <QMainWindow>
|
|
#include <QMessageBox>
|
|
#include <QMimeData>
|
|
#include <QPixmap>
|
|
#include <QPushButton>
|
|
#include <QScrollArea>
|
|
#include <QSplitter>
|
|
#include <QStandardPaths>
|
|
#include <QStatusBar>
|
|
#include <QTabWidget>
|
|
#include <QTimer>
|
|
#include <QUndoCommand>
|
|
#include <QVBoxLayout>
|
|
|
|
#include <algorithm>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
|
|
class DeleteVirtualNodeCommand : public QUndoCommand {
|
|
public:
|
|
struct Snapshot {
|
|
uint32_t pwNodeId;
|
|
QtNodes::NodeId qtNodeId;
|
|
std::string name;
|
|
std::string mediaClass;
|
|
QPointF position;
|
|
};
|
|
|
|
DeleteVirtualNodeCommand(GraphEditorWidget *widget,
|
|
const QList<QtNodes::NodeId> &nodeIds)
|
|
: m_widget(widget) {
|
|
WarpGraphModel *model = widget->m_model;
|
|
for (auto nodeId : nodeIds) {
|
|
const WarpNodeData *data = model->warpNodeData(nodeId);
|
|
if (!data)
|
|
continue;
|
|
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
|
|
if (type != WarpNodeType::kVirtualSink &&
|
|
type != WarpNodeType::kVirtualSource)
|
|
continue;
|
|
|
|
Snapshot snap;
|
|
snap.pwNodeId = data->info.id.value;
|
|
snap.qtNodeId = nodeId;
|
|
snap.name = data->info.name;
|
|
snap.mediaClass = data->info.media_class;
|
|
snap.position =
|
|
model->nodeData(nodeId, QtNodes::NodeRole::Position).toPointF();
|
|
m_snapshots.push_back(snap);
|
|
}
|
|
setText(QStringLiteral("Delete Virtual Node"));
|
|
}
|
|
|
|
void undo() override {
|
|
if (!m_widget)
|
|
return;
|
|
auto *client = m_widget->m_client;
|
|
auto *model = m_widget->m_model;
|
|
if (!client || !model)
|
|
return;
|
|
|
|
for (const auto &snap : m_snapshots) {
|
|
model->setPendingPosition(snap.name, snap.position);
|
|
bool isSink = snap.mediaClass == "Audio/Sink" ||
|
|
snap.mediaClass == "Audio/Duplex";
|
|
if (isSink) {
|
|
client->CreateVirtualSink(snap.name);
|
|
} else {
|
|
client->CreateVirtualSource(snap.name);
|
|
}
|
|
}
|
|
model->refreshFromClient();
|
|
}
|
|
|
|
void redo() override {
|
|
if (!m_widget)
|
|
return;
|
|
auto *client = m_widget->m_client;
|
|
auto *model = m_widget->m_model;
|
|
if (!client || !model)
|
|
return;
|
|
|
|
for (auto &snap : m_snapshots) {
|
|
uint32_t currentPwId = model->findPwNodeIdByName(snap.name);
|
|
if (currentPwId != 0) {
|
|
snap.pwNodeId = currentPwId;
|
|
client->RemoveNode(warppipe::NodeId{currentPwId});
|
|
}
|
|
}
|
|
model->refreshFromClient();
|
|
}
|
|
|
|
private:
|
|
GraphEditorWidget *m_widget = nullptr;
|
|
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) {
|
|
m_layoutPath =
|
|
QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) +
|
|
QStringLiteral("/layout.json");
|
|
|
|
m_model = new WarpGraphModel(client, this);
|
|
bool hasLayout = m_model->loadLayout(m_layoutPath);
|
|
|
|
m_scene = new QtNodes::BasicGraphicsScene(*m_model, this);
|
|
|
|
QtNodes::ConnectionStyle::setConnectionStyle(
|
|
R"({"ConnectionStyle": {
|
|
"ConstructionColor": "#b4b4c8",
|
|
"NormalColor": "#c8c8dc",
|
|
"SelectedColor": "#ffa500",
|
|
"SelectedHaloColor": "#ffa50040",
|
|
"HoveredColor": "#f0c878",
|
|
"LineWidth": 2.4,
|
|
"ConstructionLineWidth": 1.8,
|
|
"PointDiameter": 10.0,
|
|
"UseDataDefinedColors": false
|
|
}})");
|
|
|
|
m_view = new QtNodes::GraphicsView(m_scene);
|
|
m_view->setFocusPolicy(Qt::StrongFocus);
|
|
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();
|
|
|
|
auto *metersTab = new QWidget();
|
|
auto *metersLayout = new QVBoxLayout(metersTab);
|
|
metersLayout->setContentsMargins(8, 8, 8, 8);
|
|
metersLayout->setSpacing(8);
|
|
|
|
auto *masterLabel = new QLabel(QStringLiteral("MASTER OUTPUT"));
|
|
masterLabel->setStyleSheet(QStringLiteral(
|
|
"QLabel { color: #a0a8b6; font-size: 11px; font-weight: bold;"
|
|
" background: transparent; }"));
|
|
metersLayout->addWidget(masterLabel);
|
|
|
|
auto *masterRow = new QWidget();
|
|
auto *masterRowLayout = new QHBoxLayout(masterRow);
|
|
masterRowLayout->setContentsMargins(0, 0, 0, 0);
|
|
masterRowLayout->setSpacing(4);
|
|
m_masterMeterL = new AudioLevelMeter();
|
|
m_masterMeterL->setFixedWidth(18);
|
|
m_masterMeterL->setMinimumHeight(100);
|
|
m_masterMeterR = new AudioLevelMeter();
|
|
m_masterMeterR->setFixedWidth(18);
|
|
m_masterMeterR->setMinimumHeight(100);
|
|
masterRowLayout->addStretch();
|
|
masterRowLayout->addWidget(m_masterMeterL);
|
|
masterRowLayout->addWidget(m_masterMeterR);
|
|
masterRowLayout->addStretch();
|
|
metersLayout->addWidget(masterRow);
|
|
|
|
auto *nodeMetersLabel = new QLabel(QStringLiteral("NODE METERS"));
|
|
nodeMetersLabel->setStyleSheet(masterLabel->styleSheet());
|
|
metersLayout->addWidget(nodeMetersLabel);
|
|
|
|
m_nodeMeterScroll = new QScrollArea();
|
|
m_nodeMeterScroll->setWidgetResizable(true);
|
|
m_nodeMeterScroll->setStyleSheet(QStringLiteral(
|
|
"QScrollArea { background: transparent; 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_nodeMeterContainer = new QWidget();
|
|
m_nodeMeterContainer->setStyleSheet(QStringLiteral("background: transparent;"));
|
|
auto *nodeMeterLayout = new QVBoxLayout(m_nodeMeterContainer);
|
|
nodeMeterLayout->setContentsMargins(0, 0, 0, 0);
|
|
nodeMeterLayout->setSpacing(2);
|
|
nodeMeterLayout->addStretch();
|
|
m_nodeMeterScroll->setWidget(m_nodeMeterContainer);
|
|
metersLayout->addWidget(m_nodeMeterScroll, 1);
|
|
|
|
metersTab->setStyleSheet(QStringLiteral("background: #1a1a1e;"));
|
|
m_sidebar->addTab(metersTab, QStringLiteral("METERS"));
|
|
|
|
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_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_splitter);
|
|
|
|
m_view->setContextMenuPolicy(Qt::CustomContextMenu);
|
|
connect(m_view, &QWidget::customContextMenuRequested, this,
|
|
&GraphEditorWidget::onContextMenuRequested);
|
|
|
|
removeDefaultActions();
|
|
|
|
auto *deleteAction =
|
|
new QAction(QStringLiteral("Delete Selection"), m_view);
|
|
deleteAction->setShortcut(QKeySequence::Delete);
|
|
deleteAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(deleteAction, &QAction::triggered, this,
|
|
&GraphEditorWidget::deleteSelection);
|
|
m_view->addAction(deleteAction);
|
|
|
|
auto *copyAction =
|
|
new QAction(QStringLiteral("Copy Selection"), m_view);
|
|
copyAction->setShortcut(QKeySequence::Copy);
|
|
copyAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(copyAction, &QAction::triggered, this,
|
|
&GraphEditorWidget::copySelection);
|
|
m_view->addAction(copyAction);
|
|
|
|
auto *pasteAction =
|
|
new QAction(QStringLiteral("Paste Selection"), m_view);
|
|
pasteAction->setShortcut(QKeySequence::Paste);
|
|
pasteAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(pasteAction, &QAction::triggered, this,
|
|
[this]() { pasteSelection(QPointF(30, 30)); });
|
|
m_view->addAction(pasteAction);
|
|
|
|
auto *duplicateAction =
|
|
new QAction(QStringLiteral("Duplicate Selection"), m_view);
|
|
duplicateAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D));
|
|
duplicateAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(duplicateAction, &QAction::triggered, this,
|
|
&GraphEditorWidget::duplicateSelection);
|
|
m_view->addAction(duplicateAction);
|
|
|
|
auto *autoArrangeAction =
|
|
new QAction(QStringLiteral("Auto-Arrange"), m_view);
|
|
autoArrangeAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_L));
|
|
autoArrangeAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(autoArrangeAction, &QAction::triggered, this, [this]() {
|
|
m_model->autoArrange();
|
|
saveLayoutWithViewState();
|
|
});
|
|
m_view->addAction(autoArrangeAction);
|
|
|
|
auto *selectAllAction =
|
|
new QAction(QStringLiteral("Select All"), m_view);
|
|
selectAllAction->setShortcut(QKeySequence::SelectAll);
|
|
selectAllAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(selectAllAction, &QAction::triggered, this, [this]() {
|
|
for (auto *item : m_scene->items()) {
|
|
item->setSelected(true);
|
|
}
|
|
});
|
|
m_view->addAction(selectAllAction);
|
|
|
|
auto *deselectAllAction =
|
|
new QAction(QStringLiteral("Deselect All"), m_view);
|
|
deselectAllAction->setShortcut(
|
|
QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_A));
|
|
deselectAllAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(deselectAllAction, &QAction::triggered, m_scene,
|
|
&QGraphicsScene::clearSelection);
|
|
m_view->addAction(deselectAllAction);
|
|
|
|
auto *zoomFitAllAction =
|
|
new QAction(QStringLiteral("Zoom Fit All"), m_view);
|
|
zoomFitAllAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_F));
|
|
zoomFitAllAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(zoomFitAllAction, &QAction::triggered, m_view,
|
|
&QtNodes::GraphicsView::zoomFitAll);
|
|
m_view->addAction(zoomFitAllAction);
|
|
|
|
auto *zoomFitSelectedAction =
|
|
new QAction(QStringLiteral("Zoom Fit Selected"), m_view);
|
|
zoomFitSelectedAction->setShortcut(QKeySequence(Qt::Key_F));
|
|
zoomFitSelectedAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(zoomFitSelectedAction, &QAction::triggered, m_view,
|
|
&QtNodes::GraphicsView::zoomFitSelected);
|
|
m_view->addAction(zoomFitSelectedAction);
|
|
|
|
auto *refreshAction =
|
|
new QAction(QStringLiteral("Refresh Graph"), m_view);
|
|
refreshAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
|
|
refreshAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
|
connect(refreshAction, &QAction::triggered, this, [this]() {
|
|
m_model->refreshFromClient();
|
|
});
|
|
m_view->addAction(refreshAction);
|
|
|
|
connect(m_model, &WarpGraphModel::nodePositionUpdated, this,
|
|
&GraphEditorWidget::scheduleSaveLayout);
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeCreated, this,
|
|
&GraphEditorWidget::scheduleSaveLayout);
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this,
|
|
&GraphEditorWidget::scheduleSaveLayout);
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeUpdated, this,
|
|
&GraphEditorWidget::scheduleSaveLayout);
|
|
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeCreated, this,
|
|
[this](QtNodes::NodeId nodeId) {
|
|
wireVolumeWidget(nodeId);
|
|
rebuildMixerStrips();
|
|
rebuildNodeMeters();
|
|
});
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this,
|
|
[this](QtNodes::NodeId nodeId) {
|
|
m_mixerStrips.erase(nodeId);
|
|
m_nodeMeters.erase(nodeId);
|
|
rebuildMixerStrips();
|
|
rebuildNodeMeters();
|
|
});
|
|
|
|
m_saveTimer = new QTimer(this);
|
|
m_saveTimer->setSingleShot(true);
|
|
m_saveTimer->setInterval(1000);
|
|
connect(m_saveTimer, &QTimer::timeout, this,
|
|
&GraphEditorWidget::saveLayoutWithViewState);
|
|
|
|
m_model->refreshFromClient();
|
|
if (!hasLayout) {
|
|
m_model->autoArrange();
|
|
}
|
|
|
|
QTimer::singleShot(0, this, &GraphEditorWidget::restoreViewState);
|
|
|
|
if (m_model->allNodeIds().size() > 0) {
|
|
m_graphReady = true;
|
|
Q_EMIT graphReady();
|
|
}
|
|
|
|
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this,
|
|
&GraphEditorWidget::saveLayoutWithViewState);
|
|
|
|
m_refreshTimer = new QTimer(this);
|
|
connect(m_refreshTimer, &QTimer::timeout, this,
|
|
&GraphEditorWidget::onRefreshTimer);
|
|
m_refreshTimer->start(500);
|
|
|
|
m_meterTimer = new QTimer(this);
|
|
m_meterTimer->setTimerType(Qt::PreciseTimer);
|
|
connect(m_meterTimer, &QTimer::timeout, this,
|
|
&GraphEditorWidget::updateMeters);
|
|
m_meterTimer->start(33);
|
|
}
|
|
|
|
void GraphEditorWidget::onRefreshTimer() {
|
|
m_model->refreshFromClient();
|
|
|
|
if (!m_graphReady && m_model->allNodeIds().size() > 0) {
|
|
m_graphReady = true;
|
|
Q_EMIT graphReady();
|
|
captureDebugScreenshot("initial_load");
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::scheduleSaveLayout() {
|
|
if (!m_saveTimer->isActive()) {
|
|
m_saveTimer->start();
|
|
}
|
|
}
|
|
|
|
int GraphEditorWidget::nodeCount() const {
|
|
return static_cast<int>(m_model->allNodeIds().size());
|
|
}
|
|
|
|
int GraphEditorWidget::linkCount() const {
|
|
int count = 0;
|
|
for (auto nodeId : m_model->allNodeIds()) {
|
|
count += static_cast<int>(
|
|
m_model->allConnectionIds(nodeId).size());
|
|
}
|
|
return count / 2;
|
|
}
|
|
|
|
void GraphEditorWidget::setDebugScreenshotDir(const QString &dir) {
|
|
m_debugScreenshotDir = dir;
|
|
QDir d(dir);
|
|
if (!d.exists()) {
|
|
d.mkpath(".");
|
|
}
|
|
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeCreated, this, [this]() {
|
|
captureDebugScreenshot("node_added");
|
|
});
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this, [this]() {
|
|
captureDebugScreenshot("node_removed");
|
|
});
|
|
connect(m_model, &QtNodes::AbstractGraphModel::connectionCreated, this,
|
|
[this]() { captureDebugScreenshot("connection_added"); });
|
|
connect(m_model, &QtNodes::AbstractGraphModel::connectionDeleted, this,
|
|
[this]() { captureDebugScreenshot("connection_removed"); });
|
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeUpdated, this, [this]() {
|
|
captureDebugScreenshot("node_updated");
|
|
});
|
|
|
|
if (m_graphReady) {
|
|
QTimer::singleShot(200, this, [this]() {
|
|
captureDebugScreenshot("initial_load");
|
|
});
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::captureDebugScreenshot(const QString &event) {
|
|
if (m_debugScreenshotDir.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
QWidget *win = window();
|
|
if (!win) {
|
|
return;
|
|
}
|
|
|
|
QPixmap pixmap = win->grab();
|
|
if (pixmap.isNull()) {
|
|
return;
|
|
}
|
|
|
|
QString timestamp =
|
|
QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
|
|
QString filename = QString("warppipe_%1_%2.png").arg(timestamp, event);
|
|
pixmap.save(m_debugScreenshotDir + "/" + filename);
|
|
}
|
|
|
|
bool GraphEditorWidget::eventFilter(QObject *obj, QEvent *event) {
|
|
if (obj != m_view->viewport()) {
|
|
return QWidget::eventFilter(obj, event);
|
|
}
|
|
if (event->type() == QEvent::ContextMenu) {
|
|
auto *cme = static_cast<QContextMenuEvent *>(event);
|
|
m_lastContextMenuScenePos = m_view->mapToScene(cme->pos());
|
|
} else if (event->type() == QEvent::MouseButtonPress) {
|
|
auto *me = static_cast<QMouseEvent *>(event);
|
|
if (me->button() == Qt::MiddleButton) {
|
|
m_view->centerOn(m_view->mapToScene(me->pos()));
|
|
return true;
|
|
}
|
|
}
|
|
return QWidget::eventFilter(obj, event);
|
|
}
|
|
|
|
void GraphEditorWidget::onContextMenuRequested(const QPoint &pos) {
|
|
QPointF scenePos = m_view->mapToScene(pos);
|
|
m_lastContextMenuScenePos = scenePos;
|
|
|
|
uint32_t hitPwNodeId = 0;
|
|
QtNodes::NodeId hitQtNodeId = 0;
|
|
for (auto nodeId : m_model->allNodeIds()) {
|
|
const WarpNodeData *data = m_model->warpNodeData(nodeId);
|
|
if (!data) {
|
|
continue;
|
|
}
|
|
QPointF nodePos =
|
|
m_model->nodeData(nodeId, QtNodes::NodeRole::Position).toPointF();
|
|
QSize nodeSize =
|
|
m_model->nodeData(nodeId, QtNodes::NodeRole::Size).toSize();
|
|
QRectF nodeRect(nodePos, QSizeF(nodeSize));
|
|
if (nodeRect.contains(scenePos)) {
|
|
hitPwNodeId = data->info.id.value;
|
|
hitQtNodeId = nodeId;
|
|
break;
|
|
}
|
|
}
|
|
|
|
QPoint screenPos = m_view->mapToGlobal(pos);
|
|
if (hitPwNodeId != 0) {
|
|
showNodeContextMenu(screenPos, hitPwNodeId, hitQtNodeId);
|
|
} else {
|
|
showCanvasContextMenu(screenPos, scenePos);
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
|
|
const QPointF &scenePos) {
|
|
QMenu menu;
|
|
QAction *createSink = menu.addAction(QStringLiteral("Create Virtual Sink"));
|
|
QAction *createSource =
|
|
menu.addAction(QStringLiteral("Create Virtual Source"));
|
|
menu.addSeparator();
|
|
QAction *pasteAction = menu.addAction(QStringLiteral("Paste"));
|
|
pasteAction->setShortcut(QKeySequence::Paste);
|
|
pasteAction->setEnabled(!m_clipboardJson.isEmpty() ||
|
|
(QGuiApplication::clipboard()->mimeData() &&
|
|
QGuiApplication::clipboard()->mimeData()->hasFormat(
|
|
QStringLiteral(
|
|
"application/warppipe-virtual-graph"))));
|
|
menu.addSeparator();
|
|
QAction *selectAll = menu.addAction(QStringLiteral("Select All"));
|
|
selectAll->setShortcut(QKeySequence::SelectAll);
|
|
QAction *deselectAll = menu.addAction(QStringLiteral("Deselect All"));
|
|
deselectAll->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_A));
|
|
menu.addSeparator();
|
|
QAction *zoomFitAll = menu.addAction(QStringLiteral("Zoom Fit All"));
|
|
zoomFitAll->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_F));
|
|
QAction *zoomFitSelected = menu.addAction(QStringLiteral("Zoom Fit Selected"));
|
|
zoomFitSelected->setShortcut(QKeySequence(Qt::Key_F));
|
|
menu.addSeparator();
|
|
QAction *autoArrange = menu.addAction(QStringLiteral("Auto-Arrange"));
|
|
autoArrange->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_L));
|
|
QAction *refreshGraph = menu.addAction(QStringLiteral("Refresh Graph"));
|
|
refreshGraph->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
|
|
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) {
|
|
createVirtualNode(true, scenePos);
|
|
} else if (chosen == createSource) {
|
|
createVirtualNode(false, scenePos);
|
|
} else if (chosen == pasteAction) {
|
|
pasteSelection(QPointF(0, 0));
|
|
} else if (chosen == selectAll) {
|
|
for (auto *item : m_scene->items()) {
|
|
item->setSelected(true);
|
|
}
|
|
} else if (chosen == deselectAll) {
|
|
m_scene->clearSelection();
|
|
} else if (chosen == zoomFitAll) {
|
|
m_view->zoomFitAll();
|
|
} else if (chosen == zoomFitSelected) {
|
|
m_view->zoomFitSelected();
|
|
} else if (chosen == autoArrange) {
|
|
m_model->autoArrange();
|
|
saveLayoutWithViewState();
|
|
} else if (chosen == refreshGraph) {
|
|
m_model->refreshFromClient();
|
|
} else if (chosen == saveLayoutAs) {
|
|
QString path = QFileDialog::getSaveFileName(
|
|
this, QStringLiteral("Save Layout As"), QString(),
|
|
QStringLiteral("JSON files (*.json)"));
|
|
if (!path.isEmpty()) {
|
|
saveLayoutWithViewState();
|
|
m_model->saveLayout(path);
|
|
}
|
|
} else if (chosen == resetLayout) {
|
|
m_model->clearSavedPositions();
|
|
m_model->autoArrange();
|
|
m_view->zoomFitAll();
|
|
saveLayoutWithViewState();
|
|
} else if (chosen == savePresetAction) {
|
|
savePreset();
|
|
} else if (chosen == loadPresetAction) {
|
|
loadPreset();
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos,
|
|
uint32_t pwNodeId,
|
|
QtNodes::NodeId qtNodeId) {
|
|
const WarpNodeData *data = m_model->warpNodeData(qtNodeId);
|
|
if (!data) {
|
|
return;
|
|
}
|
|
|
|
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
|
|
bool isVirtual =
|
|
type == WarpNodeType::kVirtualSink || type == WarpNodeType::kVirtualSource;
|
|
|
|
QMenu menu;
|
|
|
|
QAction *copyAction = nullptr;
|
|
QAction *duplicateAction = nullptr;
|
|
QAction *deleteAction = nullptr;
|
|
|
|
if (isVirtual) {
|
|
copyAction = menu.addAction(QStringLiteral("Copy"));
|
|
copyAction->setShortcut(QKeySequence::Copy);
|
|
duplicateAction = menu.addAction(QStringLiteral("Duplicate"));
|
|
duplicateAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D));
|
|
menu.addSeparator();
|
|
deleteAction = menu.addAction(QStringLiteral("Delete"));
|
|
deleteAction->setShortcut(QKeySequence::Delete);
|
|
}
|
|
|
|
QAction *pasteAction = menu.addAction(QStringLiteral("Paste"));
|
|
pasteAction->setShortcut(QKeySequence::Paste);
|
|
pasteAction->setEnabled(!m_clipboardJson.isEmpty() ||
|
|
(QGuiApplication::clipboard()->mimeData() &&
|
|
QGuiApplication::clipboard()->mimeData()->hasFormat(
|
|
QStringLiteral(
|
|
"application/warppipe-virtual-graph"))));
|
|
|
|
QAction *chosen = menu.exec(screenPos);
|
|
if (!chosen) {
|
|
return;
|
|
}
|
|
if (chosen == copyAction) {
|
|
copySelection();
|
|
} else if (chosen == duplicateAction) {
|
|
duplicateSelection();
|
|
} else if (chosen == deleteAction && m_client) {
|
|
deleteSelection();
|
|
} else if (chosen == pasteAction) {
|
|
pasteSelection(QPointF(0, 0));
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::createVirtualNode(bool isSink,
|
|
const QPointF &scenePos) {
|
|
QString label = isSink ? QStringLiteral("Create Virtual Sink")
|
|
: QStringLiteral("Create Virtual Source");
|
|
bool ok = false;
|
|
QString name = QInputDialog::getText(this, label,
|
|
QStringLiteral("Node name:"),
|
|
QLineEdit::Normal, QString(), &ok);
|
|
if (!ok || name.trimmed().isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
std::string nodeName = name.trimmed().toStdString();
|
|
m_model->setPendingPosition(nodeName, scenePos);
|
|
|
|
warppipe::Status status;
|
|
if (isSink) {
|
|
auto result = m_client->CreateVirtualSink(nodeName);
|
|
status = result.status;
|
|
} else {
|
|
auto result = m_client->CreateVirtualSource(nodeName);
|
|
status = result.status;
|
|
}
|
|
|
|
if (!status.ok()) {
|
|
QMessageBox::warning(this, QStringLiteral("Error"),
|
|
QString::fromStdString(status.message));
|
|
return;
|
|
}
|
|
|
|
m_model->refreshFromClient();
|
|
}
|
|
|
|
void GraphEditorWidget::removeDefaultActions() {
|
|
const QList<QAction *> actions = m_view->actions();
|
|
for (QAction *action : actions) {
|
|
const QString text = action->text();
|
|
if (text.contains(QStringLiteral("Copy Selection")) ||
|
|
text.contains(QStringLiteral("Paste Selection")) ||
|
|
text.contains(QStringLiteral("Duplicate Selection")) ||
|
|
text.contains(QStringLiteral("Delete Selection"))) {
|
|
m_view->removeAction(action);
|
|
action->deleteLater();
|
|
}
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::deleteSelection() {
|
|
if (!m_scene) {
|
|
return;
|
|
}
|
|
|
|
const QList<QGraphicsItem *> items = m_scene->selectedItems();
|
|
QList<QtNodes::NodeId> virtualNodeIds;
|
|
bool hasSelectedConnections = false;
|
|
|
|
for (auto *item : items) {
|
|
if (auto *nodeObj =
|
|
qgraphicsitem_cast<QtNodes::NodeGraphicsObject *>(item)) {
|
|
const WarpNodeData *data = m_model->warpNodeData(nodeObj->nodeId());
|
|
if (!data)
|
|
continue;
|
|
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
|
|
if (type == WarpNodeType::kVirtualSink ||
|
|
type == WarpNodeType::kVirtualSource) {
|
|
virtualNodeIds.append(nodeObj->nodeId());
|
|
}
|
|
} else if (qgraphicsitem_cast<QtNodes::ConnectionGraphicsObject *>(
|
|
item)) {
|
|
hasSelectedConnections = true;
|
|
}
|
|
}
|
|
|
|
if (!virtualNodeIds.isEmpty()) {
|
|
m_scene->undoStack().push(
|
|
new DeleteVirtualNodeCommand(this, virtualNodeIds));
|
|
}
|
|
|
|
if (virtualNodeIds.isEmpty() && hasSelectedConnections) {
|
|
m_scene->undoStack().push(new QtNodes::DeleteCommand(m_scene));
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::copySelection() {
|
|
if (!m_scene || !m_client) {
|
|
return;
|
|
}
|
|
|
|
QJsonArray nodesJson;
|
|
std::unordered_set<std::string> selectedNames;
|
|
QPointF sum;
|
|
int count = 0;
|
|
|
|
const QList<QGraphicsItem *> items = m_scene->selectedItems();
|
|
for (auto *item : items) {
|
|
auto *nodeObj =
|
|
qgraphicsitem_cast<QtNodes::NodeGraphicsObject *>(item);
|
|
if (!nodeObj)
|
|
continue;
|
|
|
|
const WarpNodeData *data = m_model->warpNodeData(nodeObj->nodeId());
|
|
if (!data)
|
|
continue;
|
|
|
|
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
|
|
if (type != WarpNodeType::kVirtualSink &&
|
|
type != WarpNodeType::kVirtualSource)
|
|
continue;
|
|
|
|
QJsonObject nodeJson;
|
|
nodeJson[QStringLiteral("name")] =
|
|
QString::fromStdString(data->info.name);
|
|
nodeJson[QStringLiteral("media_class")] =
|
|
QString::fromStdString(data->info.media_class);
|
|
int channels = static_cast<int>(
|
|
std::max(data->inputPorts.size(), data->outputPorts.size()));
|
|
nodeJson[QStringLiteral("channels")] = channels > 0 ? channels : 2;
|
|
QPointF pos =
|
|
m_model->nodeData(nodeObj->nodeId(), QtNodes::NodeRole::Position)
|
|
.toPointF();
|
|
nodeJson[QStringLiteral("x")] = pos.x();
|
|
nodeJson[QStringLiteral("y")] = pos.y();
|
|
|
|
nodesJson.append(nodeJson);
|
|
selectedNames.insert(data->info.name);
|
|
sum += pos;
|
|
++count;
|
|
}
|
|
|
|
if (nodesJson.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
std::unordered_map<uint32_t, std::pair<std::string, std::string>> portOwner;
|
|
for (auto qtId : m_model->allNodeIds()) {
|
|
const WarpNodeData *data = m_model->warpNodeData(qtId);
|
|
if (!data || selectedNames.find(data->info.name) == selectedNames.end())
|
|
continue;
|
|
for (const auto &port : data->outputPorts) {
|
|
portOwner[port.id.value] = {data->info.name, port.name};
|
|
}
|
|
for (const auto &port : data->inputPorts) {
|
|
portOwner[port.id.value] = {data->info.name, port.name};
|
|
}
|
|
}
|
|
|
|
QJsonArray linksJson;
|
|
auto linksResult = m_client->ListLinks();
|
|
if (linksResult.ok()) {
|
|
for (const auto &link : linksResult.value) {
|
|
auto outIt = portOwner.find(link.output_port.value);
|
|
auto inIt = portOwner.find(link.input_port.value);
|
|
if (outIt != portOwner.end() && inIt != portOwner.end()) {
|
|
QJsonObject linkJson;
|
|
linkJson[QStringLiteral("source")] = QString::fromStdString(
|
|
outIt->second.first + ":" + outIt->second.second);
|
|
linkJson[QStringLiteral("target")] = QString::fromStdString(
|
|
inIt->second.first + ":" + inIt->second.second);
|
|
linksJson.append(linkJson);
|
|
}
|
|
}
|
|
}
|
|
|
|
QJsonObject root;
|
|
root[QStringLiteral("nodes")] = nodesJson;
|
|
root[QStringLiteral("links")] = linksJson;
|
|
root[QStringLiteral("center_x")] = count > 0 ? sum.x() / count : 0.0;
|
|
root[QStringLiteral("center_y")] = count > 0 ? sum.y() / count : 0.0;
|
|
root[QStringLiteral("version")] = 1;
|
|
|
|
m_clipboardJson = root;
|
|
|
|
QJsonDocument doc(root);
|
|
auto *mime = new QMimeData();
|
|
mime->setData(QStringLiteral("application/warppipe-virtual-graph"),
|
|
doc.toJson(QJsonDocument::Compact));
|
|
mime->setText(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)));
|
|
QGuiApplication::clipboard()->setMimeData(mime);
|
|
}
|
|
|
|
void GraphEditorWidget::pasteSelection(const QPointF &offset) {
|
|
if (!m_client || !m_model) {
|
|
return;
|
|
}
|
|
|
|
QJsonObject root;
|
|
const QMimeData *mime = QGuiApplication::clipboard()->mimeData();
|
|
if (mime &&
|
|
mime->hasFormat(
|
|
QStringLiteral("application/warppipe-virtual-graph"))) {
|
|
root = QJsonDocument::fromJson(
|
|
mime->data(QStringLiteral(
|
|
"application/warppipe-virtual-graph")))
|
|
.object();
|
|
} else if (!m_clipboardJson.isEmpty()) {
|
|
root = m_clipboardJson;
|
|
}
|
|
|
|
if (root.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
std::unordered_set<std::string> existingNames;
|
|
auto nodesResult = m_client->ListNodes();
|
|
if (nodesResult.ok()) {
|
|
for (const auto &node : nodesResult.value) {
|
|
existingNames.insert(node.name);
|
|
}
|
|
}
|
|
|
|
std::unordered_map<std::string, std::string> nameMap;
|
|
|
|
const QJsonArray nodesArray =
|
|
root[QStringLiteral("nodes")].toArray();
|
|
for (const auto &entry : nodesArray) {
|
|
QJsonObject nodeObj = entry.toObject();
|
|
std::string baseName =
|
|
nodeObj[QStringLiteral("name")].toString().toStdString();
|
|
std::string mediaClass =
|
|
nodeObj[QStringLiteral("media_class")].toString().toStdString();
|
|
double x = nodeObj[QStringLiteral("x")].toDouble();
|
|
double y = nodeObj[QStringLiteral("y")].toDouble();
|
|
|
|
if (baseName.empty())
|
|
continue;
|
|
|
|
std::string newName = baseName + " Copy";
|
|
int suffix = 2;
|
|
while (existingNames.count(newName)) {
|
|
newName = baseName + " Copy " + std::to_string(suffix++);
|
|
}
|
|
existingNames.insert(newName);
|
|
nameMap[baseName] = newName;
|
|
|
|
m_model->setPendingPosition(newName, QPointF(x, y) + offset);
|
|
|
|
bool isSink =
|
|
mediaClass == "Audio/Sink" || mediaClass == "Audio/Duplex";
|
|
if (isSink) {
|
|
m_client->CreateVirtualSink(newName);
|
|
} else {
|
|
m_client->CreateVirtualSource(newName);
|
|
}
|
|
}
|
|
|
|
const QJsonArray linksArray =
|
|
root[QStringLiteral("links")].toArray();
|
|
for (const auto &entry : linksArray) {
|
|
QJsonObject linkObj = entry.toObject();
|
|
std::string source =
|
|
linkObj[QStringLiteral("source")].toString().toStdString();
|
|
std::string target =
|
|
linkObj[QStringLiteral("target")].toString().toStdString();
|
|
|
|
auto splitKey = [](const std::string &s)
|
|
-> std::pair<std::string, std::string> {
|
|
auto pos = s.rfind(':');
|
|
if (pos == std::string::npos)
|
|
return {s, ""};
|
|
return {s.substr(0, pos), s.substr(pos + 1)};
|
|
};
|
|
|
|
auto [outName, outPort] = splitKey(source);
|
|
auto [inName, inPort] = splitKey(target);
|
|
|
|
auto outIt = nameMap.find(outName);
|
|
auto inIt = nameMap.find(inName);
|
|
if (outIt == nameMap.end() || inIt == nameMap.end())
|
|
continue;
|
|
|
|
PendingPasteLink pending;
|
|
pending.outNodeName = outIt->second;
|
|
pending.outPortName = outPort;
|
|
pending.inNodeName = inIt->second;
|
|
pending.inPortName = inPort;
|
|
m_pendingPasteLinks.push_back(pending);
|
|
}
|
|
|
|
m_model->refreshFromClient();
|
|
tryResolvePendingLinks();
|
|
}
|
|
|
|
void GraphEditorWidget::duplicateSelection() {
|
|
copySelection();
|
|
pasteSelection(QPointF(40, 40));
|
|
}
|
|
|
|
void GraphEditorWidget::tryResolvePendingLinks() {
|
|
if (m_pendingPasteLinks.empty() || !m_client) {
|
|
return;
|
|
}
|
|
|
|
auto nodesResult = m_client->ListNodes();
|
|
if (!nodesResult.ok()) {
|
|
return;
|
|
}
|
|
|
|
std::vector<PendingPasteLink> remaining;
|
|
|
|
for (const auto &pending : m_pendingPasteLinks) {
|
|
warppipe::PortId outPortId{0};
|
|
warppipe::PortId inPortId{0};
|
|
bool foundOut = false;
|
|
bool foundIn = false;
|
|
|
|
for (const auto &node : nodesResult.value) {
|
|
if (!foundOut && node.name == pending.outNodeName) {
|
|
auto portsResult = m_client->ListPorts(node.id);
|
|
if (portsResult.ok()) {
|
|
for (const auto &port : portsResult.value) {
|
|
if (!port.is_input && port.name == pending.outPortName) {
|
|
outPortId = port.id;
|
|
foundOut = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!foundIn && node.name == pending.inNodeName) {
|
|
auto portsResult = m_client->ListPorts(node.id);
|
|
if (portsResult.ok()) {
|
|
for (const auto &port : portsResult.value) {
|
|
if (port.is_input && port.name == pending.inPortName) {
|
|
inPortId = port.id;
|
|
foundIn = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (foundOut && foundIn) {
|
|
m_client->CreateLink(outPortId, inPortId, warppipe::LinkOptions{.linger = true});
|
|
} else {
|
|
remaining.push_back(pending);
|
|
}
|
|
}
|
|
|
|
m_pendingPasteLinks = remaining;
|
|
}
|
|
|
|
void GraphEditorWidget::saveLayoutWithViewState() {
|
|
WarpGraphModel::ViewState vs;
|
|
vs.scale = m_view->getScale();
|
|
QPointF center = m_view->mapToScene(m_view->viewport()->rect().center());
|
|
vs.centerX = center.x();
|
|
vs.centerY = center.y();
|
|
QList<int> sizes = m_splitter->sizes();
|
|
vs.splitterGraph = sizes.value(0, 1200);
|
|
vs.splitterSidebar = sizes.value(1, 320);
|
|
vs.valid = true;
|
|
m_model->saveLayout(m_layoutPath, vs);
|
|
}
|
|
|
|
void GraphEditorWidget::restoreViewState() {
|
|
auto vs = m_model->savedViewState();
|
|
if (vs.valid) {
|
|
m_view->setupScale(vs.scale);
|
|
m_view->centerOn(QPointF(vs.centerX, vs.centerY));
|
|
if (vs.splitterGraph > 0 || vs.splitterSidebar > 0) {
|
|
m_splitter->setSizes({vs.splitterGraph, vs.splitterSidebar});
|
|
}
|
|
} else {
|
|
m_view->zoomFitAll();
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::savePreset() {
|
|
QDir dir(m_presetDir);
|
|
if (!dir.exists())
|
|
dir.mkpath(".");
|
|
|
|
QString path = QFileDialog::getSaveFileName(
|
|
this, QStringLiteral("Save Preset"), m_presetDir,
|
|
QStringLiteral("JSON files (*.json)"));
|
|
if (path.isEmpty())
|
|
return;
|
|
if (!path.endsWith(QStringLiteral(".json"), Qt::CaseInsensitive))
|
|
path += QStringLiteral(".json");
|
|
|
|
if (PresetManager::savePreset(path, m_client, m_model)) {
|
|
if (auto *mw = qobject_cast<QMainWindow *>(window()))
|
|
mw->statusBar()->showMessage(
|
|
QStringLiteral("Preset saved: ") + QFileInfo(path).fileName(), 4000);
|
|
} else {
|
|
QMessageBox::warning(this, QStringLiteral("Error"),
|
|
QStringLiteral("Failed to save preset."));
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::loadPreset() {
|
|
QString path = QFileDialog::getOpenFileName(
|
|
this, QStringLiteral("Load Preset"), m_presetDir,
|
|
QStringLiteral("JSON files (*.json)"));
|
|
if (path.isEmpty())
|
|
return;
|
|
|
|
if (PresetManager::loadPreset(path, m_client, m_model)) {
|
|
if (auto *mw = qobject_cast<QMainWindow *>(window()))
|
|
mw->statusBar()->showMessage(
|
|
QStringLiteral("Preset loaded: ") + QFileInfo(path).fileName(), 4000);
|
|
} else {
|
|
QMessageBox::warning(this, QStringLiteral("Error"),
|
|
QStringLiteral("Failed to load preset."));
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
void GraphEditorWidget::updateMeters() {
|
|
if (!m_client)
|
|
return;
|
|
|
|
auto master = m_client->MeterPeak();
|
|
if (master.ok()) {
|
|
m_masterMeterL->setLevel(master.value.peak_left);
|
|
m_masterMeterR->setLevel(master.value.peak_right);
|
|
}
|
|
|
|
for (auto &[nodeId, row] : m_nodeMeters) {
|
|
const WarpNodeData *data = m_model->warpNodeData(nodeId);
|
|
if (!data || !row.meter)
|
|
continue;
|
|
auto peak = m_client->NodeMeterPeak(data->info.id);
|
|
if (peak.ok()) {
|
|
row.meter->setLevel(
|
|
std::max(peak.value.peak_left, peak.value.peak_right));
|
|
}
|
|
}
|
|
}
|
|
|
|
void GraphEditorWidget::rebuildNodeMeters() {
|
|
if (!m_nodeMeterContainer || !m_client)
|
|
return;
|
|
|
|
auto *layout = m_nodeMeterContainer->layout();
|
|
if (!layout)
|
|
return;
|
|
|
|
std::unordered_map<uint32_t, bool> old_pw_ids;
|
|
for (const auto &[nid, row] : m_nodeMeters) {
|
|
const WarpNodeData *d = m_model->warpNodeData(nid);
|
|
if (d)
|
|
old_pw_ids[d->info.id.value] = true;
|
|
}
|
|
|
|
while (layout->count() > 0) {
|
|
auto *item = layout->takeAt(0);
|
|
if (item->widget())
|
|
item->widget()->deleteLater();
|
|
delete item;
|
|
}
|
|
m_nodeMeters.clear();
|
|
|
|
auto nodeIds = m_model->allNodeIds();
|
|
std::vector<QtNodes::NodeId> sorted(nodeIds.begin(), nodeIds.end());
|
|
std::sort(sorted.begin(), sorted.end());
|
|
|
|
std::unordered_map<uint32_t, bool> new_pw_ids;
|
|
for (auto nodeId : sorted) {
|
|
const WarpNodeData *data = m_model->warpNodeData(nodeId);
|
|
if (!data)
|
|
continue;
|
|
|
|
new_pw_ids[data->info.id.value] = true;
|
|
m_client->EnsureNodeMeter(data->info.id);
|
|
|
|
auto *row = new QWidget();
|
|
auto *rowLayout = new QHBoxLayout(row);
|
|
rowLayout->setContentsMargins(0, 0, 0, 0);
|
|
rowLayout->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->setStyleSheet(QStringLiteral(
|
|
"QLabel { color: #a0a8b6; font-size: 11px; background: transparent; }"));
|
|
label->setToolTip(QString::fromStdString(data->info.name));
|
|
|
|
auto *meter = new AudioLevelMeter();
|
|
meter->setFixedWidth(26);
|
|
meter->setMinimumHeight(70);
|
|
|
|
rowLayout->addWidget(label, 1);
|
|
rowLayout->addWidget(meter);
|
|
|
|
layout->addWidget(row);
|
|
|
|
NodeMeterRow meterRow;
|
|
meterRow.widget = row;
|
|
meterRow.meter = meter;
|
|
meterRow.label = label;
|
|
m_nodeMeters[nodeId] = meterRow;
|
|
}
|
|
|
|
static_cast<QVBoxLayout *>(layout)->addStretch();
|
|
|
|
for (const auto &[pw_id, _] : old_pw_ids) {
|
|
if (new_pw_ids.find(pw_id) == new_pw_ids.end()) {
|
|
m_client->DisableNodeMeter(warppipe::NodeId{pw_id});
|
|
}
|
|
}
|
|
}
|