potato/src/gui/PipeWireGraphModel.cpp
2026-01-28 11:19:34 -07:00

1184 lines
35 KiB
C++

#include "PipeWireGraphModel.h"
#include "PipeWireGraphModel.h"
#include <QtCore/QJsonArray>
#include <QtCore/QJsonDocument>
#include <QtCore/QJsonObject>
#include <QtCore/QObject>
#include <QtCore/QStandardPaths>
#include <QVariant>
#include <QtGui/QFont>
#include <QtGui/QFontMetrics>
#include <QtCore/QRectF>
#include <QtCore/QSizeF>
#include <QtGui/QColor>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHBoxLayout>
#include <QSlider>
#include "gui/ClickSlider.h"
#include <QSizePolicy>
#include <QToolButton>
#include <QWidget>
#include <QtNodes/NodeStyle>
#include <QtNodes/StyleCollection>
#include <algorithm>
#include <cmath>
#include <unordered_set>
#include <vector>
namespace {
QVariant nodeStyleVariant(const Potato::NodeInfo &info)
{
QtNodes::NodeStyle style = QtNodes::StyleCollection::nodeStyle();
QColor base;
switch (info.type) {
case Potato::NodeType::Hardware:
base = QColor(72, 94, 118);
break;
case Potato::NodeType::Virtual:
base = QColor(62, 122, 104);
break;
case Potato::NodeType::Application:
base = QColor(138, 104, 72);
break;
case Potato::NodeType::Bus:
base = QColor(86, 92, 128);
break;
default:
base = QColor(86, 94, 108);
break;
}
style.GradientColor0 = base.lighter(120);
style.GradientColor1 = base.lighter(108);
style.GradientColor2 = base.darker(105);
style.GradientColor3 = base.darker(120);
style.NormalBoundaryColor = base.lighter(135);
style.SelectedBoundaryColor = QColor(255, 165, 0);
style.FontColor = QColor(236, 240, 246);
style.FontColorFaded = QColor(160, 168, 182);
style.ConnectionPointColor = QColor(200, 208, 220);
style.FilledConnectionPointColor = QColor(255, 165, 0);
style.WarningColor = QColor(230, 180, 70);
style.ErrorColor = QColor(220, 70, 70);
style.PenWidth = 1.3f;
style.HoveredPenWidth = 2.4f;
style.ConnectionPointDiameter = 10.0f;
style.Opacity = 1.0f;
return style.toJson().toVariantMap();
}
int nodeWidthFor(const Potato::NodeInfo &info)
{
QFont captionFont;
captionFont.setBold(true);
QFontMetrics captionMetrics(captionFont);
int maxTextWidth = captionMetrics.horizontalAdvance(info.name);
QFont portFont;
QFontMetrics portMetrics(portFont);
for (const auto &port : info.inputPorts) {
maxTextWidth = std::max(maxTextWidth, portMetrics.horizontalAdvance(port.name));
}
for (const auto &port : info.outputPorts) {
maxTextWidth = std::max(maxTextWidth, portMetrics.horizontalAdvance(port.name));
}
const int widthPadding = 120;
const int minWidth = 200;
const int maxWidth = 600;
const int width = maxTextWidth + widthPadding;
return std::max(minWidth, std::min(maxWidth, width));
}
QString elideLabel(const QString &text, int width, const QFont &font)
{
QFontMetrics metrics(font);
const int available = std::max(60, width);
return metrics.elidedText(text, Qt::ElideRight, available);
}
}
PipeWireGraphModel::PipeWireGraphModel(Potato::PipeWireController *controller, QObject *parent)
: QtNodes::AbstractGraphModel()
, m_controller(controller)
{
if (parent) {
setParent(parent);
}
}
QWidget *PipeWireGraphModel::nodeWidget(QtNodes::NodeId nodeId) const
{
auto it = m_nodeWidgets.find(nodeId);
if (it != m_nodeWidgets.end()) {
return it->second;
}
uint32_t pipewireId = 0;
auto nodeIt = m_nodes.find(nodeId);
if (nodeIt != m_nodes.end()) {
pipewireId = nodeIt->second.id;
}
auto *widget = new QWidget();
auto *layout = new QHBoxLayout(widget);
layout->setContentsMargins(6, 2, 6, 2);
layout->setSpacing(6);
auto *muteButton = new QToolButton(widget);
muteButton->setText("M");
muteButton->setCheckable(true);
muteButton->setFixedSize(20, 20);
muteButton->setToolTip(QString("Mute"));
auto *slider = new ClickSlider(Qt::Horizontal, widget);
slider->setRange(0, 100);
slider->setValue(100);
slider->setFixedHeight(18);
slider->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
slider->setToolTip(QString("Volume"));
if (pipewireId != 0 && m_nodeVolumeState.contains(pipewireId)) {
const NodeVolumeState state = m_nodeVolumeState.value(pipewireId);
slider->setValue(static_cast<int>(state.volume * 100.0f));
muteButton->setChecked(state.mute);
}
const auto applyVolume = [this, pipewireId, slider, muteButton]() {
if (!m_controller || pipewireId == 0) {
return;
}
const float volume = static_cast<float>(slider->value()) / 100.0f;
const NodeVolumeState next{volume, muteButton->isChecked()};
const NodeVolumeState previous = m_nodeVolumeState.value(pipewireId, next);
m_controller->setNodeVolume(pipewireId, volume, next.mute);
auto *self = const_cast<PipeWireGraphModel*>(this);
self->setNodeVolumeState(pipewireId, next, false);
if (!slider->isSliderDown()
&& !m_inlineStartState.contains(pipewireId)
&& !slider->property("pressValue").isValid()) {
self->emitNodeVolumeChanged(pipewireId, previous, next);
}
};
QObject::connect(slider, &QSlider::valueChanged, widget, [applyVolume](int) { applyVolume(); });
QObject::connect(muteButton, &QToolButton::toggled, widget, [applyVolume](bool) { applyVolume(); });
QObject::connect(slider, &QSlider::sliderPressed, widget, [this, pipewireId, slider, muteButton]() {
if (pipewireId == 0) {
return;
}
bool ok = false;
const int pressValue = slider->property("pressValue").toInt(&ok);
const float volume = ok ? (static_cast<float>(pressValue) / 100.0f)
: static_cast<float>(slider->value()) / 100.0f;
const bool mute = muteButton->isChecked();
m_inlineStartState.insert(pipewireId, NodeVolumeState{volume, mute});
});
QObject::connect(slider, &QSlider::sliderReleased, widget, [this, pipewireId, slider, muteButton]() {
if (pipewireId == 0) {
return;
}
const NodeVolumeState previous = m_inlineStartState.value(pipewireId, m_nodeVolumeState.value(pipewireId));
m_inlineStartState.remove(pipewireId);
slider->setProperty("pressValue", QVariant());
const float volume = static_cast<float>(slider->value()) / 100.0f;
const NodeVolumeState next{volume, muteButton->isChecked()};
auto *self = const_cast<PipeWireGraphModel*>(this);
self->emitNodeVolumeChanged(pipewireId, previous, next);
});
layout->addWidget(muteButton);
layout->addWidget(slider);
widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
widget->setFixedHeight(26);
widget->setStyleSheet(
"QToolButton { background: #2b2f38; color: #dbe2ee; border: 1px solid #39404c; border-radius: 4px; font-weight: 700; }"
"QToolButton:checked { background: #3c4350; color: #ffcf7a; }"
"QSlider::groove:horizontal { height: 4px; background: #2b2f38; border-radius: 2px; }"
"QSlider::sub-page:horizontal { background: #5fcf8d; border-radius: 2px; }"
"QSlider::add-page:horizontal { background: #2b2f38; border-radius: 2px; }"
"QSlider::handle:horizontal { width: 10px; margin: -6px 0; background: #dbe2ee; border-radius: 5px; }"
);
widget->adjustSize();
m_nodeWidgets.emplace(nodeId, widget);
return widget;
}
QtNodes::NodeId PipeWireGraphModel::newNodeId()
{
return m_nextNodeId++;
}
std::unordered_set<QtNodes::NodeId> PipeWireGraphModel::allNodeIds() const
{
std::unordered_set<QtNodes::NodeId> ids;
ids.reserve(m_nodes.size());
for (const auto &entry : m_nodes) {
ids.insert(entry.first);
}
return ids;
}
std::unordered_set<QtNodes::ConnectionId> PipeWireGraphModel::allConnectionIds(QtNodes::NodeId const nodeId) const
{
std::unordered_set<QtNodes::ConnectionId> result;
for (const auto &conn : m_connections) {
if (conn.outNodeId == nodeId || conn.inNodeId == nodeId) {
result.insert(conn);
}
}
return result;
}
std::unordered_set<QtNodes::ConnectionId> PipeWireGraphModel::connections(QtNodes::NodeId nodeId,
QtNodes::PortType portType,
QtNodes::PortIndex portIndex) const
{
std::unordered_set<QtNodes::ConnectionId> result;
for (const auto &conn : m_connections) {
if (portType == QtNodes::PortType::Out) {
if (conn.outNodeId == nodeId && conn.outPortIndex == portIndex) {
result.insert(conn);
}
} else if (portType == QtNodes::PortType::In) {
if (conn.inNodeId == nodeId && conn.inPortIndex == portIndex) {
result.insert(conn);
}
}
}
return result;
}
bool PipeWireGraphModel::connectionExists(QtNodes::ConnectionId const connectionId) const
{
return m_connections.find(connectionId) != m_connections.end();
}
QtNodes::NodeId PipeWireGraphModel::addNode(QString const nodeType)
{
Q_UNUSED(nodeType)
const QtNodes::NodeId nodeId = newNodeId();
Potato::NodeInfo info;
info.id = 0;
info.name = QString("Node %1").arg(static_cast<quint32>(nodeId));
info.description = info.name;
info.stableId = info.name;
m_nodes.emplace(nodeId, info);
QPointF position = nextPosition();
if (!info.stableId.isEmpty() && m_layoutByStableId.contains(info.stableId)) {
position = m_layoutByStableId.value(info.stableId);
}
m_positions.emplace(nodeId, position);
updateLayoutForNode(nodeId, position);
Q_EMIT nodeCreated(nodeId);
Q_EMIT nodeUpdated(nodeId);
return nodeId;
}
bool PipeWireGraphModel::connectionPossible(QtNodes::ConnectionId const connectionId) const
{
if (!nodeExists(connectionId.outNodeId) || !nodeExists(connectionId.inNodeId)) {
return false;
}
if (connectionExists(connectionId)) {
return false;
}
const auto outIt = m_nodes.find(connectionId.outNodeId);
const auto inIt = m_nodes.find(connectionId.inNodeId);
if (outIt == m_nodes.end() || inIt == m_nodes.end()) {
return false;
}
const auto &outInfo = outIt->second;
const auto &inInfo = inIt->second;
if (connectionId.outPortIndex >= static_cast<QtNodes::PortIndex>(outInfo.outputPorts.size())) {
return false;
}
if (connectionId.inPortIndex >= static_cast<QtNodes::PortIndex>(inInfo.inputPorts.size())) {
return false;
}
const auto &outPort = outInfo.outputPorts.at(connectionId.outPortIndex);
const auto &inPort = inInfo.inputPorts.at(connectionId.inPortIndex);
if (outPort.direction != SPA_DIRECTION_OUTPUT || inPort.direction != SPA_DIRECTION_INPUT) {
return false;
}
const auto isAudioClass = [](Potato::MediaClass mediaClass) {
return mediaClass == Potato::MediaClass::AudioSink
|| mediaClass == Potato::MediaClass::AudioSource
|| mediaClass == Potato::MediaClass::AudioDuplex
|| mediaClass == Potato::MediaClass::Stream;
};
if (!isAudioClass(outInfo.mediaClass) || !isAudioClass(inInfo.mediaClass)) {
return false;
}
return true;
}
void PipeWireGraphModel::addConnection(QtNodes::ConnectionId const connectionId)
{
if (!connectionPossible(connectionId)) {
return;
}
m_connections.insert(connectionId);
Q_EMIT connectionCreated(connectionId);
}
bool PipeWireGraphModel::nodeExists(QtNodes::NodeId const nodeId) const
{
return m_nodes.find(nodeId) != m_nodes.end();
}
QVariant PipeWireGraphModel::nodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole role) const
{
auto it = m_nodes.find(nodeId);
if (it == m_nodes.end()) {
return QVariant();
}
const auto &info = it->second;
switch (role) {
case QtNodes::NodeRole::Caption:
{
QFont captionFont;
captionFont.setBold(true);
const int width = nodeWidthFor(info) - 50;
const QString title = info.description.isEmpty() ? info.name : info.description;
return elideLabel(title, width, captionFont);
}
case QtNodes::NodeRole::CaptionVisible:
return true;
case QtNodes::NodeRole::Position: {
auto posIt = m_positions.find(nodeId);
if (posIt != m_positions.end()) {
return posIt->second;
}
return QPointF(0, 0);
}
case QtNodes::NodeRole::Size:
{
auto sizeIt = m_nodeSizes.find(nodeId);
if (sizeIt != m_nodeSizes.end()) {
return sizeIt->second;
}
const int maxPorts = std::max(info.inputPorts.size(), info.outputPorts.size());
const int baseHeight = 50;
const int perPortHeight = 28;
const int height = std::max(80, baseHeight + (maxPorts * perPortHeight));
const int width = nodeWidthFor(info);
return QSize(width, height);
}
case QtNodes::NodeRole::InPortCount:
return static_cast<unsigned int>(info.inputPorts.size());
case QtNodes::NodeRole::OutPortCount:
return static_cast<unsigned int>(info.outputPorts.size());
case QtNodes::NodeRole::Type:
return QString("PipeWire");
case QtNodes::NodeRole::Style:
return nodeStyleVariant(info);
case QtNodes::NodeRole::Widget:
return QVariant::fromValue(nodeWidget(nodeId));
default:
return QVariant();
}
}
bool PipeWireGraphModel::setNodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole role, QVariant value)
{
if (!nodeExists(nodeId)) {
return false;
}
if (role == QtNodes::NodeRole::Position) {
const QPointF position = value.toPointF();
m_positions[nodeId] = position;
updateLayoutForNode(nodeId, position);
Q_EMIT nodePositionUpdated(nodeId);
return true;
}
if (role == QtNodes::NodeRole::Size) {
m_nodeSizes[nodeId] = value.toSize();
return true;
}
return false;
}
QVariant PipeWireGraphModel::portData(QtNodes::NodeId nodeId,
QtNodes::PortType portType,
QtNodes::PortIndex portIndex,
QtNodes::PortRole role) const
{
auto it = m_nodes.find(nodeId);
if (it == m_nodes.end()) {
return QVariant();
}
const auto &info = it->second;
if (role == QtNodes::PortRole::DataType) {
return QString("audio");
}
if (role == QtNodes::PortRole::CaptionVisible) {
return true;
}
if (role == QtNodes::PortRole::Caption) {
if (portType == QtNodes::PortType::In) {
if (portIndex < static_cast<QtNodes::PortIndex>(info.inputPorts.size())) {
QFont font;
const int width = nodeWidthFor(info) - 100;
return elideLabel(info.inputPorts.at(portIndex).name, width, font);
}
} else if (portType == QtNodes::PortType::Out) {
if (portIndex < static_cast<QtNodes::PortIndex>(info.outputPorts.size())) {
QFont font;
const int width = nodeWidthFor(info) - 100;
return elideLabel(info.outputPorts.at(portIndex).name, width, font);
}
}
}
if (role == QtNodes::PortRole::ConnectionPolicyRole) {
return QVariant::fromValue(QtNodes::ConnectionPolicy::Many);
}
return QVariant();
}
bool PipeWireGraphModel::setPortData(QtNodes::NodeId, QtNodes::PortType, QtNodes::PortIndex,
QVariant const &, QtNodes::PortRole)
{
return false;
}
bool PipeWireGraphModel::deleteConnection(QtNodes::ConnectionId const connectionId)
{
auto it = m_connections.find(connectionId);
if (it == m_connections.end()) {
return false;
}
m_connections.erase(it);
Q_EMIT connectionDeleted(connectionId);
return true;
}
bool PipeWireGraphModel::deleteNode(QtNodes::NodeId const nodeId)
{
if (!nodeExists(nodeId)) {
return false;
}
const Potato::NodeInfo info = m_nodes.at(nodeId);
if (info.type != Potato::NodeType::Virtual) {
return false;
}
const Potato::NodeInfo liveNode = m_controller ? m_controller->nodeById(info.id) : Potato::NodeInfo{};
if (liveNode.isValid()) {
m_controller->destroyVirtualNode(info.id);
}
std::vector<QtNodes::ConnectionId> toRemove;
for (const auto &conn : m_connections) {
if (conn.outNodeId == nodeId || conn.inNodeId == nodeId) {
toRemove.push_back(conn);
}
}
for (const auto &conn : toRemove) {
deleteConnection(conn);
}
m_nodes.erase(nodeId);
m_positions.erase(nodeId);
m_nodeSizes.erase(nodeId);
m_nodeWidgets.erase(nodeId);
Q_EMIT nodeDeleted(nodeId);
return true;
}
QJsonObject PipeWireGraphModel::saveNode(QtNodes::NodeId const nodeId) const
{
QJsonObject obj;
obj["id"] = static_cast<qint64>(nodeId);
const QPointF pos = nodeData(nodeId, QtNodes::NodeRole::Position).toPointF();
QJsonObject posJson;
posJson["x"] = pos.x();
posJson["y"] = pos.y();
obj["position"] = posJson;
return obj;
}
void PipeWireGraphModel::loadNode(QJsonObject const &)
{
}
QtNodes::NodeId PipeWireGraphModel::addPipeWireNode(const Potato::NodeInfo &node)
{
if (m_pwToNode.find(node.id) != m_pwToNode.end()) {
return m_pwToNode.at(node.id);
}
const QtNodes::NodeId nodeId = newNodeId();
m_nodes.emplace(nodeId, node);
m_pwToNode.emplace(node.id, nodeId);
QPointF position = nextPosition();
if (!node.stableId.isEmpty() && m_layoutByStableId.contains(node.stableId)) {
position = m_layoutByStableId.value(node.stableId);
}
m_positions.emplace(nodeId, position);
updateLayoutForNode(nodeId, position);
Q_EMIT nodeCreated(nodeId);
Q_EMIT nodeUpdated(nodeId);
return nodeId;
}
void PipeWireGraphModel::removePipeWireNode(uint32_t nodeId)
{
auto it = m_pwToNode.find(nodeId);
if (it == m_pwToNode.end()) {
return;
}
deleteNode(it->second);
m_pwToNode.erase(it);
}
bool PipeWireGraphModel::addPipeWireConnection(const Potato::LinkInfo &link, QtNodes::ConnectionId *connectionId)
{
bool ok = false;
QtNodes::ConnectionId localId = connectionFromPipeWire(link, &ok);
if (!ok) {
return false;
}
if (connectionExists(localId)) {
return false;
}
m_connections.insert(localId);
m_linkIdToConnection.emplace(link.id, localId);
if (connectionId) {
*connectionId = localId;
}
Q_EMIT connectionCreated(localId);
return true;
}
void PipeWireGraphModel::removePipeWireConnection(uint32_t linkId)
{
auto it = m_linkIdToConnection.find(linkId);
if (it == m_linkIdToConnection.end()) {
return;
}
deleteConnection(it->second);
m_linkIdToConnection.erase(it);
}
bool PipeWireGraphModel::findConnectionForLink(uint32_t linkId, QtNodes::ConnectionId &connectionId) const
{
auto it = m_linkIdToConnection.find(linkId);
if (it == m_linkIdToConnection.end()) {
return false;
}
connectionId = it->second;
return true;
}
bool PipeWireGraphModel::updatePipeWireNode(const Potato::NodeInfo &node)
{
auto it = m_pwToNode.find(node.id);
if (it == m_pwToNode.end()) {
return false;
}
const QtNodes::NodeId nodeId = it->second;
m_nodes[nodeId] = node;
m_nodeSizes.erase(nodeId);
Q_EMIT nodeUpdated(nodeId);
return true;
}
const Potato::NodeInfo *PipeWireGraphModel::nodeInfo(QtNodes::NodeId nodeId) const
{
auto it = m_nodes.find(nodeId);
if (it == m_nodes.end()) {
return nullptr;
}
return &it->second;
}
bool PipeWireGraphModel::connectionIdForLink(const Potato::LinkInfo &link, QtNodes::ConnectionId &connectionId) const
{
bool ok = false;
connectionId = connectionFromPipeWire(link, &ok);
return ok;
}
void PipeWireGraphModel::reset()
{
m_connections.clear();
m_linkIdToConnection.clear();
m_nodes.clear();
m_pwToNode.clear();
m_positions.clear();
m_nodeSizes.clear();
m_nodeWidgets.clear();
m_nextNodeId = 1;
Q_EMIT modelReset();
}
void PipeWireGraphModel::autoArrange()
{
std::vector<QtNodes::NodeId> ids;
ids.reserve(m_nodes.size());
for (const auto &entry : m_nodes) {
ids.push_back(entry.first);
}
std::sort(ids.begin(), ids.end(), [this](QtNodes::NodeId a, QtNodes::NodeId b) {
const QString &left = m_nodes.at(a).stableId;
const QString &right = m_nodes.at(b).stableId;
return left < right;
});
int maxWidth = 0;
int maxHeight = 0;
for (const auto &entry : m_nodes) {
const Potato::NodeInfo &info = entry.second;
maxWidth = std::max(maxWidth, nodeWidthFor(info));
const int ports = std::max(info.inputPorts.size(), info.outputPorts.size());
const int height = std::max(110, 70 + (ports * 28));
maxHeight = std::max(maxHeight, height);
}
const int columns = 3;
const qreal spacingX = static_cast<qreal>(maxWidth + 160);
const qreal spacingY = static_cast<qreal>(maxHeight + 120);
for (int i = 0; i < static_cast<int>(ids.size()); ++i) {
const int row = i / columns;
const int col = i % columns;
const QPointF position(col * spacingX, row * spacingY);
m_positions[ids[i]] = position;
updateLayoutForNode(ids[i], position);
Q_EMIT nodePositionUpdated(ids[i]);
}
}
void PipeWireGraphModel::loadLayout()
{
m_layoutByStableId.clear();
m_hasViewState = false;
m_hasSplitterSizes = false;
m_splitterSizes.clear();
const QString path = layoutFilePath();
if (path.isEmpty()) {
return;
}
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
if (doc.isArray()) {
applyLayoutData(doc.array());
return;
}
if (doc.isObject()) {
const QJsonObject root = doc.object();
const QJsonArray nodes = root.value("nodes").toArray();
applyLayoutData(nodes);
const QJsonObject view = root.value("view").toObject();
if (!view.isEmpty()) {
m_viewScale = view.value("scale").toDouble(1.0);
const double x = view.value("center_x").toDouble(0.0);
const double y = view.value("center_y").toDouble(0.0);
m_viewCenter = QPointF(x, y);
m_hasViewState = true;
}
const QJsonArray splitter = root.value("splitter").toArray();
if (!splitter.isEmpty()) {
QList<int> sizes;
sizes.reserve(splitter.size());
for (const auto &value : splitter) {
sizes.append(value.toInt());
}
if (!sizes.isEmpty()) {
m_splitterSizes = sizes;
m_hasSplitterSizes = true;
}
}
}
}
QJsonObject PipeWireGraphModel::layoutJson() const
{
QJsonObject root;
QJsonArray nodes;
for (auto it = m_layoutByStableId.cbegin(); it != m_layoutByStableId.cend(); ++it) {
QJsonObject item;
item["id"] = it.key();
item["x"] = it.value().x();
item["y"] = it.value().y();
nodes.append(item);
}
root["nodes"] = nodes;
QJsonObject view;
view["scale"] = m_viewScale;
view["center_x"] = m_viewCenter.x();
view["center_y"] = m_viewCenter.y();
root["view"] = view;
if (m_hasSplitterSizes && !m_splitterSizes.isEmpty()) {
QJsonArray splitter;
for (const auto size : m_splitterSizes) {
splitter.append(size);
}
root["splitter"] = splitter;
}
return root;
}
void PipeWireGraphModel::applyLayoutJson(const QJsonObject &root)
{
m_layoutByStableId.clear();
m_hasViewState = false;
m_hasSplitterSizes = false;
m_splitterSizes.clear();
const QJsonArray nodes = root.value("nodes").toArray();
applyLayoutData(nodes);
const QJsonObject view = root.value("view").toObject();
if (!view.isEmpty()) {
m_viewScale = view.value("scale").toDouble(1.0);
const double x = view.value("center_x").toDouble(0.0);
const double y = view.value("center_y").toDouble(0.0);
m_viewCenter = QPointF(x, y);
m_hasViewState = true;
}
const QJsonArray splitter = root.value("splitter").toArray();
if (!splitter.isEmpty()) {
QList<int> sizes;
sizes.reserve(splitter.size());
for (const auto &value : splitter) {
sizes.append(value.toInt());
}
if (!sizes.isEmpty()) {
m_splitterSizes = sizes;
m_hasSplitterSizes = true;
}
}
for (const auto &entry : m_nodes) {
const QtNodes::NodeId nodeId = entry.first;
const Potato::NodeInfo &info = entry.second;
if (!info.stableId.isEmpty() && m_layoutByStableId.contains(info.stableId)) {
const QPointF position = m_layoutByStableId.value(info.stableId);
m_positions[nodeId] = position;
Q_EMIT nodePositionUpdated(nodeId);
}
}
}
QHash<QString, NodeVolumeState> PipeWireGraphModel::volumeStates() const
{
QHash<QString, NodeVolumeState> result;
for (const auto &entry : m_nodes) {
const Potato::NodeInfo &info = entry.second;
if (info.stableId.isEmpty()) {
continue;
}
if (!m_nodeVolumeState.contains(info.id)) {
continue;
}
result.insert(info.stableId, m_nodeVolumeState.value(info.id));
}
return result;
}
void PipeWireGraphModel::applyVolumeStates(const QHash<QString, NodeVolumeState> &states)
{
if (!m_controller) {
return;
}
const QVector<Potato::NodeInfo> nodes = m_controller->nodes();
for (const auto &node : nodes) {
if (node.stableId.isEmpty() || !states.contains(node.stableId)) {
continue;
}
const NodeVolumeState state = states.value(node.stableId);
setNodeVolumeState(node.id, state, false);
m_controller->setNodeVolume(node.id, state.volume, state.mute);
}
}
void PipeWireGraphModel::emitNodeVolumeChanged(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &current)
{
const bool changedVolume = qAbs(previous.volume - current.volume) > 0.0001f;
if (!changedVolume && previous.mute == current.mute) {
return;
}
Q_EMIT nodeVolumeChanged(nodeId, previous, current);
}
void PipeWireGraphModel::setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool notify)
{
const NodeVolumeState previous = m_nodeVolumeState.value(nodeId, NodeVolumeState{});
m_nodeVolumeState.insert(nodeId, state);
auto nodeIt = m_pwToNode.find(nodeId);
if (nodeIt == m_pwToNode.end()) {
return;
}
auto widgetIt = m_nodeWidgets.find(nodeIt->second);
if (widgetIt == m_nodeWidgets.end()) {
return;
}
QWidget *widget = widgetIt->second;
if (!widget) {
return;
}
if (auto *slider = widget->findChild<QSlider*>()) {
slider->blockSignals(true);
slider->setValue(static_cast<int>(state.volume * 100.0f));
slider->blockSignals(false);
}
if (auto *button = widget->findChild<QToolButton*>()) {
button->blockSignals(true);
button->setChecked(state.mute);
button->blockSignals(false);
}
if (notify) {
const bool changedVolume = std::abs(previous.volume - state.volume) > 0.0001f;
if (changedVolume || previous.mute != state.mute) {
Q_EMIT nodeVolumeChanged(nodeId, previous, state);
}
}
}
bool PipeWireGraphModel::nodeVolumeState(uint32_t nodeId, NodeVolumeState &state) const
{
if (!m_nodeVolumeState.contains(nodeId)) {
return false;
}
state = m_nodeVolumeState.value(nodeId);
return true;
}
void PipeWireGraphModel::saveLayout() const
{
const QString path = layoutFilePath();
if (path.isEmpty()) {
return;
}
writeLayoutToFile(path);
}
void PipeWireGraphModel::saveLayoutAs(const QString &path) const
{
if (path.isEmpty()) {
return;
}
writeLayoutToFile(path);
}
void PipeWireGraphModel::resetLayout()
{
m_layoutByStableId.clear();
autoArrange();
saveLayout();
}
QString PipeWireGraphModel::defaultLayoutPath() const
{
return layoutFilePath();
}
QtNodes::ConnectionId PipeWireGraphModel::connectionFromPipeWire(const Potato::LinkInfo &link, bool *ok) const
{
auto outIt = m_pwToNode.find(link.outputNodeId);
auto inIt = m_pwToNode.find(link.inputNodeId);
if (outIt == m_pwToNode.end() || inIt == m_pwToNode.end()) {
if (ok) {
*ok = false;
}
return QtNodes::ConnectionId{QtNodes::InvalidNodeId, 0, QtNodes::InvalidNodeId, 0};
}
const auto &outInfo = m_nodes.at(outIt->second);
const auto &inInfo = m_nodes.at(inIt->second);
QtNodes::PortIndex outIndex = 0;
QtNodes::PortIndex inIndex = 0;
if (!findPortIndex(outInfo, link.outputPortId, QtNodes::PortType::Out, outIndex)) {
if (ok) {
*ok = false;
}
return QtNodes::ConnectionId{QtNodes::InvalidNodeId, 0, QtNodes::InvalidNodeId, 0};
}
if (!findPortIndex(inInfo, link.inputPortId, QtNodes::PortType::In, inIndex)) {
if (ok) {
*ok = false;
}
return QtNodes::ConnectionId{QtNodes::InvalidNodeId, 0, QtNodes::InvalidNodeId, 0};
}
if (ok) {
*ok = true;
}
return QtNodes::ConnectionId{outIt->second, outIndex, inIt->second, inIndex};
}
bool PipeWireGraphModel::findPortIndex(const Potato::NodeInfo &node, uint32_t portId,
QtNodes::PortType type, QtNodes::PortIndex &index) const
{
if (type == QtNodes::PortType::In) {
for (int i = 0; i < node.inputPorts.size(); ++i) {
if (node.inputPorts.at(i).id == portId) {
index = static_cast<QtNodes::PortIndex>(i);
return true;
}
}
return false;
}
for (int i = 0; i < node.outputPorts.size(); ++i) {
if (node.outputPorts.at(i).id == portId) {
index = static_cast<QtNodes::PortIndex>(i);
return true;
}
}
return false;
}
QString PipeWireGraphModel::portLabel(const Potato::PortInfo &port) const
{
return port.name;
}
QPointF PipeWireGraphModel::nextPosition() const
{
const int index = static_cast<int>(m_positions.size());
int maxWidth = 0;
int maxHeight = 0;
for (const auto &entry : m_nodes) {
const Potato::NodeInfo &info = entry.second;
maxWidth = std::max(maxWidth, nodeWidthFor(info));
const int ports = std::max(info.inputPorts.size(), info.outputPorts.size());
const int height = std::max(110, 70 + (ports * 28));
maxHeight = std::max(maxHeight, height);
}
const int columns = 3;
const qreal spacingX = static_cast<qreal>(maxWidth + 160);
const qreal spacingY = static_cast<qreal>(maxHeight + 120);
const int row = index / columns;
const int col = index % columns;
return QPointF(col * spacingX, row * spacingY);
}
bool PipeWireGraphModel::hasOverlaps() const
{
struct NodeBox {
QtNodes::NodeId id;
QRectF rect;
};
std::vector<NodeBox> boxes;
boxes.reserve(m_nodes.size());
for (const auto &entry : m_nodes) {
const QtNodes::NodeId nodeId = entry.first;
const auto posIt = m_positions.find(nodeId);
if (posIt == m_positions.end()) {
continue;
}
const Potato::NodeInfo &info = entry.second;
const int width = nodeWidthFor(info);
const int ports = std::max(info.inputPorts.size(), info.outputPorts.size());
const int height = std::max(110, 70 + (ports * 28));
QRectF rect(posIt->second, QSizeF(width, height));
boxes.push_back({nodeId, rect});
}
for (size_t i = 0; i < boxes.size(); ++i) {
for (size_t j = i + 1; j < boxes.size(); ++j) {
if (boxes[i].rect.intersects(boxes[j].rect)) {
return true;
}
}
}
return false;
}
QString PipeWireGraphModel::layoutFilePath() const
{
const QString baseDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
if (baseDir.isEmpty()) {
return QString();
}
return baseDir + QString("/layout.json");
}
void PipeWireGraphModel::updateLayoutForNode(QtNodes::NodeId nodeId, QPointF position)
{
auto it = m_nodes.find(nodeId);
if (it == m_nodes.end()) {
return;
}
const QString stableId = it->second.stableId;
if (!stableId.isEmpty()) {
m_layoutByStableId.insert(stableId, position);
}
}
void PipeWireGraphModel::writeLayoutToFile(const QString &path) const
{
QJsonArray nodes;
for (const auto &entry : m_nodes) {
const auto &info = entry.second;
if (info.stableId.isEmpty()) {
continue;
}
auto posIt = m_positions.find(entry.first);
if (posIt == m_positions.end()) {
continue;
}
const QPointF pos = posIt->second;
QJsonObject obj;
obj["id"] = info.stableId;
obj["x"] = pos.x();
obj["y"] = pos.y();
nodes.append(obj);
}
QJsonObject root;
root["nodes"] = nodes;
QJsonObject view;
view["scale"] = m_viewScale;
view["center_x"] = m_viewCenter.x();
view["center_y"] = m_viewCenter.y();
root["view"] = view;
if (m_hasSplitterSizes && !m_splitterSizes.isEmpty()) {
QJsonArray splitter;
for (const auto size : m_splitterSizes) {
splitter.append(size);
}
root["splitter"] = splitter;
}
QFile file(path);
QDir().mkpath(QFileInfo(path).absolutePath());
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
}
void PipeWireGraphModel::applyLayoutData(const QJsonArray &nodes)
{
for (const auto &entry : nodes) {
const QJsonObject obj = entry.toObject();
const QString id = obj.value("id").toString();
const double x = obj.value("x").toDouble();
const double y = obj.value("y").toDouble();
if (!id.isEmpty()) {
m_layoutByStableId.insert(id, QPointF(x, y));
}
}
}
void PipeWireGraphModel::setViewState(double scale, const QPointF &center)
{
m_viewScale = scale;
m_viewCenter = center;
m_hasViewState = true;
}
bool PipeWireGraphModel::viewState(double &scale, QPointF &center) const
{
if (!m_hasViewState) {
return false;
}
scale = m_viewScale;
center = m_viewCenter;
return true;
}
void PipeWireGraphModel::setSplitterSizes(const QList<int> &sizes)
{
m_splitterSizes = sizes;
m_hasSplitterSizes = !sizes.isEmpty();
}
bool PipeWireGraphModel::splitterSizes(QList<int> &sizes) const
{
if (!m_hasSplitterSizes) {
return false;
}
sizes = m_splitterSizes;
return !sizes.isEmpty();
}
#include <QtNodes/StyleCollection>