diff --git a/GUI_PLAN.md b/GUI_PLAN.md index 419b159..560715e 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -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 --- diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 878fe95..a7b32c6 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -14,10 +14,14 @@ #include #include #include +#include #include #include +#include +#include #include #include +#include #include #include #include @@ -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(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(); +} diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index bd050b1..c327aff 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -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 m_nodeMeters; + + QWidget *m_rulesContainer = nullptr; + QScrollArea *m_rulesScroll = nullptr; }; diff --git a/include/warppipe/warppipe.hpp b/include/warppipe/warppipe.hpp index 3f9c9fd..e04046e 100644 --- a/include/warppipe/warppipe.hpp +++ b/include/warppipe/warppipe.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -203,6 +204,9 @@ class Client { Status SaveConfig(std::string_view path); Status LoadConfig(std::string_view path); + using ChangeCallback = std::function; + void SetChangeCallback(ChangeCallback callback); + #ifdef WARPPIPE_TESTING Status Test_InsertNode(const NodeInfo& node); Status Test_InsertPort(const PortInfo& port); diff --git a/src/warppipe.cpp b/src/warppipe.cpp index 3ddbb7c..36fe7e0 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -363,6 +363,10 @@ struct Client::Impl { }; std::vector 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 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 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 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 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 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 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 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 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 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 lock(impl_->cache_mutex); - impl_->nodes[node.id.value] = node; - impl_->CheckRulesForNode(node); + { + std::lock_guard 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 lock(impl_->cache_mutex); - impl_->ports[port.id.value] = port; + { + std::lock_guard 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 lock(impl_->cache_mutex); - impl_->links[link.id.value] = link; + { + std::lock_guard lock(impl_->cache_mutex); + impl_->links[link.id.value] = link; + } + impl_->NotifyChange(); return Status::Ok(); } diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index 0eba604..d5e2c6d 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -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(); + 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 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 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 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(); + 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"); +}