GUI M8f: Event-driven updates, deferred link restoration, routing rules UI
This commit is contained in:
parent
e8d3f63f4d
commit
b819d6fd65
6 changed files with 440 additions and 121 deletions
27
GUI_PLAN.md
27
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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
234
src/warppipe.cpp
234
src/warppipe.cpp
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue