GUI Milestone 4

This commit is contained in:
Joey Yakimowich-Payne 2026-01-30 06:14:49 -07:00
commit a369381b6c
5 changed files with 177 additions and 16 deletions

View file

@ -74,20 +74,20 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library.
- [x] Remove from m_connections
- [x] Verify: Drag connection from output port to input port creates PipeWire link; delete removes it
- [ ] Milestone 4 - Context Menu and Virtual Node Creation
- [ ] Add context menu to GraphEditorWidget:
- [ ] Right-click on canvas (not on node) shows menu
- [ ] Menu items: "Create Virtual Sink", "Create Virtual Source"
- [ ] Implement "Create Virtual Sink":
- [ ] Prompt user for name (QInputDialog or inline text field)
- [ ] Call `Client::CreateVirtualSink(name, VirtualNodeOptions{})` with default options
- [ ] On success, node appears in graph at context menu position
- [ ] Implement "Create Virtual Source":
- [ ] Same as sink but call `Client::CreateVirtualSource()`
- [ ] Add context menu on nodes:
- [ ] Right-click on virtual node shows "Delete Node" option
- [ ] Call `Client::RemoveNode(nodeId)` and remove from graph
- [ ] Verify: Can create/delete virtual sinks and sources via right-click
- [x] Milestone 4 - Context Menu and Virtual Node Creation
- [x] Add context menu to GraphEditorWidget:
- [x] Right-click on canvas (not on node) shows menu
- [x] Menu items: "Create Virtual Sink", "Create Virtual Source"
- [x] Implement "Create Virtual Sink":
- [x] Prompt user for name (QInputDialog or inline text field)
- [x] Call `Client::CreateVirtualSink(name, VirtualNodeOptions{})` with default options
- [x] On success, node appears in graph at context menu position
- [x] Implement "Create Virtual Source":
- [x] Same as sink but call `Client::CreateVirtualSource()`
- [x] Add context menu on nodes:
- [x] Right-click on virtual node shows "Delete Node" option
- [x] Call `Client::RemoveNode(nodeId)` and remove from graph
- [x] Verify: Can create/delete virtual sinks and sources via right-click
- [ ] Milestone 5 - Layout Persistence and Polish
- [ ] Implement layout save/load:

View file

@ -4,6 +4,9 @@
#include <QtNodes/BasicGraphicsScene>
#include <QtNodes/GraphicsView>
#include <QInputDialog>
#include <QMenu>
#include <QMessageBox>
#include <QTimer>
#include <QVBoxLayout>
@ -18,6 +21,10 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(m_view);
m_view->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_view, &QWidget::customContextMenuRequested, this,
&GraphEditorWidget::onContextMenuRequested);
m_model->refreshFromClient();
m_refreshTimer = new QTimer(this);
@ -27,3 +34,105 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
}
void GraphEditorWidget::onRefreshTimer() { m_model->refreshFromClient(); }
void GraphEditorWidget::onContextMenuRequested(const QPoint &pos) {
QPointF scenePos = m_view->mapToScene(pos);
uint32_t hitPwNodeId = 0;
for (auto nodeId : m_model->allNodeIds()) {
const WarpNodeData *data = m_model->warpNodeData(nodeId);
if (!data) {
continue;
}
QPointF nodePos =
m_model->nodeData(nodeId, QtNodes::NodeRole::Position).toPointF();
QSize nodeSize =
m_model->nodeData(nodeId, QtNodes::NodeRole::Size).toSize();
QRectF nodeRect(nodePos, QSizeF(nodeSize));
if (nodeRect.contains(scenePos)) {
hitPwNodeId = data->info.id.value;
break;
}
}
QPoint screenPos = m_view->mapToGlobal(pos);
if (hitPwNodeId != 0) {
showNodeContextMenu(screenPos, hitPwNodeId);
} else {
showCanvasContextMenu(screenPos, scenePos);
}
}
void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
const QPointF &scenePos) {
QMenu menu;
QAction *createSink = menu.addAction(QStringLiteral("Create Virtual Sink"));
QAction *createSource =
menu.addAction(QStringLiteral("Create Virtual Source"));
QAction *chosen = menu.exec(screenPos);
if (chosen == createSink) {
createVirtualNode(true, scenePos);
} else if (chosen == createSource) {
createVirtualNode(false, scenePos);
}
}
void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos,
uint32_t pwNodeId) {
QtNodes::NodeId qtId = m_model->qtNodeIdForPw(pwNodeId);
const WarpNodeData *data = m_model->warpNodeData(qtId);
if (!data) {
return;
}
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
bool isVirtual =
type == WarpNodeType::kVirtualSink || type == WarpNodeType::kVirtualSource;
if (!isVirtual) {
return;
}
QMenu menu;
QAction *deleteAction = menu.addAction(QStringLiteral("Delete Node"));
QAction *chosen = menu.exec(screenPos);
if (chosen == deleteAction && m_client) {
m_client->RemoveNode(warppipe::NodeId{pwNodeId});
m_model->refreshFromClient();
}
}
void GraphEditorWidget::createVirtualNode(bool isSink,
const QPointF &scenePos) {
QString label = isSink ? QStringLiteral("Create Virtual Sink")
: QStringLiteral("Create Virtual Source");
bool ok = false;
QString name = QInputDialog::getText(this, label,
QStringLiteral("Node name:"),
QLineEdit::Normal, QString(), &ok);
if (!ok || name.trimmed().isEmpty()) {
return;
}
std::string nodeName = name.trimmed().toStdString();
m_model->setPendingPosition(nodeName, scenePos);
warppipe::Status status;
if (isSink) {
auto result = m_client->CreateVirtualSink(nodeName);
status = result.status;
} else {
auto result = m_client->CreateVirtualSource(nodeName);
status = result.status;
}
if (!status.ok()) {
QMessageBox::warning(this, QStringLiteral("Error"),
QString::fromStdString(status.message));
return;
}
m_model->refreshFromClient();
}

