GUI M8f: Event-driven updates, deferred link restoration, routing rules UI

This commit is contained in:
Joey Yakimowich-Payne 2026-01-30 11:52:24 -07:00
commit b819d6fd65
6 changed files with 440 additions and 121 deletions

View file

@ -274,19 +274,20 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library.
- [x] Remove meter rows when nodes deleted
- [x] `rebuildNodeMeters()` wired to `nodeCreated`/`nodeDeleted` signals
- [x] Add tests for AudioLevelMeter level clamping, hold/decay logic, METERS tab existence, meter row creation
- [ ] Milestone 8f - Architecture and Routing Rules
- [ ] Event-driven updates: replace 500ms polling with signal/slot if core adds registry callbacks
- [ ] `nodeAdded(NodeInfo)`, `nodeRemoved(uint32_t)`, `nodeChanged(NodeInfo)`
- [ ] `linkAdded(LinkInfo)`, `linkRemoved(uint32_t)`
- [ ] Keep polling as fallback if signals not available
- [ ] Link intent system: remember intended links by stable key, restore when nodes reappear
- [ ] `rememberLinkIntent(LinkInfo)` — store stable_id:port_name pairs
- [ ] `tryRestoreLinks()` — called on node add, resolves stored intents
- [ ] Persist link intents in layout JSON
- [ ] Add routing rule UI (separate panel or dialog)
- [ ] List existing rules from `Client::ListRouteRules()`
- [ ] Add/remove rules with RuleMatch fields
- [ ] Show which nodes are affected by rules
- [x] Milestone 8f - Architecture and Routing Rules
- [x] Event-driven updates: core `SetChangeCallback()` fires on registry changes, GUI debounces via 50ms QTimer + QueuedConnection marshal (2s polling kept as fallback)
- [x] `Client::SetChangeCallback(ChangeCallback)` — fires from PW thread on node/port/link add/remove
- [x] `NotifyChange()` uses dedicated `change_cb_mutex` (not cache_mutex) to avoid lock ordering issues
- [x] GUI marshals to Qt thread via `QMetaObject::invokeMethod(..., Qt::QueuedConnection)`
- [x] Link intent system: implemented via core `saved_links` + deferred `ProcessSavedLinks()`
- [x] `LoadConfig()` parses links into `saved_links` vector (stable node:port name pairs)
- [x] `ProcessSavedLinks()` resolves names → port IDs on each CoreDone, creates via `CreateSavedLinkAsync()`
- [x] Competing links from WirePlumber auto-removed after saved link creation
- [x] Persisted in config.json `links` array (not layout JSON — core owns link state)
- [x] Add routing rule UI (RULES sidebar tab)
- [x] List existing rules from `Client::ListRouteRules()` as styled cards
- [x] Add rules via dialog with Application Name, Process Binary, Media Role, Target Node fields
- [x] Delete rules via per-card ✕ button
---

View file

