Milestone1

This commit is contained in:
Joey Yakimowich-Payne 2026-01-27 15:24:29 -07:00
commit 4addf989cc
17 changed files with 2876 additions and 0 deletions

View file

@ -0,0 +1,652 @@
#include "PipeWireGraphModel.h"
#include <QtCore/QJsonArray>
#include <QtCore/QJsonDocument>
#include <QtCore/QJsonObject>
#include <QtCore/QObject>
#include <QtCore/QStandardPaths>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <algorithm>
#include <unordered_set>
#include <vector>
PipeWireGraphModel::PipeWireGraphModel(Potato::PipeWireController *controller, QObject *parent)
: QtNodes::AbstractGraphModel()
, m_controller(controller)
{
if (parent) {
setParent(parent);
}
}
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;
}
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:
return info.name;
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:
return QSize(180, 80);
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");
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;
}
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())) {
return portLabel(info.inputPorts.at(portIndex));
}
} else if (portType == QtNodes::PortType::Out) {
if (portIndex < static_cast<QtNodes::PortIndex>(info.outputPorts.size())) {
return portLabel(info.outputPorts.at(portIndex));
}
}
}
if (role == QtNodes::PortRole::ConnectionPolicyRole) {
if (portType == QtNodes::PortType::In) {
return QVariant::fromValue(QtNodes::ConnectionPolicy::One);
}
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;
}
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);
Q_EMIT nodeDeleted(nodeId);
return true;
}
QJsonObject PipeWireGraphModel::saveNode(QtNodes::NodeId const) const
{
return QJsonObject();
}
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;
}
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_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;
});
const int columns = 4;
const qreal spacingX = 260.0;
const qreal spacingY = 160.0;
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();
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;
}
}
}
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());
const int columns = 4;
const qreal spacingX = 260.0;
const qreal spacingY = 160.0;
const int row = index / columns;
const int col = index % columns;
return QPointF(col * spacingX, row * spacingY);
}
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;
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;
}