diff --git a/CMakeLists.txt b/CMakeLists.txt index d8bc2d7..9d8f1ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,8 @@ if(WARPPIPE_BUILD_GUI) add_executable(warppipe-gui gui/main.cpp + gui/WarpGraphModel.cpp + gui/GraphEditorWidget.cpp ) target_link_libraries(warppipe-gui PRIVATE diff --git a/GUI_PLAN.md b/GUI_PLAN.md index 9723843..cb8daaf 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -15,25 +15,25 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. - [x] Create minimal main.cpp with QApplication + QMainWindow - [x] Verify GUI launches and shows empty window -- [ ] Milestone 1 - Core Model Integration - - [ ] Create `WarpGraphModel : public QtNodes::AbstractGraphModel` - - [ ] Implement AbstractGraphModel interface (newNodeId, allNodeIds, nodeData, portData, etc.) - - [ ] Add `warppipe::Client*` member, connect to PipeWire on construction - - [ ] Map `warppipe::NodeInfo` to QtNodes NodeId via internal maps (m_nodes, m_pwToNode) - - [ ] Implement node refresh: call Client::ListNodes() and sync graph - - [ ] Create `GraphEditorWidget : public QWidget` - - [ ] Instantiate WarpGraphModel, QtNodes::BasicGraphicsScene, QtNodes::GraphicsView - - [ ] Lay out view in widget - - [ ] Connect model signals to refresh handlers - - [ ] Synthesize display title from NodeInfo: - - [ ] If `application_name` is non-empty and differs from `name`, use `application_name` as title - - [ ] Otherwise use `name` field - - [ ] Store synthesized title in `nodeData(NodeRole::Caption)` - - [ ] Map warppipe ports to QtNodes ports: - - [ ] Input ports (is_input=true) appear on LEFT side of node (QtNodes PortType::In) - - [ ] Output ports (is_input=false) appear on RIGHT side of node (QtNodes PortType::Out) - - [ ] Use port name from PortInfo as port label - - [ ] Verify nodes appear in graph view with correct titles and ports +- [x] Milestone 1 - Core Model Integration + - [x] Create `WarpGraphModel : public QtNodes::AbstractGraphModel` + - [x] Implement AbstractGraphModel interface (newNodeId, allNodeIds, nodeData, portData, etc.) + - [x] Add `warppipe::Client*` member, connect to PipeWire on construction + - [x] Map `warppipe::NodeInfo` to QtNodes NodeId via internal maps (m_nodes, m_pwToNode) + - [x] Implement node refresh: call Client::ListNodes() and sync graph + - [x] Create `GraphEditorWidget : public QWidget` + - [x] Instantiate WarpGraphModel, QtNodes::BasicGraphicsScene, QtNodes::GraphicsView + - [x] Lay out view in widget + - [x] Connect model signals to refresh handlers + - [x] Synthesize display title from NodeInfo: + - [x] If `application_name` is non-empty and differs from `name`, use `application_name` as title + - [x] Otherwise use `name` field + - [x] Store synthesized title in `nodeData(NodeRole::Caption)` + - [x] Map warppipe ports to QtNodes ports: + - [x] Input ports (is_input=true) appear on LEFT side of node (QtNodes PortType::In) + - [x] Output ports (is_input=false) appear on RIGHT side of node (QtNodes PortType::Out) + - [x] Use port name from PortInfo as port label + - [x] Verify nodes appear in graph view with correct titles and ports - [ ] Milestone 2 - Visual Styling and Node Types - [ ] Define node type classification based on `media_class`: diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp new file mode 100644 index 0000000..b2a3ef0 --- /dev/null +++ b/gui/GraphEditorWidget.cpp @@ -0,0 +1,29 @@ +#include "GraphEditorWidget.h" +#include "WarpGraphModel.h" + +#include +#include + +#include +#include + +GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, + QWidget *parent) + : QWidget(parent), m_client(client) { + m_model = new WarpGraphModel(client, this); + m_scene = new QtNodes::BasicGraphicsScene(*m_model, this); + m_view = new QtNodes::GraphicsView(m_scene); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(m_view); + + m_model->refreshFromClient(); + + m_refreshTimer = new QTimer(this); + connect(m_refreshTimer, &QTimer::timeout, this, + &GraphEditorWidget::onRefreshTimer); + m_refreshTimer->start(500); +} + +void GraphEditorWidget::onRefreshTimer() { m_model->refreshFromClient(); } diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h new file mode 100644 index 0000000..1271297 --- /dev/null +++ b/gui/GraphEditorWidget.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include + +namespace QtNodes { +class BasicGraphicsScene; +class GraphicsView; +} // namespace QtNodes + +class WarpGraphModel; +class QTimer; + +class GraphEditorWidget : public QWidget { + Q_OBJECT + +public: + explicit GraphEditorWidget(warppipe::Client *client, + QWidget *parent = nullptr); + +private slots: + void onRefreshTimer(); + +private: + warppipe::Client *m_client = nullptr; + WarpGraphModel *m_model = nullptr; + QtNodes::BasicGraphicsScene *m_scene = nullptr; + QtNodes::GraphicsView *m_view = nullptr; + QTimer *m_refreshTimer = nullptr; +}; diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp new file mode 100644 index 0000000..c85895f --- /dev/null +++ b/gui/WarpGraphModel.cpp @@ -0,0 +1,409 @@ +#include "WarpGraphModel.h" + +#include +#include + +#include +#include + +WarpGraphModel::WarpGraphModel(warppipe::Client *client, QObject *parent) + : QtNodes::AbstractGraphModel(), m_client(client) { + if (parent) { + setParent(parent); + } +} + +QtNodes::NodeId WarpGraphModel::newNodeId() { return m_nextNodeId++; } + +std::unordered_set WarpGraphModel::allNodeIds() const { + std::unordered_set ids; + ids.reserve(m_nodes.size()); + for (const auto &entry : m_nodes) { + ids.insert(entry.first); + } + return ids; +} + +std::unordered_set +WarpGraphModel::allConnectionIds(QtNodes::NodeId const nodeId) const { + std::unordered_set result; + for (const auto &conn : m_connections) { + if (conn.outNodeId == nodeId || conn.inNodeId == nodeId) { + result.insert(conn); + } + } + return result; +} + +std::unordered_set +WarpGraphModel::connections(QtNodes::NodeId nodeId, + QtNodes::PortType portType, + QtNodes::PortIndex portIndex) const { + std::unordered_set 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 WarpGraphModel::connectionExists( + QtNodes::ConnectionId const connectionId) const { + return m_connections.find(connectionId) != m_connections.end(); +} + +QtNodes::NodeId WarpGraphModel::addNode(QString const) { + return newNodeId(); +} + +bool WarpGraphModel::connectionPossible( + QtNodes::ConnectionId const connectionId) const { + if (!nodeExists(connectionId.outNodeId) || + !nodeExists(connectionId.inNodeId)) { + return false; + } + if (connectionExists(connectionId)) { + return false; + } + + auto outIt = m_nodes.find(connectionId.outNodeId); + auto inIt = m_nodes.find(connectionId.inNodeId); + if (outIt == m_nodes.end() || inIt == m_nodes.end()) { + return false; + } + + auto outIdx = static_cast(connectionId.outPortIndex); + auto inIdx = static_cast(connectionId.inPortIndex); + if (outIdx >= outIt->second.outputPorts.size()) { + return false; + } + if (inIdx >= inIt->second.inputPorts.size()) { + return false; + } + + return true; +} + +void WarpGraphModel::addConnection( + QtNodes::ConnectionId const connectionId) { + if (!connectionPossible(connectionId)) { + return; + } + m_connections.insert(connectionId); + Q_EMIT connectionCreated(connectionId); +} + +bool WarpGraphModel::nodeExists(QtNodes::NodeId const nodeId) const { + return m_nodes.find(nodeId) != m_nodes.end(); +} + +QVariant WarpGraphModel::nodeData(QtNodes::NodeId nodeId, + QtNodes::NodeRole role) const { + auto it = m_nodes.find(nodeId); + if (it == m_nodes.end()) { + return QVariant(); + } + + const auto &data = it->second; + + switch (role) { + case QtNodes::NodeRole::Caption: + return captionForNode(data.info); + 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_sizes.find(nodeId); + if (sizeIt != m_sizes.end()) { + return sizeIt->second; + } + int maxPorts = static_cast( + std::max(data.inputPorts.size(), data.outputPorts.size())); + int height = std::max(80, 50 + maxPorts * 28); + return QSize(200, height); + } + case QtNodes::NodeRole::InPortCount: + return static_cast(data.inputPorts.size()); + case QtNodes::NodeRole::OutPortCount: + return static_cast(data.outputPorts.size()); + case QtNodes::NodeRole::Type: + return QString("PipeWire"); + default: + return QVariant(); + } +} + +bool WarpGraphModel::setNodeData(QtNodes::NodeId nodeId, + QtNodes::NodeRole role, QVariant value) { + if (!nodeExists(nodeId)) { + return false; + } + + if (role == QtNodes::NodeRole::Position) { + m_positions[nodeId] = value.toPointF(); + Q_EMIT nodePositionUpdated(nodeId); + return true; + } + + if (role == QtNodes::NodeRole::Size) { + m_sizes[nodeId] = value.toSize(); + return true; + } + + return false; +} + +QVariant WarpGraphModel::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 &data = 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) { + auto idx = static_cast(portIndex); + if (idx < data.inputPorts.size()) { + return QString::fromStdString(data.inputPorts[idx].name); + } + } else if (portType == QtNodes::PortType::Out) { + auto idx = static_cast(portIndex); + if (idx < data.outputPorts.size()) { + return QString::fromStdString(data.outputPorts[idx].name); + } + } + } + + if (role == QtNodes::PortRole::ConnectionPolicyRole) { + return QVariant::fromValue(QtNodes::ConnectionPolicy::Many); + } + + return QVariant(); +} + +bool WarpGraphModel::setPortData(QtNodes::NodeId, QtNodes::PortType, + QtNodes::PortIndex, QVariant const &, + QtNodes::PortRole) { + return false; +} + +bool WarpGraphModel::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 WarpGraphModel::deleteNode(QtNodes::NodeId const nodeId) { + if (!nodeExists(nodeId)) { + return false; + } + + std::vector 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_sizes.erase(nodeId); + Q_EMIT nodeDeleted(nodeId); + return true; +} + +QJsonObject WarpGraphModel::saveNode(QtNodes::NodeId const nodeId) const { + QJsonObject obj; + obj["id"] = static_cast(nodeId); + QPointF pos = nodeData(nodeId, QtNodes::NodeRole::Position).toPointF(); + QJsonObject posObj; + posObj["x"] = pos.x(); + posObj["y"] = pos.y(); + obj["position"] = posObj; + return obj; +} + +void WarpGraphModel::loadNode(QJsonObject const &) {} + +void WarpGraphModel::refreshFromClient() { + if (!m_client) { + return; + } + + auto nodesResult = m_client->ListNodes(); + if (!nodesResult.ok()) { + return; + } + + std::unordered_set seenPwIds; + + for (const auto &nodeInfo : nodesResult.value) { + seenPwIds.insert(nodeInfo.id.value); + + auto existing = m_pwToQt.find(nodeInfo.id.value); + if (existing != m_pwToQt.end()) { + auto &data = m_nodes[existing->second]; + data.info = nodeInfo; + continue; + } + + auto portsResult = m_client->ListPorts(nodeInfo.id); + std::vector inputs; + std::vector outputs; + if (portsResult.ok()) { + for (const auto &port : portsResult.value) { + if (port.is_input) { + inputs.push_back(port); + } else { + outputs.push_back(port); + } + } + std::sort(inputs.begin(), inputs.end(), + [](const auto &a, const auto &b) { return a.name < b.name; }); + std::sort(outputs.begin(), outputs.end(), + [](const auto &a, const auto &b) { return a.name < b.name; }); + } + + QtNodes::NodeId qtId = newNodeId(); + WarpNodeData data; + data.info = nodeInfo; + data.inputPorts = std::move(inputs); + data.outputPorts = std::move(outputs); + + m_nodes.emplace(qtId, std::move(data)); + m_pwToQt.emplace(nodeInfo.id.value, qtId); + m_positions.emplace(qtId, nextPosition()); + + Q_EMIT nodeCreated(qtId); + } + + auto linksResult = m_client->ListLinks(); + if (linksResult.ok()) { + std::unordered_set seenLinkIds; + for (const auto &link : linksResult.value) { + seenLinkIds.insert(link.id.value); + + if (m_linkIdToConn.find(link.id.value) != m_linkIdToConn.end()) { + continue; + } + + auto outNodeIt = m_pwToQt.end(); + auto inNodeIt = m_pwToQt.end(); + QtNodes::PortIndex outPortIdx = 0; + QtNodes::PortIndex inPortIdx = 0; + bool found = false; + + for (const auto &[qtId, nodeData] : m_nodes) { + for (size_t i = 0; i < nodeData.outputPorts.size(); ++i) { + if (nodeData.outputPorts[i].id.value == link.output_port.value) { + outNodeIt = m_pwToQt.find(nodeData.info.id.value); + outPortIdx = static_cast(i); + } + } + for (size_t i = 0; i < nodeData.inputPorts.size(); ++i) { + if (nodeData.inputPorts[i].id.value == link.input_port.value) { + inNodeIt = m_pwToQt.find(nodeData.info.id.value); + inPortIdx = static_cast(i); + } + } + } + + if (outNodeIt != m_pwToQt.end() && inNodeIt != m_pwToQt.end()) { + found = true; + } + + if (found) { + QtNodes::ConnectionId connId{outNodeIt->second, outPortIdx, + inNodeIt->second, inPortIdx}; + if (m_connections.find(connId) == m_connections.end()) { + m_connections.insert(connId); + m_linkIdToConn.emplace(link.id.value, connId); + Q_EMIT connectionCreated(connId); + } + } + } + + std::vector staleLinkIds; + for (const auto &[linkId, connId] : m_linkIdToConn) { + if (seenLinkIds.find(linkId) == seenLinkIds.end()) { + staleLinkIds.push_back(linkId); + } + } + for (uint32_t linkId : staleLinkIds) { + auto it = m_linkIdToConn.find(linkId); + if (it != m_linkIdToConn.end()) { + auto connIt = m_connections.find(it->second); + if (connIt != m_connections.end()) { + QtNodes::ConnectionId connId = it->second; + m_connections.erase(connIt); + Q_EMIT connectionDeleted(connId); + } + m_linkIdToConn.erase(it); + } + } + } +} + +const WarpNodeData * +WarpGraphModel::warpNodeData(QtNodes::NodeId nodeId) const { + auto it = m_nodes.find(nodeId); + if (it != m_nodes.end()) { + return &it->second; + } + return nullptr; +} + +QtNodes::NodeId WarpGraphModel::qtNodeIdForPw(uint32_t pwNodeId) const { + auto it = m_pwToQt.find(pwNodeId); + if (it != m_pwToQt.end()) { + return it->second; + } + return 0; +} + +QString WarpGraphModel::captionForNode(const warppipe::NodeInfo &info) { + if (!info.application_name.empty() && info.application_name != info.name) { + return QString::fromStdString(info.application_name); + } + return QString::fromStdString(info.name); +} + +QPointF WarpGraphModel::nextPosition() const { + int count = static_cast(m_nodes.size()); + double x = (count % 4) * 280.0; + double y = (count / 4) * 200.0; + return QPointF(x, y); +} diff --git a/gui/WarpGraphModel.h b/gui/WarpGraphModel.h new file mode 100644 index 0000000..f56119a --- /dev/null +++ b/gui/WarpGraphModel.h @@ -0,0 +1,73 @@ +#pragma once + +#include + +#include +#include + +#include +#include +#include +#include + +#include +#include + +struct WarpNodeData { + warppipe::NodeInfo info; + std::vector inputPorts; + std::vector outputPorts; +}; + +class WarpGraphModel : public QtNodes::AbstractGraphModel { + Q_OBJECT + +public: + explicit WarpGraphModel(warppipe::Client *client, QObject *parent = nullptr); + + QtNodes::NodeId newNodeId() override; + std::unordered_set allNodeIds() const override; + std::unordered_set allConnectionIds( + QtNodes::NodeId nodeId) const override; + std::unordered_set connections( + QtNodes::NodeId nodeId, QtNodes::PortType portType, + QtNodes::PortIndex portIndex) const override; + bool connectionExists(QtNodes::ConnectionId const connectionId) const override; + QtNodes::NodeId addNode(QString const nodeType = QString()) override; + bool connectionPossible(QtNodes::ConnectionId const connectionId) const override; + void addConnection(QtNodes::ConnectionId const connectionId) override; + bool nodeExists(QtNodes::NodeId const nodeId) const override; + QVariant nodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole role) const override; + bool setNodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole role, + QVariant value) override; + QVariant portData(QtNodes::NodeId nodeId, QtNodes::PortType portType, + QtNodes::PortIndex portIndex, + QtNodes::PortRole role) const override; + bool setPortData(QtNodes::NodeId nodeId, QtNodes::PortType portType, + QtNodes::PortIndex portIndex, QVariant const &value, + QtNodes::PortRole role = QtNodes::PortRole::Data) override; + bool deleteConnection(QtNodes::ConnectionId const connectionId) override; + bool deleteNode(QtNodes::NodeId const nodeId) override; + QJsonObject saveNode(QtNodes::NodeId const) const override; + void loadNode(QJsonObject const &) override; + + void refreshFromClient(); + const WarpNodeData *warpNodeData(QtNodes::NodeId nodeId) const; + QtNodes::NodeId qtNodeIdForPw(uint32_t pwNodeId) const; + +private: + static QString captionForNode(const warppipe::NodeInfo &info); + QPointF nextPosition() const; + + warppipe::Client *m_client = nullptr; + QtNodes::NodeId m_nextNodeId = 1; + + std::unordered_map m_nodes; + std::unordered_map m_pwToQt; + + std::unordered_set m_connections; + std::unordered_map m_linkIdToConn; + + std::unordered_map m_positions; + std::unordered_map m_sizes; +}; diff --git a/gui/main.cpp b/gui/main.cpp index d91f340..ac16edb 100644 --- a/gui/main.cpp +++ b/gui/main.cpp @@ -1,14 +1,44 @@ +#include + +#include "GraphEditorWidget.h" + +#include #include +#include #include +#include + int main(int argc, char *argv[]) { QApplication app(argc, argv); QCoreApplication::setApplicationName("Warppipe"); QCoreApplication::setApplicationVersion("0.1.0"); + warppipe::ConnectionOptions opts; + opts.application_name = "warppipe-gui"; + + auto result = warppipe::Client::Create(opts); + if (!result.ok()) { + std::cerr << "warppipe: failed to connect: " << result.status.message + << "\n"; + return 1; + } + + auto &client = result.value; + QMainWindow window; window.setWindowTitle("Warppipe — Audio Router"); + + auto *editor = new GraphEditorWidget(client.get(), &window); + window.setCentralWidget(editor); window.resize(1280, 720); + + auto *closeAction = new QAction(&window); + closeAction->setShortcut(QKeySequence::Quit); + QObject::connect(closeAction, &QAction::triggered, &window, + &QMainWindow::close); + window.addAction(closeAction); + window.show(); return app.exec();