@ -14,10 +14,14 @@
#include <QAction>
#include <QClipboard>
#include <QContextMenuEvent>
#include <QComboBox>
#include <QCoreApplication>
#include <QDateTime>
#include <QDialog>
#include <QDialogButtonBox>
#include <QDir>
#include <QFileDialog>
#include <QFormLayout>
#include <QGraphicsItem>
#include <QGuiApplication>
#include <QInputDialog>
@ -285,6 +289,18 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
m_sidebar->addTab(m_mixerScroll, QStringLiteral("MIXER"));
m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS"));
m_rulesScroll = new QScrollArea();
m_rulesScroll->setWidgetResizable(true);
m_rulesScroll->setStyleSheet(m_mixerScroll->styleSheet());
m_rulesContainer = new QWidget();
m_rulesContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;"));
auto *rulesLayout = new QVBoxLayout(m_rulesContainer);
rulesLayout->setContentsMargins(8, 8, 8, 8);
rulesLayout->setSpacing(6);
rulesLayout->addStretch();
m_rulesScroll->setWidget(m_rulesContainer);
m_sidebar->addTab(m_rulesScroll, QStringLiteral("RULES"));
m_splitter = new QSplitter(Qt::Horizontal);
m_splitter->addWidget(m_view);
m_splitter->addWidget(m_sidebar);
@ -405,6 +421,7 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
wireVolumeWidget(nodeId);
rebuildMixerStrips();
rebuildNodeMeters();
rebuildRulesList();
});
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this,
[this](QtNodes::NodeId nodeId) {
@ -412,6 +429,7 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
m_nodeMeters.erase(nodeId);
rebuildMixerStrips();
rebuildNodeMeters();
rebuildRulesList();
});
m_saveTimer = new QTimer(this);
@ -421,6 +439,7 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
&GraphEditorWidget::saveLayoutWithViewState);
m_model->refreshFromClient();
rebuildRulesList();
if (!hasLayout) {
m_model->autoArrange();
}
@ -435,10 +454,24 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this,
&GraphEditorWidget::saveLayoutWithViewState);
m_changeTimer = new QTimer(this);
m_changeTimer->setSingleShot(true);
m_changeTimer->setInterval(50);
connect(m_changeTimer, &QTimer::timeout, this,
&GraphEditorWidget::onRefreshTimer);
if (m_client) {
m_client->SetChangeCallback([this] {
QMetaObject::invokeMethod(m_changeTimer,
qOverload<>(&QTimer::start),
Qt::QueuedConnection);
});
}
m_refreshTimer = new QTimer(this);
connect(m_refreshTimer, &QTimer::timeout, this,
&GraphEditorWidget::onRefreshTimer);
m_refreshTimer->start(500);
m_refreshTimer->start(2000);
m_meterTimer = new QTimer(this);
m_meterTimer->setTimerType(Qt::PreciseTimer);
@ -1373,3 +1406,186 @@ void GraphEditorWidget::rebuildNodeMeters() {
}
}
}
void GraphEditorWidget::rebuildRulesList() {
if (!m_rulesContainer || !m_client)
return;
auto *layout = m_rulesContainer->layout();
if (!layout)
return;
while (layout->count() > 0) {
auto *item = layout->takeAt(0);
if (item->widget())
item->widget()->deleteLater();
delete item;
}
const QString labelStyle = QStringLiteral(
"QLabel { color: #a0a8b6; font-size: 11px; background: transparent; }");
const QString valueStyle = QStringLiteral(
"QLabel { color: #ecf0f6; font-size: 12px; background: transparent; }");
const QString btnStyle = QStringLiteral(
"QPushButton { background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;"
" border-radius: 4px; padding: 6px 12px; }"
"QPushButton:hover { background: #3a3a44; }"
"QPushButton:pressed { background: #44444e; }");
const QString delBtnStyle = QStringLiteral(
"QPushButton { background: transparent; color: #a05050; border: none;"
" font-size: 14px; font-weight: bold; padding: 2px 6px; }"
"QPushButton:hover { color: #e05050; }");
auto *header = new QLabel(QStringLiteral("ROUTING RULES"));
header->setStyleSheet(QStringLiteral(
"QLabel { color: #a0a8b6; font-size: 11px; font-weight: bold;"
" background: transparent; }"));
layout->addWidget(header);
auto rulesResult = m_client->ListRouteRules();
if (rulesResult.ok()) {
for (const auto &rule : rulesResult.value) {
auto *card = new QWidget();
card->setStyleSheet(QStringLiteral(
"QWidget { background: #24242a; border-radius: 4px; }"));
auto *cardLayout = new QHBoxLayout(card);
cardLayout->setContentsMargins(8, 6, 4, 6);
cardLayout->setSpacing(8);
QString matchText;
if (!rule.match.application_name.empty())
matchText += QStringLiteral("app: ") +
QString::fromStdString(rule.match.application_name);
if (!rule.match.process_binary.empty()) {
if (!matchText.isEmpty()) matchText += QStringLiteral(", ");
matchText += QStringLiteral("bin: ") +
QString::fromStdString(rule.match.process_binary);
}
if (!rule.match.media_role.empty()) {
if (!matchText.isEmpty()) matchText += QStringLiteral(", ");
matchText += QStringLiteral("role: ") +
QString::fromStdString(rule.match.media_role);
}
auto *infoLayout = new QVBoxLayout();
infoLayout->setContentsMargins(0, 0, 0, 0);
infoLayout->setSpacing(2);
auto *matchLabel = new QLabel(matchText);
matchLabel->setStyleSheet(valueStyle);
infoLayout->addWidget(matchLabel);
auto *targetLabel = new QLabel(
QStringLiteral("\xe2\x86\x92 ") +
QString::fromStdString(rule.target_node));
targetLabel->setStyleSheet(labelStyle);
infoLayout->addWidget(targetLabel);
cardLayout->addLayout(infoLayout, 1);
auto *delBtn = new QPushButton(QStringLiteral("\xe2\x9c\x95"));
delBtn->setFixedSize(24, 24);
delBtn->setStyleSheet(delBtnStyle);
warppipe::RuleId ruleId = rule.id;
connect(delBtn, &QPushButton::clicked, this, [this, ruleId]() {
m_client->RemoveRouteRule(ruleId);
rebuildRulesList();
});
cardLayout->addWidget(delBtn);
layout->addWidget(card);
}
}
auto *addBtn = new QPushButton(QStringLiteral("Add Rule..."));
addBtn->setStyleSheet(btnStyle);
connect(addBtn, &QPushButton::clicked, this,
&GraphEditorWidget::showAddRuleDialog);
layout->addWidget(addBtn);
static_cast<QVBoxLayout *>(layout)->addStretch();
}
void GraphEditorWidget::showAddRuleDialog() {
if (!m_client)
return;
QDialog dlg(this);
dlg.setWindowTitle(QStringLiteral("Add Routing Rule"));
dlg.setStyleSheet(QStringLiteral(
"QDialog { background: #1e1e22; }"
"QLabel { color: #ecf0f6; }"
"QLineEdit { background: #2a2a32; color: #ecf0f6; border: 1px solid #3a3a44;"
" border-radius: 4px; padding: 4px 8px; }"
"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; }"));
auto *form = new QFormLayout(&dlg);
form->setContentsMargins(16, 16, 16, 16);
form->setSpacing(8);
auto *appNameEdit = new QLineEdit();
appNameEdit->setPlaceholderText(QStringLiteral("e.g. Firefox"));
form->addRow(QStringLiteral("Application Name:"), appNameEdit);
auto *processBinEdit = new QLineEdit();
processBinEdit->setPlaceholderText(QStringLiteral("e.g. firefox"));
form->addRow(QStringLiteral("Process Binary:"), processBinEdit);
auto *mediaRoleEdit = new QLineEdit();
mediaRoleEdit->setPlaceholderText(QStringLiteral("e.g. Music"));
form->addRow(QStringLiteral("Media Role:"), mediaRoleEdit);
auto *targetCombo = new QComboBox();
auto nodesResult = m_client->ListNodes();
if (nodesResult.ok()) {
for (const auto &node : nodesResult.value) {
if (node.media_class.find("Sink") != std::string::npos) {
QString label = QString::fromStdString(
node.description.empty() ? node.name : node.description);
targetCombo->addItem(label, QString::fromStdString(node.name));
}
}
}
form->addRow(QStringLiteral("Target Node:"), targetCombo);
auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
buttons->setStyleSheet(QStringLiteral(
"QPushButton { background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;"
" border-radius: 4px; padding: 6px 16px; }"
"QPushButton:hover { background: #3a3a44; }"));
connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject);
form->addRow(buttons);
if (dlg.exec() != QDialog::Accepted)
return;
std::string appName = appNameEdit->text().trimmed().toStdString();
std::string procBin = processBinEdit->text().trimmed().toStdString();
std::string role = mediaRoleEdit->text().trimmed().toStdString();
std::string target = targetCombo->currentData().toString().toStdString();
if (appName.empty() && procBin.empty() && role.empty()) {
QMessageBox::warning(this, QStringLiteral("Invalid Rule"),
QStringLiteral("At least one match field must be filled."));
return;
}
if (target.empty()) {
QMessageBox::warning(this, QStringLiteral("Invalid Rule"),
QStringLiteral("A target node must be selected."));
return;
}
warppipe::RouteRule rule;
rule.match.application_name = appName;
rule.match.process_binary = procBin;
rule.match.media_role = role;
rule.target_node = target;
m_client->AddRouteRule(rule);
rebuildRulesList();
}

