Compare commits

..

4 commits

Author SHA1 Message Date
0dbd10b5e3 Fix arrange when same node title 2026-02-06 09:24:10 -07:00
750868c63f Stuff 2026-02-06 09:15:25 -07:00
9ac56d0d0b Fix node details 2026-02-06 08:41:55 -07:00
21cd3bd3f9 Node editor 2026-02-06 08:01:27 -07:00
7 changed files with 749 additions and 55 deletions

View file

@ -15,6 +15,7 @@
#include <QtNodes/internal/UndoCommands.hpp>
#include <QAction>
#include <QCheckBox>
#include <QClipboard>
#include <QContextMenuEvent>
#include <QComboBox>
@ -181,6 +182,13 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
m_scene = new QtNodes::BasicGraphicsScene(*m_model, this);
m_scene->setItemIndexMethod(QGraphicsScene::BspTreeIndex);
connect(m_model, &WarpGraphModel::beginBatchUpdate, this, [this]() {
m_scene->setItemIndexMethod(QGraphicsScene::NoIndex);
});
connect(m_model, &WarpGraphModel::endBatchUpdate, this, [this]() {
m_scene->setItemIndexMethod(QGraphicsScene::BspTreeIndex);
});
QtNodes::ConnectionStyle::setConnectionStyle(
R"({"ConnectionStyle": {
"ConstructionColor": "#b4b4c8",
@ -420,6 +428,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 +576,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);
@ -617,6 +651,10 @@ GraphEditorWidget::~GraphEditorWidget() {
if (m_client) {
m_client->SetChangeCallback(nullptr);
}
m_meterTimer->stop();
m_refreshTimer->stop();
m_changeTimer->stop();
m_saveTimer->stop();
}
int GraphEditorWidget::nodeCount() const {
@ -854,6 +892,9 @@ void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos,
createRuleAction = menu.addAction(QStringLiteral("Create Rule..."));
}
menu.addSeparator();
QAction *detailsAction = menu.addAction(QStringLiteral("Node Details"));
menu.addSeparator();
QAction *pasteAction = menu.addAction(QStringLiteral("Paste"));
pasteAction->setShortcut(QKeySequence::Paste);
@ -873,6 +914,10 @@ void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos,
duplicateSelection();
} else if (chosen == deleteAction && m_client) {
deleteSelection();
} else if (chosen == detailsAction) {
m_selectedNodeId = qtNodeId;
updateNodeDetailsPanel(qtNodeId);
m_sidebar->setCurrentWidget(m_nodeDetailsScroll);
} else if (chosen == pasteAction) {
pasteSelection(QPointF(0, 0));
} else if (chosen == createRuleAction) {
@ -884,34 +929,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 +1938,319 @@ 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;
m_selectedNodeId = selected;
if (selected != 0 && m_sidebar->currentWidget() == m_nodeDetailsScroll) {
updateNodeDetailsPanel(selected);
} else if (selected == 0 && m_sidebar->currentWidget() == m_nodeDetailsScroll) {
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);
connect(loopbackCheck, &QCheckBox::toggled, targetCombo,
&QWidget::setEnabled);
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);
}
}
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;
};

View file

