From 21cd3bd3f995ccf88759d5706f5acf9951da87f7 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 6 Feb 2026 08:01:27 -0700 Subject: [PATCH] Node editor --- gui/GraphEditorWidget.cpp | 460 ++++++++++++++++++++++++++++++++-- gui/GraphEditorWidget.h | 7 + include/warppipe/warppipe.hpp | 11 + src/warppipe.cpp | 19 ++ 4 files changed, 479 insertions(+), 18 deletions(-) diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 1ebdb84..98efd8b 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -420,6 +421,25 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, m_rulesScroll->setWidget(m_rulesContainer); m_sidebar->addTab(m_rulesScroll, QStringLiteral("RULES")); + m_nodeDetailsScroll = new QScrollArea(); + m_nodeDetailsScroll->setWidgetResizable(true); + m_nodeDetailsScroll->setStyleSheet(m_mixerScroll->styleSheet()); + m_nodeDetailsContainer = new QWidget(); + m_nodeDetailsContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;")); + auto *nodeDetailsLayout = new QVBoxLayout(m_nodeDetailsContainer); + nodeDetailsLayout->setContentsMargins(8, 8, 8, 8); + nodeDetailsLayout->setSpacing(6); + auto *noSelectionLabel = new QLabel(QStringLiteral("Select a node to view details")); + noSelectionLabel->setStyleSheet(QStringLiteral( + "color: #6a6a7a; font-style: italic; background: transparent;")); + noSelectionLabel->setAlignment(Qt::AlignCenter); + noSelectionLabel->setWordWrap(true); + nodeDetailsLayout->addStretch(); + nodeDetailsLayout->addWidget(noSelectionLabel); + nodeDetailsLayout->addStretch(); + m_nodeDetailsScroll->setWidget(m_nodeDetailsContainer); + m_sidebar->addTab(m_nodeDetailsScroll, QStringLiteral("NODE")); + m_splitter = new QSplitter(Qt::Horizontal); m_splitter->addWidget(m_view); m_splitter->addWidget(m_sidebar); @@ -549,8 +569,15 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, rebuildMixerStrips(); rebuildNodeMeters(); rebuildRulesList(); + if (nodeId == m_selectedNodeId) { + m_selectedNodeId = 0; + clearNodeDetailsPanel(); + } }); + connect(m_scene, &QGraphicsScene::selectionChanged, this, + &GraphEditorWidget::onSelectionChanged); + m_saveTimer = new QTimer(this); m_saveTimer->setSingleShot(true); m_saveTimer->setInterval(1000); @@ -884,34 +911,113 @@ void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos, 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()) { + if (isSink) { + bool ok = false; + QString name = QInputDialog::getText( + this, QStringLiteral("Create Virtual Sink"), + QStringLiteral("Node name:"), QLineEdit::Normal, QString(), &ok); + if (!ok || name.trimmed().isEmpty()) + return; + + std::string nodeName = name.trimmed().toStdString(); + m_model->setPendingPosition(nodeName, scenePos); + auto result = m_client->CreateVirtualSink(nodeName); + if (!result.status.ok()) { + QMessageBox::warning(this, QStringLiteral("Error"), + QString::fromStdString(result.status.message)); + return; + } + m_model->refreshFromClient(); return; } - std::string nodeName = name.trimmed().toStdString(); + static const QString kDialogStyle = QStringLiteral( + "QDialog { background: #1e1e22; }" + "QLabel { color: #ecf0f6; }" + "QLineEdit { background: #2a2a32; color: #ecf0f6; border: 1px solid #3a3a44;" + " border-radius: 4px; padding: 4px 8px; }" + "QCheckBox { color: #ecf0f6; }" + "QCheckBox::indicator { width: 16px; height: 16px; }" + "QComboBox { background: #2a2a32; color: #ecf0f6; border: 1px solid #3a3a44;" + " border-radius: 4px; padding: 4px 8px; }" + "QComboBox::drop-down { border: none; }" + "QComboBox QAbstractItemView { background: #2a2a32; color: #ecf0f6;" + " selection-background-color: #3a3a44; }"); + + static const QString kButtonStyle = QStringLiteral( + "QPushButton { background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;" + " border-radius: 4px; padding: 6px 16px; }" + "QPushButton:hover { background: #3a3a44; }"); + + QDialog dlg(this); + dlg.setWindowTitle(QStringLiteral("Create Virtual Source")); + dlg.setStyleSheet(kDialogStyle); + + auto *form = new QFormLayout(&dlg); + form->setContentsMargins(16, 16, 16, 16); + form->setSpacing(8); + + auto *nameEdit = new QLineEdit(); + nameEdit->setPlaceholderText(QStringLiteral("e.g. Desktop Audio")); + form->addRow(QStringLiteral("Name:"), nameEdit); + + auto *loopbackCheck = new QCheckBox(QStringLiteral("Loopback from another node")); + form->addRow(loopbackCheck); + + auto *targetCombo = new QComboBox(); + targetCombo->setEnabled(false); + auto nodesResult = m_client->ListNodes(); + if (nodesResult.ok()) { + for (const auto &node : nodesResult.value) { + if (node.media_class.find("Sink") != std::string::npos || + node.media_class.find("Source") != std::string::npos) { + QString label = QString::fromStdString( + node.description.empty() ? node.name : node.description); + targetCombo->addItem(label, QString::fromStdString(node.name)); + } + } + } + auto *targetLabel = new QLabel(QStringLiteral("Target Node:")); + targetLabel->setEnabled(false); + form->addRow(targetLabel, targetCombo); + + connect(loopbackCheck, &QCheckBox::toggled, this, [=](bool checked) { + targetCombo->setEnabled(checked); + targetLabel->setEnabled(checked); + }); + + auto *buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + buttons->setStyleSheet(kButtonStyle); + connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); + form->addRow(buttons); + + if (dlg.exec() != QDialog::Accepted) + return; + + QString name = nameEdit->text().trimmed(); + if (name.isEmpty()) { + QMessageBox::warning(this, QStringLiteral("Error"), + QStringLiteral("Name cannot be empty.")); + return; + } + + std::string nodeName = name.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; + warppipe::VirtualNodeOptions opts; + if (loopbackCheck->isChecked()) { + opts.behavior = warppipe::VirtualBehavior::kLoopback; + opts.target_node = targetCombo->currentData().toString().toStdString(); } - if (!status.ok()) { + auto result = m_client->CreateVirtualSource(nodeName, opts); + if (!result.status.ok()) { QMessageBox::warning(this, QStringLiteral("Error"), - QString::fromStdString(status.message)); + QString::fromStdString(result.status.message)); return; } - m_model->refreshFromClient(); } @@ -1814,3 +1920,321 @@ void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp, m_client->AddRouteRule(rule); rebuildRulesList(); } + +void GraphEditorWidget::onSelectionChanged() { + auto items = m_scene->selectedItems(); + + QtNodes::NodeId selected = 0; + for (QGraphicsItem *item : items) { + if (auto *ngo = + qgraphicsitem_cast(item)) { + selected = ngo->nodeId(); + break; + } + } + + if (selected == m_selectedNodeId) + return; + + if (selected != 0) { + m_selectedNodeId = selected; + updateNodeDetailsPanel(selected); + m_sidebar->setCurrentWidget(m_nodeDetailsScroll); + } else { + m_selectedNodeId = 0; + clearNodeDetailsPanel(); + } +} + +void GraphEditorWidget::clearNodeDetailsPanel() { + delete m_nodeDetailsScroll->takeWidget(); + m_nodeDetailsContainer = new QWidget(); + m_nodeDetailsContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;")); + auto *layout = new QVBoxLayout(m_nodeDetailsContainer); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(6); + auto *label = new QLabel(QStringLiteral("Select a node to view details")); + label->setStyleSheet(QStringLiteral( + "color: #6a6a7a; font-style: italic; background: transparent;")); + label->setAlignment(Qt::AlignCenter); + label->setWordWrap(true); + layout->addStretch(); + layout->addWidget(label); + layout->addStretch(); + m_nodeDetailsScroll->setWidget(m_nodeDetailsContainer); +} + +static QLabel *makeDetailHeader(const QString &text) { + auto *label = new QLabel(text); + label->setStyleSheet(QStringLiteral( + "QLabel { color: #a0a8b6; font-size: 11px; font-weight: bold;" + " background: transparent; }")); + return label; +} + +static QLabel *makeDetailValue(const QString &text) { + auto *label = new QLabel(text); + label->setStyleSheet(QStringLiteral( + "QLabel { color: #ecf0f6; font-size: 12px; background: transparent; }")); + label->setWordWrap(true); + label->setTextInteractionFlags(Qt::TextSelectableByMouse); + return label; +} + +void GraphEditorWidget::updateNodeDetailsPanel(QtNodes::NodeId nodeId) { + const WarpNodeData *data = m_model->warpNodeData(nodeId); + if (!data) + return; + + const auto &info = data->info; + WarpNodeType type = WarpGraphModel::classifyNode(info); + bool isVirtual = type == WarpNodeType::kVirtualSink || + type == WarpNodeType::kVirtualSource; + + delete m_nodeDetailsScroll->takeWidget(); + m_nodeDetailsContainer = new QWidget(); + m_nodeDetailsContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;")); + auto *layout = new QVBoxLayout(m_nodeDetailsContainer); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(4); + + auto addField = [&](const QString &header, const QString &value) { + if (value.isEmpty()) + return; + layout->addSpacing(4); + layout->addWidget(makeDetailHeader(header)); + layout->addWidget(makeDetailValue(value)); + }; + + QString typeLabel; + switch (type) { + case WarpNodeType::kHardwareSink: typeLabel = QStringLiteral("Hardware Sink"); break; + case WarpNodeType::kHardwareSource: typeLabel = QStringLiteral("Hardware Source"); break; + case WarpNodeType::kVirtualSink: typeLabel = QStringLiteral("Virtual Sink"); break; + case WarpNodeType::kVirtualSource: typeLabel = QStringLiteral("Virtual Source"); break; + case WarpNodeType::kApplication: typeLabel = QStringLiteral("Application"); break; + case WarpNodeType::kVideoSource: typeLabel = QStringLiteral("Video Source"); break; + case WarpNodeType::kVideoSink: typeLabel = QStringLiteral("Video Sink"); break; + default: typeLabel = QStringLiteral("Unknown"); break; + } + + auto *titleLabel = new QLabel(QString::fromStdString( + info.description.empty() ? info.name : info.description)); + titleLabel->setStyleSheet(QStringLiteral( + "QLabel { color: #ecf0f6; font-size: 14px; font-weight: bold;" + " background: transparent; }")); + titleLabel->setWordWrap(true); + layout->addWidget(titleLabel); + layout->addSpacing(2); + + auto *typeBadge = new QLabel(typeLabel); + typeBadge->setStyleSheet(QStringLiteral( + "QLabel { color: #1a1a1e; background: %1; font-size: 10px;" + " font-weight: bold; border-radius: 3px; padding: 2px 6px; }") + .arg(isVirtual ? QStringLiteral("#4caf50") + : QStringLiteral("#6a7a8a"))); + typeBadge->setFixedHeight(18); + typeBadge->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Fixed); + layout->addWidget(typeBadge); + + static const QString kEditStyle = QStringLiteral( + "QLineEdit { background: #2a2a32; color: #ecf0f6;" + " border: 1px solid #3a3a44; border-radius: 4px; padding: 4px 8px; }"); + static const QString kComboStyle = QStringLiteral( + "QComboBox { background: #2a2a32; color: #ecf0f6;" + " border: 1px solid #3a3a44; border-radius: 4px; padding: 4px 8px; }" + "QComboBox::drop-down { border: none; }" + "QComboBox QAbstractItemView { background: #2a2a32; color: #ecf0f6;" + " selection-background-color: #3a3a44; }"); + static const QString kCheckStyle = QStringLiteral( + "QCheckBox { color: #ecf0f6; background: transparent; }" + "QCheckBox::indicator { width: 16px; height: 16px; }"); + static const QString kBtnStyle = QStringLiteral( + "QPushButton { background: #2e2e36; color: #ecf0f6;" + " border: 1px solid #3a3a44; border-radius: 4px;" + " padding: 6px 16px; font-weight: bold; }" + "QPushButton:hover { background: #3a3a44; }"); + static const QString kDeleteBtnStyle = QStringLiteral( + "QPushButton { background: #b03030; color: #ecf0f6;" + " border: 1px solid #d04040; border-radius: 4px;" + " padding: 6px 16px; font-weight: bold; }" + "QPushButton:hover { background: #c04040; }"); + + if (isVirtual) { + bool isSource = type == WarpNodeType::kVirtualSource; + auto vnResult = m_client->GetVirtualNodeInfo(info.id); + + layout->addSpacing(4); + layout->addWidget(makeDetailHeader(QStringLiteral("NAME"))); + auto *nameEdit = new QLineEdit(QString::fromStdString(info.name)); + nameEdit->setStyleSheet(kEditStyle); + layout->addWidget(nameEdit); + + QCheckBox *loopbackCheck = nullptr; + QComboBox *targetCombo = nullptr; + + if (isSource) { + layout->addSpacing(8); + loopbackCheck = new QCheckBox(QStringLiteral("Loopback from another node")); + loopbackCheck->setStyleSheet(kCheckStyle); + layout->addWidget(loopbackCheck); + + layout->addSpacing(4); + layout->addWidget(makeDetailHeader(QStringLiteral("TARGET NODE"))); + targetCombo = new QComboBox(); + targetCombo->setStyleSheet(kComboStyle); + targetCombo->setEnabled(false); + auto nodesResult = m_client->ListNodes(); + if (nodesResult.ok()) { + for (const auto &node : nodesResult.value) { + if (node.id.value == info.id.value) + continue; + if (node.media_class.find("Sink") != std::string::npos || + node.media_class.find("Source") != std::string::npos) { + QString label = QString::fromStdString( + node.description.empty() ? node.name : node.description); + targetCombo->addItem(label, QString::fromStdString(node.name)); + } + } + } + layout->addWidget(targetCombo); + + if (vnResult.ok() && vnResult.value.loopback) { + loopbackCheck->setChecked(true); + int idx = targetCombo->findData( + QString::fromStdString(vnResult.value.target_node)); + if (idx >= 0) + targetCombo->setCurrentIndex(idx); + } + + connect(loopbackCheck, &QCheckBox::toggled, targetCombo, + &QWidget::setEnabled); + } + + layout->addSpacing(12); + auto *applyBtn = new QPushButton(QStringLiteral("Apply Changes")); + applyBtn->setStyleSheet(kBtnStyle); + connect(applyBtn, &QPushButton::clicked, this, + [this, nodeId, nameEdit, loopbackCheck, targetCombo, isSource]() { + const WarpNodeData *d = m_model->warpNodeData(nodeId); + if (!d) return; + + QString newName = nameEdit->text().trimmed(); + if (newName.isEmpty()) { + QMessageBox::warning(this, QStringLiteral("Error"), + QStringLiteral("Name cannot be empty.")); + return; + } + + QPointF pos = m_model->nodeData(nodeId, + QtNodes::NodeRole::Position).toPointF(); + + auto removeStatus = m_client->RemoveNode(d->info.id); + if (!removeStatus.ok()) { + QMessageBox::warning(this, QStringLiteral("Error"), + QString::fromStdString(removeStatus.message)); + return; + } + + std::string nodeName = newName.toStdString(); + m_model->setPendingPosition(nodeName, pos); + + warppipe::Status status; + if (isSource) { + warppipe::VirtualNodeOptions opts; + if (loopbackCheck && loopbackCheck->isChecked() && targetCombo) { + opts.behavior = warppipe::VirtualBehavior::kLoopback; + opts.target_node = + targetCombo->currentData().toString().toStdString(); + } + auto result = m_client->CreateVirtualSource(nodeName, opts); + status = result.status; + } else { + auto result = m_client->CreateVirtualSink(nodeName); + status = result.status; + } + + if (!status.ok()) { + QMessageBox::warning(this, QStringLiteral("Error"), + QString::fromStdString(status.message)); + m_model->refreshFromClient(); + return; + } + + m_selectedNodeId = 0; + m_model->refreshFromClient(); + + for (auto nid : m_model->allNodeIds()) { + const WarpNodeData *nd = m_model->warpNodeData(nid); + if (nd && nd->info.name == nodeName) { + m_selectedNodeId = nid; + updateNodeDetailsPanel(nid); + break; + } + } + + if (auto *mw = qobject_cast(window())) + mw->statusBar()->showMessage( + QStringLiteral("Virtual node updated"), 4000); + }); + layout->addWidget(applyBtn); + + layout->addSpacing(8); + auto *deleteBtn = new QPushButton(QStringLiteral("Delete Node")); + deleteBtn->setStyleSheet(kDeleteBtnStyle); + connect(deleteBtn, &QPushButton::clicked, this, [this, nodeId]() { + const WarpNodeData *d = m_model->warpNodeData(nodeId); + if (!d) return; + auto status = m_client->RemoveNode(d->info.id); + if (status.ok()) { + m_model->refreshFromClient(); + clearNodeDetailsPanel(); + m_selectedNodeId = 0; + } else { + QMessageBox::warning(this, QStringLiteral("Error"), + QString::fromStdString(status.message)); + } + }); + layout->addWidget(deleteBtn); + } else { + addField(QStringLiteral("NAME"), QString::fromStdString(info.name)); + if (!info.description.empty() && info.description != info.name) + addField(QStringLiteral("DESCRIPTION"), + QString::fromStdString(info.description)); + addField(QStringLiteral("MEDIA CLASS"), + QString::fromStdString(info.media_class)); + if (!info.application_name.empty()) + addField(QStringLiteral("APPLICATION"), + QString::fromStdString(info.application_name)); + if (!info.process_binary.empty()) + addField(QStringLiteral("PROCESS"), + QString::fromStdString(info.process_binary)); + if (!info.media_role.empty()) + addField(QStringLiteral("MEDIA ROLE"), + QString::fromStdString(info.media_role)); + } + + addField(QStringLiteral("NODE ID"), + QString::number(info.id.value)); + + if (!data->inputPorts.empty()) { + layout->addSpacing(8); + layout->addWidget(makeDetailHeader(QStringLiteral("INPUT PORTS"))); + for (const auto &port : data->inputPorts) { + layout->addWidget(makeDetailValue( + QStringLiteral(" ") + QString::fromStdString(port.name))); + } + } + + if (!data->outputPorts.empty()) { + layout->addSpacing(8); + layout->addWidget(makeDetailHeader(QStringLiteral("OUTPUT PORTS"))); + for (const auto &port : data->outputPorts) { + layout->addWidget(makeDetailValue( + QStringLiteral(" ") + QString::fromStdString(port.name))); + } + } + + layout->addStretch(); + m_nodeDetailsScroll->setWidget(m_nodeDetailsContainer); +} diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index 65921ad..52dc491 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -87,6 +87,9 @@ private: const std::string &prefillTarget = {}, warppipe::RuleId editRuleId = {}); void setConnectionStyle(ConnectionStyleType style); + void onSelectionChanged(); + void updateNodeDetailsPanel(QtNodes::NodeId nodeId); + void clearNodeDetailsPanel(); struct PendingPasteLink { std::string outNodeName; @@ -138,4 +141,8 @@ private: QLabel *m_zoomMinValue = nullptr; QSlider *m_zoomMaxSlider = nullptr; QLabel *m_zoomMaxValue = nullptr; + + QWidget *m_nodeDetailsContainer = nullptr; + QScrollArea *m_nodeDetailsScroll = nullptr; + QtNodes::NodeId m_selectedNodeId = 0; }; diff --git a/include/warppipe/warppipe.hpp b/include/warppipe/warppipe.hpp index e04046e..a01a0e6 100644 --- a/include/warppipe/warppipe.hpp +++ b/include/warppipe/warppipe.hpp @@ -138,6 +138,16 @@ struct RouteRule { std::string target_node; }; +struct VirtualNodeInfo { + NodeId node; + std::string name; + bool is_source = false; + bool loopback = false; + std::string target_node; + uint32_t rate = 48000; + uint32_t channels = 2; +}; + struct VolumeState { float volume = 1.0f; bool mute = false; @@ -176,6 +186,7 @@ class Client { Result CreateVirtualSource(std::string_view name, const VirtualNodeOptions& options = VirtualNodeOptions{}); Status RemoveNode(NodeId node); + Result GetVirtualNodeInfo(NodeId node) const; Status SetNodeVolume(NodeId node, float volume, bool mute); Result GetNodeVolume(NodeId node) const; diff --git a/src/warppipe.cpp b/src/warppipe.cpp index ec57de8..2b687e7 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -2140,9 +2140,28 @@ Status Client::DisableNodeMeter(NodeId node) { meter->stream = nullptr; pw_thread_loop_unlock(impl_->thread_loop); } + impl_->AutoSave(); return Status::Ok(); } +Result Client::GetVirtualNodeInfo(NodeId node) const { + std::lock_guard lock(impl_->cache_mutex); + auto it = impl_->virtual_streams.find(node.value); + if (it == impl_->virtual_streams.end()) + return {Status::Error(StatusCode::kNotFound, "not a virtual node"), {}}; + const auto &sd = *it->second; + VirtualNodeInfo info; + info.node = node; + info.name = sd.name; + info.is_source = sd.is_source; + info.loopback = sd.loopback; + info.target_node = sd.target_node; + info.rate = sd.rate; + info.channels = sd.channels; + return {Status::Ok(), std::move(info)}; +} + + Result Client::NodeMeterPeak(NodeId node) const { std::lock_guard lock(impl_->cache_mutex); auto live_it = impl_->live_meters.find(node.value);