Node editor

This commit is contained in:
Joey Yakimowich-Payne 2026-02-06 08:01:27 -07:00
commit 21cd3bd3f9
4 changed files with 479 additions and 18 deletions

View file

@ -15,6 +15,7 @@
#include <QtNodes/internal/UndoCommands.hpp>
#include <QAction>
#include <QCheckBox>
#include <QClipboard>
#include <QContextMenuEvent>
#include <QComboBox>
@ -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<QtNodes::NodeGraphicsObject *>(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<QMainWindow *>(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);
}

View file

@ -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;
};