@ -208,8 +208,8 @@ QVariant WarpGraphModel::nodeData(QtNodes::NodeId nodeId,
}
case QtNodes::NodeRole::Widget: {
auto wIt = m_volumeWidgets.find(nodeId);
if (wIt != m_volumeWidgets.end())
return QVariant::fromValue(wIt->second);
if (wIt != m_volumeWidgets.end() && wIt->second)
return QVariant::fromValue(wIt->second.data());
return QVariant::fromValue(static_cast<QWidget *>(nullptr));
}
default:
@ -329,11 +329,7 @@ bool WarpGraphModel::deleteNode(QtNodes::NodeId const nodeId) {
m_sizes.erase(nodeId);
m_volumeStates.erase(nodeId);
m_styleCache.erase(nodeId);
auto vwIt = m_volumeWidgets.find(nodeId);
if (vwIt != m_volumeWidgets.end()) {
delete vwIt->second;
m_volumeWidgets.erase(vwIt);
}
m_volumeWidgets.erase(nodeId);
Q_EMIT nodeDeleted(nodeId);
return true;
}
@ -356,9 +352,12 @@ void WarpGraphModel::refreshFromClient() {
return;
}
Q_EMIT beginBatchUpdate();
m_refreshing = true;
auto nodesResult = m_client->ListNodes();
if (!nodesResult.ok()) {
m_refreshing = false;
Q_EMIT endBatchUpdate();
return;
}
@ -489,18 +488,23 @@ void WarpGraphModel::refreshFromClient() {
m_pwToQt.emplace(nodeInfo.id.value, qtId);
auto pendingIt = m_pendingPositions.find(nodeInfo.name);
if (pendingIt != m_pendingPositions.end()) {
m_positions.emplace(qtId, pendingIt->second);
m_pendingPositions.erase(pendingIt);
} else {
auto savedIt = m_savedPositions.find(nodeInfo.name);
if (savedIt != m_savedPositions.end()) {
m_positions.emplace(qtId, savedIt->second);
if (pendingIt != m_pendingPositions.end()) {
m_positions.emplace(qtId, pendingIt->second);
m_pendingPositions.erase(pendingIt);
} else {
QPointF candidate = nextPosition(nodeIt->second);
m_positions.emplace(qtId, findNonOverlappingPosition(candidate, nodeIt->second));
auto groupPos = findAppGroupPosition(nodeIt->second);
if (groupPos) {
m_positions.emplace(qtId, findNonOverlappingPosition(*groupPos, nodeIt->second));
} else {
auto savedIt = m_savedPositions.find(nodeInfo.name);
if (savedIt != m_savedPositions.end()) {
m_positions.emplace(qtId, savedIt->second);
} else {
QPointF candidate = nextPosition(nodeIt->second);
m_positions.emplace(qtId, findNonOverlappingPosition(candidate, nodeIt->second));
}
}
}
}
if (nodeHasVolume(nodeType)) {
auto *volumeWidget = new NodeVolumeWidget();
@ -680,8 +684,8 @@ void WarpGraphModel::refreshFromClient() {
cached.mute = mute;
auto wIt = m_volumeWidgets.find(qtId);
if (wIt != m_volumeWidgets.end()) {
auto *vw = static_cast<NodeVolumeWidget *>(wIt->second);
if (wIt != m_volumeWidgets.end() && wIt->second) {
auto *vw = static_cast<NodeVolumeWidget *>(wIt->second.data());
if (!vw->isSliderDown()) {
vw->setVolume(sliderVal);
vw->setMuted(mute);
@ -692,6 +696,7 @@ void WarpGraphModel::refreshFromClient() {
}
m_refreshing = false;
Q_EMIT endBatchUpdate();
}
const WarpNodeData *
@ -763,8 +768,61 @@ QPointF WarpGraphModel::nextPosition(const WarpNodeData &data) {
return pos;
}
std::string WarpGraphModel::appGroupKey(const warppipe::NodeInfo &info) {
if (!info.application_name.empty())
return info.application_name;
if (!info.process_binary.empty())
return info.process_binary;
return {};
}
std::optional<QPointF> WarpGraphModel::findAppGroupPosition(const WarpNodeData &data) const {
WarpNodeType type = classifyNode(data.info);
if (type != WarpNodeType::kApplication)
return std::nullopt;
std::string key = appGroupKey(data.info);
if (key.empty())
return std::nullopt;
double lowestBottom = -1.0;
QPointF siblingPos;
bool found = false;
for (const auto &[existingId, existingData] : m_nodes) {
if (classifyNode(existingData.info) != WarpNodeType::kApplication)
continue;
if (appGroupKey(existingData.info) != key)
continue;
auto posIt = m_positions.find(existingId);
if (posIt == m_positions.end())
continue;
QSizeF existingSize;
auto sizeIt = m_sizes.find(existingId);
if (sizeIt != m_sizes.end()) {
existingSize = QSizeF(sizeIt->second);
} else {
existingSize = QSizeF(estimateNodeSize(existingData));
}
double bottom = posIt->second.y() + existingSize.height();
if (bottom > lowestBottom) {
lowestBottom = bottom;
siblingPos = posIt->second;
found = true;
}
}
if (!found)
return std::nullopt;
return QPointF(siblingPos.x(), lowestBottom + kVerticalGap);
}
QPointF WarpGraphModel::findNonOverlappingPosition(QPointF candidate,
const WarpNodeData &data) const {
const WarpNodeData &data) const {
QSizeF newSize(estimateNodeSize(data));
constexpr int kMaxAttempts = 50;
@ -801,6 +859,22 @@ bool WarpGraphModel::isGhost(QtNodes::NodeId nodeId) const {
return m_ghostNodes.find(nodeId) != m_ghostNodes.end();
}
bool WarpGraphModel::ghostConnectionExists(
QtNodes::ConnectionId connectionId) const {
return m_ghostConnections.find(connectionId) != m_ghostConnections.end();
}
std::unordered_set<QtNodes::ConnectionId>
WarpGraphModel::allGhostConnectionIds(QtNodes::NodeId nodeId) const {
std::unordered_set<QtNodes::ConnectionId> result;
for (const auto &conn : m_ghostConnections) {
if (conn.outNodeId == nodeId || conn.inNodeId == nodeId) {
result.insert(conn);
}
}
return result;
}
uint32_t WarpGraphModel::findPwNodeIdByName(const std::string &name) const {
for (const auto &[qtId, data] : m_nodes) {
if (data.info.name == name) {
@ -855,8 +929,8 @@ void WarpGraphModel::setNodeVolumeState(QtNodes::NodeId nodeId,
}
auto wIt = m_volumeWidgets.find(nodeId);
if (wIt != m_volumeWidgets.end()) {
auto *w = qobject_cast<NodeVolumeWidget *>(wIt->second);
if (wIt != m_volumeWidgets.end() && wIt->second) {
auto *w = qobject_cast<NodeVolumeWidget *>(wIt->second.data());
if (w) {
w->setVolume(volumeToSlider(state.volume));
w->setMuted(state.mute);
@ -1175,6 +1249,19 @@ void WarpGraphModel::autoArrange() {
}
}
std::sort(apps.ids.begin(), apps.ids.end(),
[this](QtNodes::NodeId a, QtNodes::NodeId b) {
auto itA = m_nodes.find(a);
auto itB = m_nodes.find(b);
if (itA == m_nodes.end() || itB == m_nodes.end())
return a < b;
std::string keyA = appGroupKey(itA->second.info);
std::string keyB = appGroupKey(itB->second.info);
if (keyA != keyB)
return keyA < keyB;
return a < b;
});
auto layoutColumn = [&](Column &col, double xOffset) {
double y = 0.0;
for (QtNodes::NodeId id : col.ids) {

View file

@ -8,8 +8,10 @@
#include <QHash>
#include <QPointF>
#include <QSize>
#include <QPointer>
#include <QString>
#include <optional>
#include <unordered_map>
#include <unordered_set>
@ -77,6 +79,9 @@ public:
const WarpNodeData *warpNodeData(QtNodes::NodeId nodeId) const;
QtNodes::NodeId qtNodeIdForPw(uint32_t pwNodeId) const;
bool isGhost(QtNodes::NodeId nodeId) const;
bool ghostConnectionExists(QtNodes::ConnectionId connectionId) const;
std::unordered_set<QtNodes::ConnectionId> allGhostConnectionIds(
QtNodes::NodeId nodeId) const;
void setPendingPosition(const std::string &nodeName, QPointF pos);
static WarpNodeType classifyNode(const warppipe::NodeInfo &info);
@ -91,6 +96,8 @@ public:
NodeVolumeState nodeVolumeState(QtNodes::NodeId nodeId) const;
Q_SIGNALS:
void beginBatchUpdate();
void endBatchUpdate();
void nodeVolumeChanged(QtNodes::NodeId nodeId, NodeVolumeState previous,
NodeVolumeState current);
@ -120,6 +127,8 @@ private:
static QVariant styleForNode(WarpNodeType type, bool ghost);
QPointF nextPosition(const WarpNodeData &data);
QPointF findNonOverlappingPosition(QPointF candidate, const WarpNodeData &data) const;
std::optional<QPointF> findAppGroupPosition(const WarpNodeData &data) const;
static std::string appGroupKey(const warppipe::NodeInfo &info);
static QSize estimateNodeSize(const WarpNodeData &data);
warppipe::Client *m_client = nullptr;
@ -158,6 +167,6 @@ private:
ViewState m_savedViewState{};
std::unordered_map<QtNodes::NodeId, NodeVolumeState> m_volumeStates;
std::unordered_map<QtNodes::NodeId, QWidget *> m_volumeWidgets;
std::unordered_map<QtNodes::NodeId, QPointer<QWidget>> m_volumeWidgets;
mutable std::unordered_map<QtNodes::NodeId, QVariant> m_styleCache;
};

View file

@ -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<VirtualSource> CreateVirtualSource(std::string_view name,
const VirtualNodeOptions& options = VirtualNodeOptions{});
Status RemoveNode(NodeId node);
Result<VirtualNodeInfo> GetVirtualNodeInfo(NodeId node) const;
Status SetNodeVolume(NodeId node, float volume, bool mute);
Result<VolumeState> GetNodeVolume(NodeId node) const;

View file

@ -10,6 +10,7 @@
#include <unordered_set>
#include <utility>
#include <pipewire/impl-module.h>
#include <pipewire/keys.h>
#include <pipewire/link.h>
#include <pipewire/pipewire.h>
@ -104,6 +105,7 @@ bool MatchesRule(const NodeInfo& node, const RuleMatch& match) {
struct StreamData {
pw_stream* stream = nullptr;
pw_impl_module* module = nullptr;
spa_hook listener{};
pw_thread_loop* loop = nullptr;
bool is_source = false;
@ -349,6 +351,8 @@ struct Client::Impl {
std::unordered_map<uint32_t, std::unique_ptr<NodeProxyData>> node_proxies;
std::unordered_map<uint32_t, uint32_t> node_channel_counts;
std::unordered_set<uint32_t> loopback_internal_nodes;
std::unordered_map<uint32_t, MeterState> meter_states;
std::unordered_set<uint32_t> metered_nodes;
MeterState master_meter;
@ -789,6 +793,7 @@ void Client::Impl::ClearCache() {
nodes.clear();
ports.clear();
links.clear();
loopback_internal_nodes.clear();
pending_auto_links.clear();
auto_link_claimed_pairs.clear();
policy_sync_pending = false;
@ -873,6 +878,103 @@ Result<uint32_t> Client::Impl::CreateVirtualStreamLocked(std::string_view name,
}
}
if (options.behavior == VirtualBehavior::kLoopback && options.target_node) {
std::string args;
args += "{ node.description = \"" + display_name + "\"";
args += " node.name = \"" + stream_name + "\"";
args += " audio.channels = " + std::to_string(options.format.channels);
args += " audio.rate = " + std::to_string(options.format.rate);
args += " capture.props = {";
args += " target.object = \"" + *options.target_node + "\"";
args += " stream.capture.sink = true";
args += " node.passive = true";
args += " node.dont-reconnect = true";
args += " }";
args += " playback.props = {";
args += " node.name = \"" + stream_name + "\"";
args += " node.description = \"" + display_name + "\"";
args += " media.class = \"" + media_class_value + "\"";
args += " node.virtual = true";
args += " }";
args += " }";
pw_impl_module* mod = pw_context_load_module(context,
"libpipewire-module-loopback", args.c_str(), nullptr);
if (!mod) {
return {Status::Error(StatusCode::kUnavailable, "failed to load loopback module"), 0};
}
auto sync_status = SyncLocked();
if (!sync_status.ok()) {
pw_impl_module_destroy(mod);
return {sync_status, 0};
}
uint32_t node_id = SPA_ID_INVALID;
{
std::lock_guard<std::mutex> lock(cache_mutex);
for (const auto& entry : nodes) {
if (entry.second.name == stream_name) {
node_id = entry.first;
break;
}
}
}
if (node_id == SPA_ID_INVALID) {
int wait_attempts = 0;
while (node_id == SPA_ID_INVALID && wait_attempts < 3) {
int wait_res = pw_thread_loop_timed_wait(thread_loop, kSyncWaitSeconds);
if (wait_res == -ETIMEDOUT) {
break;
}
auto sync2 = SyncLocked();
(void)sync2;
std::lock_guard<std::mutex> lock(cache_mutex);
for (const auto& entry : nodes) {
if (entry.second.name == stream_name) {
node_id = entry.first;
break;
}
}
++wait_attempts;
}
}
if (node_id == SPA_ID_INVALID) {
pw_impl_module_destroy(mod);
return {Status::Error(StatusCode::kTimeout, "loopback node did not appear in registry"), 0};
}
std::string capture_name = "input." + stream_name;
{
std::lock_guard<std::mutex> lock(cache_mutex);
for (const auto& entry : nodes) {
if (entry.second.name == capture_name) {
loopback_internal_nodes.insert(entry.first);
break;
}
}
}
auto stream_data = std::make_unique<StreamData>();
stream_data->module = mod;
stream_data->loop = thread_loop;
stream_data->is_source = is_source;
stream_data->loopback = true;
stream_data->target_node = *options.target_node;
stream_data->name = stream_name;
stream_data->rate = options.format.rate;
stream_data->channels = options.format.channels;
stream_data->node_id = node_id;
stream_data->ready = true;
{
std::lock_guard<std::mutex> lock(cache_mutex);
virtual_streams.emplace(node_id, std::move(stream_data));
}
return {Status::Ok(), node_id};
}
pw_properties* props = pw_properties_new(PW_KEY_MEDIA_TYPE, "Audio",
PW_KEY_MEDIA_CATEGORY, media_category,
PW_KEY_MEDIA_ROLE, "Music",
@ -888,9 +990,6 @@ Result<uint32_t> Client::Impl::CreateVirtualStreamLocked(std::string_view name,
if (node_group) {
pw_properties_set(props, PW_KEY_NODE_GROUP, node_group);
}
if (options.behavior == VirtualBehavior::kLoopback && options.target_node) {
pw_properties_set(props, PW_KEY_TARGET_OBJECT, options.target_node->c_str());
}
pw_stream* stream = pw_stream_new(core, stream_name.c_str(), props);
if (!stream) {
@ -901,7 +1000,7 @@ Result<uint32_t> Client::Impl::CreateVirtualStreamLocked(std::string_view name,
stream_data->stream = stream;
stream_data->loop = thread_loop;
stream_data->is_source = is_source;
stream_data->loopback = options.behavior == VirtualBehavior::kLoopback;
stream_data->loopback = false;
if (options.target_node) {
stream_data->target_node = *options.target_node;
}
@ -926,9 +1025,6 @@ Result<uint32_t> Client::Impl::CreateVirtualStreamLocked(std::string_view name,
enum pw_direction direction = is_source ? PW_DIRECTION_OUTPUT : PW_DIRECTION_INPUT;
enum pw_stream_flags flags = PW_STREAM_FLAG_MAP_BUFFERS;
if (options.behavior == VirtualBehavior::kLoopback && options.target_node) {
flags = static_cast<pw_stream_flags>(flags | PW_STREAM_FLAG_AUTOCONNECT);
}
int res = pw_stream_connect(stream, direction, PW_ID_ANY, flags, params, 1);
if (res < 0) {
pw_stream_destroy(stream);
@ -1042,7 +1138,11 @@ void Client::Impl::DisconnectLocked() {
}
for (auto& entry : streams) {
StreamData* stream_data = entry.second.get();
if (stream_data && stream_data->stream) {
if (!stream_data) continue;
if (stream_data->module) {
pw_impl_module_destroy(stream_data->module);
stream_data->module = nullptr;
} else if (stream_data->stream) {
pw_stream_disconnect(stream_data->stream);
pw_stream_destroy(stream_data->stream);
stream_data->stream = nullptr;
@ -1827,6 +1927,9 @@ Result<std::vector<NodeInfo>> Client::ListNodes() {
std::vector<NodeInfo> items;
items.reserve(impl_->nodes.size());
for (const auto& entry : impl_->nodes) {
if (impl_->loopback_internal_nodes.count(entry.first)) {
continue;
}
NodeInfo info = entry.second;
if (!info.is_virtual &&
impl_->virtual_streams.find(entry.first) !=
@ -1933,10 +2036,28 @@ Status Client::RemoveNode(NodeId node) {
owned_stream = std::move(it->second);
impl_->virtual_streams.erase(it);
}
if (owned_stream && owned_stream->stream) {
pw_stream_disconnect(owned_stream->stream);
pw_stream_destroy(owned_stream->stream);
owned_stream->stream = nullptr;
if (owned_stream) {
if (owned_stream->module) {
std::string capture_name = "input." + owned_stream->name;
{
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
for (auto it = impl_->loopback_internal_nodes.begin();
it != impl_->loopback_internal_nodes.end(); ++it) {
auto node_it = impl_->nodes.find(*it);
if (node_it != impl_->nodes.end() &&
node_it->second.name == capture_name) {
impl_->loopback_internal_nodes.erase(it);
break;
}
}
}
pw_impl_module_destroy(owned_stream->module);
owned_stream->module = nullptr;
} else if (owned_stream->stream) {
pw_stream_disconnect(owned_stream->stream);
pw_stream_destroy(owned_stream->stream);
owned_stream->stream = nullptr;
}
}
pw_thread_loop_unlock(impl_->thread_loop);
impl_->AutoSave();
@ -2140,9 +2261,28 @@ Status Client::DisableNodeMeter(NodeId node) {
meter->stream = nullptr;
pw_thread_loop_unlock(impl_->thread_loop);
}
impl_->AutoSave();
return Status::Ok();
}
Result<VirtualNodeInfo> Client::GetVirtualNodeInfo(NodeId node) const {
std::lock_guard<std::mutex> 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<MeterState> Client::NodeMeterPeak(NodeId node) const {
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
auto live_it = impl_->live_meters.find(node.value);

View file

@ -728,7 +728,7 @@ TEST_CASE("ghost connections preserved when node becomes ghost") {
model.refreshFromClient();
REQUIRE(model.isGhost(appQt));
REQUIRE(model.connectionExists(
REQUIRE(model.ghostConnectionExists(
QtNodes::ConnectionId{appQt, 0, sinkQt, 0}));
}
@ -782,7 +782,7 @@ TEST_CASE("ghost connections survive save/load round-trip") {
REQUIRE(appQt2 != 0);
REQUIRE(model2.isGhost(appQt2));
auto conns = model2.allConnectionIds(appQt2);
auto conns = model2.allGhostConnectionIds(appQt2);
REQUIRE(conns.size() == 1);
auto conn = *conns.begin();
REQUIRE(conn.outNodeId == appQt2);