1184 lines
35 KiB
C++
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 ¤t)
|
|
{
|
|
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 ¢er)
|
|
{
|
|
m_viewScale = scale;
|
|
m_viewCenter = center;
|
|
m_hasViewState = true;
|
|
}
|
|
|
|
bool PipeWireGraphModel::viewState(double &scale, QPointF ¢er) 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>
|