View file

@ -21,8 +21,13 @@ public:
private slots:
void onRefreshTimer();
void onContextMenuRequested(const QPoint &pos);
private:
void showCanvasContextMenu(const QPoint &screenPos, const QPointF &scenePos);
void showNodeContextMenu(const QPoint &screenPos, uint32_t pwNodeId);
void createVirtualNode(bool isSink, const QPointF &scenePos);
warppipe::Client *m_client = nullptr;
WarpGraphModel *m_model = nullptr;
QtNodes::BasicGraphicsScene *m_scene = nullptr;

View file

@ -318,11 +318,43 @@ void WarpGraphModel::refreshFromClient() {
for (const auto &nodeInfo : nodesResult.value) {
seenPwIds.insert(nodeInfo.id.value);
WarpNodeType nodeType = classifyNode(nodeInfo);
bool isStream = nodeType == WarpNodeType::kApplication;
if (isStream && nodeInfo.name.empty() &&
nodeInfo.application_name.empty()) {
continue;
}
auto existing = m_pwToQt.find(nodeInfo.id.value);
if (existing != m_pwToQt.end()) {
QtNodes::NodeId qtId = existing->second;
auto &data = m_nodes[qtId];
data.info = nodeInfo;
bool portsMissing =
data.inputPorts.empty() && data.outputPorts.empty();
if (portsMissing) {
auto portsResult = m_client->ListPorts(nodeInfo.id);
if (portsResult.ok() && !portsResult.value.empty()) {
for (const auto &port : portsResult.value) {
if (port.is_input) {
data.inputPorts.push_back(port);
} else {
data.outputPorts.push_back(port);
}
}
std::sort(data.inputPorts.begin(), data.inputPorts.end(),
[](const auto &a, const auto &b) {
return a.name < b.name;
});
std::sort(data.outputPorts.begin(), data.outputPorts.end(),
[](const auto &a, const auto &b) {
return a.name < b.name;
});
Q_EMIT nodeUpdated(qtId);
}
}
if (m_ghostNodes.erase(qtId)) {
Q_EMIT nodeUpdated(qtId);
}
@ -392,7 +424,14 @@ void WarpGraphModel::refreshFromClient() {
auto [nodeIt, _] = m_nodes.emplace(qtId, std::move(data));
m_pwToQt.emplace(nodeInfo.id.value, qtId);
m_positions.emplace(qtId, nextPosition(nodeIt->second));
auto pendingIt = m_pendingPositions.find(nodeInfo.name);
if (pendingIt != m_pendingPositions.end()) {
m_positions.emplace(qtId, pendingIt->second);
m_pendingPositions.erase(pendingIt);
} else {
m_positions.emplace(qtId, nextPosition(nodeIt->second));
}
Q_EMIT nodeCreated(qtId);
}
@ -510,6 +549,11 @@ QtNodes::NodeId WarpGraphModel::qtNodeIdForPw(uint32_t pwNodeId) const {
return 0;
}
void WarpGraphModel::setPendingPosition(const std::string &nodeName,
QPointF pos) {
m_pendingPositions[nodeName] = pos;
}
QString WarpGraphModel::captionForNode(const warppipe::NodeInfo &info) {
if (!info.description.empty()) {
return QString::fromStdString(info.description);

View file

@ -64,10 +64,11 @@ public:
const WarpNodeData *warpNodeData(QtNodes::NodeId nodeId) const;
QtNodes::NodeId qtNodeIdForPw(uint32_t pwNodeId) const;
bool isGhost(QtNodes::NodeId nodeId) const;
void setPendingPosition(const std::string &nodeName, QPointF pos);
static WarpNodeType classifyNode(const warppipe::NodeInfo &info);
private:
static QString captionForNode(const warppipe::NodeInfo &info);
static WarpNodeType classifyNode(const warppipe::NodeInfo &info);
static QVariant styleForNode(WarpNodeType type, bool ghost);
QPointF nextPosition(const WarpNodeData &data);
static QSize estimateNodeSize(const WarpNodeData &data);
@ -93,4 +94,6 @@ private:
double m_rowMaxHeight = 0.0;
bool m_refreshing = false;
std::unordered_map<std::string, QPointF> m_pendingPositions;
};