View file

@ -72,6 +72,8 @@ private:
void rebuildMixerStrips();
void updateMeters();
void rebuildNodeMeters();
void rebuildRulesList();
void showAddRuleDialog();
struct PendingPasteLink {
std::string outNodeName;
@ -87,6 +89,7 @@ private:
QSplitter *m_splitter = nullptr;
QTabWidget *m_sidebar = nullptr;
QTimer *m_refreshTimer = nullptr;
QTimer *m_changeTimer = nullptr;
QTimer *m_saveTimer = nullptr;
QString m_layoutPath;
QString m_presetDir;
@ -110,4 +113,7 @@ private:
QLabel *label = nullptr;
};
std::unordered_map<QtNodes::NodeId, NodeMeterRow> m_nodeMeters;
QWidget *m_rulesContainer = nullptr;
QScrollArea *m_rulesScroll = nullptr;
};

View file

@ -1,6 +1,7 @@
#pragma once
#include <cstdint>
#include <functional>
#include <memory>
#include <optional>
#include <string>
@ -203,6 +204,9 @@ class Client {
Status SaveConfig(std::string_view path);
Status LoadConfig(std::string_view path);
using ChangeCallback = std::function<void()>;
void SetChangeCallback(ChangeCallback callback);
#ifdef WARPPIPE_TESTING
Status Test_InsertNode(const NodeInfo& node);
Status Test_InsertPort(const PortInfo& port);

View file

@ -363,6 +363,10 @@ struct Client::Impl {
};
std::vector<SavedLink> saved_links;
std::mutex change_cb_mutex;
Client::ChangeCallback change_callback;
void NotifyChange();
Status ConnectLocked();
void DisconnectLocked();
Status SyncLocked();
@ -407,59 +411,66 @@ void Client::Impl::RegistryGlobal(void* data,
return;
}
bool notify = false;
{
std::lock_guard<std::mutex> lock(impl->cache_mutex);
if (IsNodeType(type)) {
NodeInfo info;
info.id = NodeId{id};
info.name = LookupString(props, PW_KEY_NODE_NAME);
info.description = LookupString(props, PW_KEY_NODE_DESCRIPTION);
info.media_class = LookupString(props, PW_KEY_MEDIA_CLASS);
info.application_name = LookupString(props, PW_KEY_APP_NAME);
info.process_binary = LookupString(props, PW_KEY_APP_PROCESS_BINARY);
info.media_role = LookupString(props, PW_KEY_MEDIA_ROLE);
std::string virt_str = LookupString(props, PW_KEY_NODE_VIRTUAL);
info.is_virtual = (virt_str == "true");
impl->nodes[id] = info;
impl->CheckRulesForNode(info);
notify = true;
} else if (IsPortType(type)) {
PortInfo info;
info.id = PortId{id};
info.name = LookupString(props, PW_KEY_PORT_NAME);
info.is_input = false;
uint32_t node_id = 0;
if (ParseUint32(SafeLookup(props, PW_KEY_NODE_ID), &node_id)) {
info.node = NodeId{node_id};
}
const char* direction = SafeLookup(props, PW_KEY_PORT_DIRECTION);
if (direction && spa_streq(direction, "in")) {
info.is_input = true;
}
impl->ports[id] = info;
if (!impl->pending_auto_links.empty() || !impl->saved_links.empty()) {
impl->SchedulePolicySync();
}
notify = true;
} else if (IsLinkType(type)) {
Link info;
info.id = LinkId{id};
uint32_t out_port = 0;
uint32_t in_port = 0;
if (ParseUint32(SafeLookup(props, PW_KEY_LINK_OUTPUT_PORT), &out_port)) {
info.output_port = PortId{out_port};
}
if (ParseUint32(SafeLookup(props, PW_KEY_LINK_INPUT_PORT), &in_port)) {
info.input_port = PortId{in_port};
}
impl->links[id] = std::move(info);
notify = true;
}
}
if (notify) {
impl->NotifyChange();
return;
}
std::lock_guard<std::mutex> lock(impl->cache_mutex);
if (IsNodeType(type)) {
NodeInfo info;
info.id = NodeId{id};
info.name = LookupString(props, PW_KEY_NODE_NAME);
info.description = LookupString(props, PW_KEY_NODE_DESCRIPTION);
info.media_class = LookupString(props, PW_KEY_MEDIA_CLASS);
info.application_name = LookupString(props, PW_KEY_APP_NAME);
info.process_binary = LookupString(props, PW_KEY_APP_PROCESS_BINARY);
info.media_role = LookupString(props, PW_KEY_MEDIA_ROLE);
std::string virt_str = LookupString(props, PW_KEY_NODE_VIRTUAL);
info.is_virtual = (virt_str == "true");
impl->nodes[id] = info;
impl->CheckRulesForNode(info);
return;
}
if (IsPortType(type)) {
PortInfo info;
info.id = PortId{id};
info.name = LookupString(props, PW_KEY_PORT_NAME);
info.is_input = false;
uint32_t node_id = 0;
if (ParseUint32(SafeLookup(props, PW_KEY_NODE_ID), &node_id)) {
info.node = NodeId{node_id};
}
const char* direction = SafeLookup(props, PW_KEY_PORT_DIRECTION);
if (direction && spa_streq(direction, "in")) {
info.is_input = true;
}
impl->ports[id] = info;
if (!impl->pending_auto_links.empty() || !impl->saved_links.empty()) {
impl->SchedulePolicySync();
}
return;
}
if (IsLinkType(type)) {
Link info;
info.id = LinkId{id};
uint32_t out_port = 0;
uint32_t in_port = 0;
if (ParseUint32(SafeLookup(props, PW_KEY_LINK_OUTPUT_PORT), &out_port)) {
info.output_port = PortId{out_port};
}
if (ParseUint32(SafeLookup(props, PW_KEY_LINK_INPUT_PORT), &in_port)) {
info.input_port = PortId{in_port};
}
impl->links[id] = std::move(info);
return;
}
if (type && spa_streq(type, PW_TYPE_INTERFACE_Metadata)) {
const char* meta_name = SafeLookup(props, "metadata.name");
if (meta_name && spa_streq(meta_name, "default") && !impl->metadata_proxy) {
@ -487,50 +498,49 @@ void Client::Impl::RegistryGlobalRemove(void* data, uint32_t id) {
return;
}
std::lock_guard<std::mutex> lock(impl->cache_mutex);
impl->virtual_streams.erase(id);
impl->link_proxies.erase(id);
auto node_it = impl->nodes.find(id);
if (node_it != impl->nodes.end()) {
impl->nodes.erase(node_it);
std::vector<uint32_t> removed_ports;
for (auto it = impl->ports.begin(); it != impl->ports.end();) {
if (it->second.node.value == id) {
removed_ports.push_back(it->first);
it = impl->ports.erase(it);
} else {
++it;
}
}
for (auto it = impl->links.begin(); it != impl->links.end();) {
bool remove_link = false;
for (uint32_t port_id : removed_ports) {
if (it->second.input_port.value == port_id || it->second.output_port.value == port_id) {
remove_link = true;
break;
{
std::lock_guard<std::mutex> lock(impl->cache_mutex);
impl->virtual_streams.erase(id);
impl->link_proxies.erase(id);
auto node_it = impl->nodes.find(id);
if (node_it != impl->nodes.end()) {
impl->nodes.erase(node_it);
std::vector<uint32_t> removed_ports;
for (auto it = impl->ports.begin(); it != impl->ports.end();) {
if (it->second.node.value == id) {
removed_ports.push_back(it->first);
it = impl->ports.erase(it);
} else {
++it;
}
}
if (remove_link) {
it = impl->links.erase(it);
} else {
++it;
for (auto it = impl->links.begin(); it != impl->links.end();) {
bool remove_link = false;
for (uint32_t port_id : removed_ports) {
if (it->second.input_port.value == port_id || it->second.output_port.value == port_id) {
remove_link = true;
break;
}
}
if (remove_link) {
it = impl->links.erase(it);
} else {
++it;
}
}
}
return;
}
if (impl->ports.erase(id) > 0) {
for (auto it = impl->links.begin(); it != impl->links.end();) {
if (it->second.input_port.value == id || it->second.output_port.value == id) {
it = impl->links.erase(it);
} else {
++it;
} else if (impl->ports.erase(id) > 0) {
for (auto it = impl->links.begin(); it != impl->links.end();) {
if (it->second.input_port.value == id || it->second.output_port.value == id) {
it = impl->links.erase(it);
} else {
++it;
}
}
} else {
impl->links.erase(id);
}
return;
}
impl->links.erase(id);
impl->NotifyChange();
}
void Client::Impl::CoreDone(void* data, uint32_t, int seq) {
@ -589,6 +599,13 @@ void Client::Impl::ClearCache() {
policy_sync_pending = false;
}
void Client::Impl::NotifyChange() {
std::lock_guard<std::mutex> lock(change_cb_mutex);
if (change_callback) {
change_callback();
}
}
Status Client::Impl::EnsureConnected() {
if (connected) {
return Status::Ok();
@ -1025,8 +1042,6 @@ void Client::Impl::ProcessSavedLinks() {
{
std::lock_guard<std::mutex> lock(cache_mutex);
fprintf(stderr, "[warppipe] ProcessSavedLinks: %zu pending, %zu nodes, %zu ports\n",
saved_links.size(), nodes.size(), ports.size());
for (auto it = saved_links.begin(); it != saved_links.end();) {
uint32_t out_id = 0, in_id = 0;
for (const auto& port_entry : ports) {
@ -1044,9 +1059,6 @@ void Client::Impl::ProcessSavedLinks() {
if (out_id && in_id) break;
}
if (!out_id || !in_id) {
fprintf(stderr, " deferred: %s:%s -> %s:%s (ports not found)\n",
it->out_node.c_str(), it->out_port.c_str(),
it->in_node.c_str(), it->in_port.c_str());
++it;
continue;
}
@ -1059,9 +1071,6 @@ void Client::Impl::ProcessSavedLinks() {
}
}
if (exists) {
fprintf(stderr, " already exists: %s:%s -> %s:%s\n",
it->out_node.c_str(), it->out_port.c_str(),
it->in_node.c_str(), it->in_port.c_str());
it = saved_links.erase(it);
continue;
}
@ -1097,13 +1106,10 @@ void Client::Impl::ProcessSavedLinks() {
}
for (uint32_t id : competing_link_ids) {
fprintf(stderr, " removing competing link %u\n", id);
pw_registry_destroy(registry, id);
}
for (const auto& spec : to_create) {
fprintf(stderr, " creating: %s (ports %u -> %u)\n",
spec.label.c_str(), spec.output_port, spec.input_port);
CreateSavedLinkAsync(spec.output_port, spec.input_port);
}
}
@ -2227,6 +2233,11 @@ Status Client::SaveConfig(std::string_view path) {
return Status::Ok();
}
void Client::SetChangeCallback(ChangeCallback callback) {
std::lock_guard<std::mutex> lock(impl_->change_cb_mutex);
impl_->change_callback = std::move(callback);
}
Status Client::LoadConfig(std::string_view path) {
if (path.empty()) {
return Status::Error(StatusCode::kInvalidArgument, "path is empty");
@ -2342,9 +2353,12 @@ Status Client::Test_InsertNode(const NodeInfo& node) {
if (!impl_) {
return Status::Error(StatusCode::kInternal, "client not initialized");
}
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
impl_->nodes[node.id.value] = node;
impl_->CheckRulesForNode(node);
{
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
impl_->nodes[node.id.value] = node;
impl_->CheckRulesForNode(node);
}
impl_->NotifyChange();
return Status::Ok();
}
@ -2352,8 +2366,11 @@ Status Client::Test_InsertPort(const PortInfo& port) {
if (!impl_) {
return Status::Error(StatusCode::kInternal, "client not initialized");
}
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
impl_->ports[port.id.value] = port;
{
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
impl_->ports[port.id.value] = port;
}
impl_->NotifyChange();
return Status::Ok();
}
@ -2361,8 +2378,11 @@ Status Client::Test_InsertLink(const Link& link) {
if (!impl_) {
return Status::Error(StatusCode::kInternal, "client not initialized");
}
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
impl_->links[link.id.value] = link;
{
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
impl_->links[link.id.value] = link;
}
impl_->NotifyChange();
return Status::Ok();
}

View file

@ -1252,3 +1252,75 @@ TEST_CASE("volume state cleaned up on node deletion") {
REQUIRE(state.volume == Catch::Approx(1.0f));
REQUIRE_FALSE(state.mute);
}
TEST_CASE("GraphEditorWidget has RULES tab") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
GraphEditorWidget widget(tc.client.get());
auto *sidebar = widget.findChild<QTabWidget *>();
REQUIRE(sidebar != nullptr);
bool found = false;
for (int i = 0; i < sidebar->count(); ++i) {
if (sidebar->tabText(i) == "RULES") {
found = true;
break;
}
}
REQUIRE(found);
}
TEST_CASE("SetChangeCallback fires on node insert") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
std::atomic<int> count{0};
tc.client->SetChangeCallback([&count]() { ++count; });
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100800, "cb-test-node", "Audio/Sink")).ok());
REQUIRE(count.load() >= 1);
}
TEST_CASE("SetChangeCallback fires on node remove") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100810, "cb-remove-node", "Audio/Sink")).ok());
std::atomic<int> count{0};
tc.client->SetChangeCallback([&count]() { ++count; });
REQUIRE(tc.client->Test_RemoveGlobal(100810).ok());
REQUIRE(count.load() >= 1);
}
TEST_CASE("SetChangeCallback can be cleared") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
std::atomic<int> count{0};
tc.client->SetChangeCallback([&count]() { ++count; });
tc.client->SetChangeCallback(nullptr);
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100820, "cb-clear-node", "Audio/Sink")).ok());
REQUIRE(count.load() == 0);
}
TEST_CASE("sidebar tab order is METERS MIXER PRESETS RULES") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
GraphEditorWidget widget(tc.client.get());
auto *sidebar = widget.findChild<QTabWidget *>();
REQUIRE(sidebar != nullptr);
REQUIRE(sidebar->count() >= 4);
REQUIRE(sidebar->tabText(0) == "METERS");
REQUIRE(sidebar->tabText(1) == "MIXER");
REQUIRE(sidebar->tabText(2) == "PRESETS");
REQUIRE(sidebar->tabText(3) == "RULES");
}