GUI Milestone 8a
This commit is contained in:
parent
d178e8765b
commit
a52f82d67b
7 changed files with 586 additions and 31 deletions
|
|
@ -93,6 +93,7 @@ if(WARPPIPE_BUILD_GUI)
|
|||
add_executable(warppipe-gui-tests
|
||||
tests/gui/warppipe_gui_tests.cpp
|
||||
gui/WarpGraphModel.cpp
|
||||
gui/GraphEditorWidget.cpp
|
||||
)
|
||||
|
||||
target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING)
|
||||
|
|
|
|||
62
GUI_PLAN.md
62
GUI_PLAN.md
|
|
@ -159,37 +159,37 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library.
|
|||
- [x] Screenshot tests gated behind `WARPPIPE_GUI_VISUAL_TESTS` CMake option (default OFF)
|
||||
- [x] `ctest --test-dir build` runs model + GUI tests
|
||||
|
||||
- [ ] Milestone 8a - Undo/Redo, Clipboard, and Keyboard Shortcuts
|
||||
- [ ] Integrate `QUndoStack` via `BasicGraphicsScene::undoStack()`
|
||||
- [ ] Undo/Redo already works for connection create/delete (built-in QtNodes `ConnectCommand`/`DisconnectCommand`)
|
||||
- [ ] Verify Ctrl+Z / Ctrl+Shift+Z (or Ctrl+Y) work out of the box for connections
|
||||
- [ ] Implement `DeleteVirtualNodeCommand : QUndoCommand`
|
||||
- [ ] `redo()`: destroy virtual node via `Client::RemoveNode()`
|
||||
- [ ] `undo()`: re-create virtual node via `Client::CreateVirtualSink/Source()` with same name/channels/rate
|
||||
- [ ] Store node position and restore on undo
|
||||
- [ ] Implement `deleteSelection()` for Del key
|
||||
- [ ] Collect selected `NodeGraphicsObject` items from `m_scene->selectedItems()`
|
||||
- [ ] Virtual nodes → push `DeleteVirtualNodeCommand` onto undo stack
|
||||
- [ ] Non-virtual nodes → push `QtNodes::DeleteCommand` (removes from graph only, not PipeWire)
|
||||
- [ ] Connection-only selection → push `QtNodes::DeleteCommand`
|
||||
- [ ] Implement `copySelection()` (Ctrl+C)
|
||||
- [ ] Serialize selected virtual nodes to JSON: stable_id, name, media_class, channels, rate, position
|
||||
- [ ] Include links between selected nodes (source stable_id:port_name → target stable_id:port_name)
|
||||
- [ ] Set `QClipboard` with custom MIME type `application/warppipe-virtual-graph`
|
||||
- [ ] Implement `pasteSelection()` (Ctrl+V)
|
||||
- [ ] Parse clipboard JSON, create new virtual nodes with " Copy" name suffix
|
||||
- [ ] Position pasted nodes at offset from originals
|
||||
- [ ] Deferred link resolution via `PendingPasteLink` queue (nodes may not exist yet)
|
||||
- [ ] `tryResolvePendingLinks()` called on node add to wire up deferred links
|
||||
- [ ] Implement `duplicateSelection()` (Ctrl+D) — copy + paste with (40, 40) offset
|
||||
- [ ] Register keyboard shortcuts on `m_view`:
|
||||
- [ ] Del → `deleteSelection()`
|
||||
- [ ] Ctrl+C → `copySelection()`
|
||||
- [ ] Ctrl+V → `pasteSelection()`
|
||||
- [ ] Ctrl+D → `duplicateSelection()`
|
||||
- [ ] Ctrl+L → auto-arrange + zoom fit
|
||||
- [ ] Remove default QtNodes copy/paste actions to avoid conflicts
|
||||
- [ ] Add tests for undo/redo command state (push command → undo → verify node re-created → redo → verify deleted)
|
||||
- [x] Milestone 8a - Undo/Redo, Clipboard, and Keyboard Shortcuts
|
||||
- [x] Integrate `QUndoStack` via `BasicGraphicsScene::undoStack()`
|
||||
- [x] Undo/Redo already works for connection create/delete (built-in QtNodes `ConnectCommand`/`DisconnectCommand`)
|
||||
- [x] Verify Ctrl+Z / Ctrl+Shift+Z (or Ctrl+Y) work out of the box for connections
|
||||
- [x] Implement `DeleteVirtualNodeCommand : QUndoCommand`
|
||||
- [x] `redo()`: destroy virtual node via `Client::RemoveNode()`
|
||||
- [x] `undo()`: re-create virtual node via `Client::CreateVirtualSink/Source()` with same name/channels/rate
|
||||
- [x] Store node position and restore on undo
|
||||
- [x] Implement `deleteSelection()` for Del key
|
||||
- [x] Collect selected `NodeGraphicsObject` items from `m_scene->selectedItems()`
|
||||
- [x] Virtual nodes → push `DeleteVirtualNodeCommand` onto undo stack
|
||||
- [x] Non-virtual nodes → push `QtNodes::DeleteCommand` (removes from graph only, not PipeWire)
|
||||
- [x] Connection-only selection → push `QtNodes::DeleteCommand`
|
||||
- [x] Implement `copySelection()` (Ctrl+C)
|
||||
- [x] Serialize selected virtual nodes to JSON: stable_id, name, media_class, channels, rate, position
|
||||
- [x] Include links between selected nodes (source stable_id:port_name → target stable_id:port_name)
|
||||
- [x] Set `QClipboard` with custom MIME type `application/warppipe-virtual-graph`
|
||||
- [x] Implement `pasteSelection()` (Ctrl+V)
|
||||
- [x] Parse clipboard JSON, create new virtual nodes with " Copy" name suffix
|
||||
- [x] Position pasted nodes at offset from originals
|
||||
- [x] Deferred link resolution via `PendingPasteLink` queue (nodes may not exist yet)
|
||||
- [x] `tryResolvePendingLinks()` called on node add to wire up deferred links
|
||||
- [x] Implement `duplicateSelection()` (Ctrl+D) — copy + paste with (40, 40) offset
|
||||
- [x] Register keyboard shortcuts on `m_view`:
|
||||
- [x] Del → `deleteSelection()`
|
||||
- [x] Ctrl+C → `copySelection()`
|
||||
- [x] Ctrl+V → `pasteSelection()`
|
||||
- [x] Ctrl+D → `duplicateSelection()`
|
||||
- [x] Ctrl+L → auto-arrange + zoom fit
|
||||
- [x] Remove default QtNodes copy/paste actions to avoid conflicts
|
||||
- [x] Add tests for undo/redo command state (push command → undo → verify node re-created → redo → verify deleted)
|
||||
- [ ] Milestone 8b - View and Layout Enhancements
|
||||
- [ ] Add "Zoom Fit All" context menu action → `m_view->zoomFitAll()`
|
||||
- [ ] Add "Zoom Fit Selected" context menu action → `m_view->zoomFitSelected()`
|
||||
|
|
|
|||
|
|
@ -4,17 +4,111 @@
|
|||
#include <QtNodes/BasicGraphicsScene>
|
||||
#include <QtNodes/ConnectionStyle>
|
||||
#include <QtNodes/GraphicsView>
|
||||
#include <QtNodes/internal/NodeGraphicsObject.hpp>
|
||||
#include <QtNodes/internal/ConnectionGraphicsObject.hpp>
|
||||
#include <QtNodes/internal/UndoCommands.hpp>
|
||||
|
||||
#include <QAction>
|
||||
#include <QClipboard>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QGraphicsItem>
|
||||
#include <QGuiApplication>
|
||||
#include <QInputDialog>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QMenu>
|
||||
#include <QMessageBox>
|
||||
#include <QMimeData>
|
||||
#include <QPixmap>
|
||||
#include <QStandardPaths>
|
||||
#include <QTimer>
|
||||
#include <QUndoCommand>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
class DeleteVirtualNodeCommand : public QUndoCommand {
|
||||
public:
|
||||
struct Snapshot {
|
||||
uint32_t pwNodeId;
|
||||
QtNodes::NodeId qtNodeId;
|
||||
std::string name;
|
||||
std::string mediaClass;
|
||||
QPointF position;
|
||||
};
|
||||
|
||||
DeleteVirtualNodeCommand(GraphEditorWidget *widget,
|
||||
const QList<QtNodes::NodeId> &nodeIds)
|
||||
: m_widget(widget) {
|
||||
WarpGraphModel *model = widget->m_model;
|
||||
for (auto nodeId : nodeIds) {
|
||||
const WarpNodeData *data = model->warpNodeData(nodeId);
|
||||
if (!data)
|
||||
continue;
|
||||
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
|
||||
if (type != WarpNodeType::kVirtualSink &&
|
||||
type != WarpNodeType::kVirtualSource)
|
||||
continue;
|
||||
|
||||
Snapshot snap;
|
||||
snap.pwNodeId = data->info.id.value;
|
||||
snap.qtNodeId = nodeId;
|
||||
snap.name = data->info.name;
|
||||
snap.mediaClass = data->info.media_class;
|
||||
snap.position =
|
||||
model->nodeData(nodeId, QtNodes::NodeRole::Position).toPointF();
|
||||
m_snapshots.push_back(snap);
|
||||
}
|
||||
setText(QStringLiteral("Delete Virtual Node"));
|
||||
}
|
||||
|
||||
void undo() override {
|
||||
if (!m_widget)
|
||||
return;
|
||||
auto *client = m_widget->m_client;
|
||||
auto *model = m_widget->m_model;
|
||||
if (!client || !model)
|
||||
return;
|
||||
|
||||
for (const auto &snap : m_snapshots) {
|
||||
model->setPendingPosition(snap.name, snap.position);
|
||||
bool isSink = snap.mediaClass == "Audio/Sink" ||
|
||||
snap.mediaClass == "Audio/Duplex";
|
||||
if (isSink) {
|
||||
client->CreateVirtualSink(snap.name);
|
||||
} else {
|
||||
client->CreateVirtualSource(snap.name);
|
||||
}
|
||||
}
|
||||
model->refreshFromClient();
|
||||
}
|
||||
|
||||
void redo() override {
|
||||
if (!m_widget)
|
||||
return;
|
||||
auto *client = m_widget->m_client;
|
||||
auto *model = m_widget->m_model;
|
||||
if (!client || !model)
|
||||
return;
|
||||
|
||||
for (auto &snap : m_snapshots) {
|
||||
uint32_t currentPwId = model->findPwNodeIdByName(snap.name);
|
||||
if (currentPwId != 0) {
|
||||
snap.pwNodeId = currentPwId;
|
||||
client->RemoveNode(warppipe::NodeId{currentPwId});
|
||||
}
|
||||
}
|
||||
model->refreshFromClient();
|
||||
}
|
||||
|
||||
private:
|
||||
GraphEditorWidget *m_widget = nullptr;
|
||||
std::vector<Snapshot> m_snapshots;
|
||||
};
|
||||
|
||||
GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
||||
QWidget *parent)
|
||||
: QWidget(parent), m_client(client) {
|
||||
|
|
@ -50,6 +144,50 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
|||
connect(m_view, &QWidget::customContextMenuRequested, this,
|
||||
&GraphEditorWidget::onContextMenuRequested);
|
||||
|
||||
removeDefaultActions();
|
||||
|
||||
auto *deleteAction =
|
||||
new QAction(QStringLiteral("Delete Selection"), m_view);
|
||||
deleteAction->setShortcut(QKeySequence::Delete);
|
||||
deleteAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
||||
connect(deleteAction, &QAction::triggered, this,
|
||||
&GraphEditorWidget::deleteSelection);
|
||||
m_view->addAction(deleteAction);
|
||||
|
||||
auto *copyAction =
|
||||
new QAction(QStringLiteral("Copy Selection"), m_view);
|
||||
copyAction->setShortcut(QKeySequence::Copy);
|
||||
copyAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
||||
connect(copyAction, &QAction::triggered, this,
|
||||
&GraphEditorWidget::copySelection);
|
||||
m_view->addAction(copyAction);
|
||||
|
||||
auto *pasteAction =
|
||||
new QAction(QStringLiteral("Paste Selection"), m_view);
|
||||
pasteAction->setShortcut(QKeySequence::Paste);
|
||||
pasteAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
||||
connect(pasteAction, &QAction::triggered, this,
|
||||
[this]() { pasteSelection(QPointF(30, 30)); });
|
||||
m_view->addAction(pasteAction);
|
||||
|
||||
auto *duplicateAction =
|
||||
new QAction(QStringLiteral("Duplicate Selection"), m_view);
|
||||
duplicateAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D));
|
||||
duplicateAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
||||
connect(duplicateAction, &QAction::triggered, this,
|
||||
&GraphEditorWidget::duplicateSelection);
|
||||
m_view->addAction(duplicateAction);
|
||||
|
||||
auto *autoArrangeAction =
|
||||
new QAction(QStringLiteral("Auto-Arrange"), m_view);
|
||||
autoArrangeAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_L));
|
||||
autoArrangeAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
|
||||
connect(autoArrangeAction, &QAction::triggered, this, [this]() {
|
||||
m_model->autoArrange();
|
||||
m_model->saveLayout(m_layoutPath);
|
||||
});
|
||||
m_view->addAction(autoArrangeAction);
|
||||
|
||||
connect(m_model, &WarpGraphModel::nodePositionUpdated, this,
|
||||
&GraphEditorWidget::scheduleSaveLayout);
|
||||
|
||||
|
|
@ -259,3 +397,312 @@ void GraphEditorWidget::createVirtualNode(bool isSink,
|
|||
|
||||
m_model->refreshFromClient();
|
||||
}
|
||||
|
||||
void GraphEditorWidget::removeDefaultActions() {
|
||||
const QList<QAction *> actions = m_view->actions();
|
||||
for (QAction *action : actions) {
|
||||
const QString text = action->text();
|
||||
if (text.contains(QStringLiteral("Copy Selection")) ||
|
||||
text.contains(QStringLiteral("Paste Selection")) ||
|
||||
text.contains(QStringLiteral("Duplicate Selection")) ||
|
||||
text.contains(QStringLiteral("Delete Selection"))) {
|
||||
m_view->removeAction(action);
|
||||
action->deleteLater();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GraphEditorWidget::deleteSelection() {
|
||||
if (!m_scene) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QList<QGraphicsItem *> items = m_scene->selectedItems();
|
||||
QList<QtNodes::NodeId> virtualNodeIds;
|
||||
bool hasSelectedConnections = false;
|
||||
|
||||
for (auto *item : items) {
|
||||
if (auto *nodeObj =
|
||||
qgraphicsitem_cast<QtNodes::NodeGraphicsObject *>(item)) {
|
||||
const WarpNodeData *data = m_model->warpNodeData(nodeObj->nodeId());
|
||||
if (!data)
|
||||
continue;
|
||||
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
|
||||
if (type == WarpNodeType::kVirtualSink ||
|
||||
type == WarpNodeType::kVirtualSource) {
|
||||
virtualNodeIds.append(nodeObj->nodeId());
|
||||
}
|
||||
} else if (qgraphicsitem_cast<QtNodes::ConnectionGraphicsObject *>(
|
||||
item)) {
|
||||
hasSelectedConnections = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!virtualNodeIds.isEmpty()) {
|
||||
m_scene->undoStack().push(
|
||||
new DeleteVirtualNodeCommand(this, virtualNodeIds));
|
||||
}
|
||||
|
||||
if (virtualNodeIds.isEmpty() && hasSelectedConnections) {
|
||||
m_scene->undoStack().push(new QtNodes::DeleteCommand(m_scene));
|
||||
}
|
||||
}
|
||||
|
||||
void GraphEditorWidget::copySelection() {
|
||||
if (!m_scene || !m_client) {
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonArray nodesJson;
|
||||
std::unordered_set<std::string> selectedNames;
|
||||
QPointF sum;
|
||||
int count = 0;
|
||||
|
||||
const QList<QGraphicsItem *> items = m_scene->selectedItems();
|
||||
for (auto *item : items) {
|
||||
auto *nodeObj =
|
||||
qgraphicsitem_cast<QtNodes::NodeGraphicsObject *>(item);
|
||||
if (!nodeObj)
|
||||
continue;
|
||||
|
||||
const WarpNodeData *data = m_model->warpNodeData(nodeObj->nodeId());
|
||||
if (!data)
|
||||
continue;
|
||||
|
||||
WarpNodeType type = WarpGraphModel::classifyNode(data->info);
|
||||
if (type != WarpNodeType::kVirtualSink &&
|
||||
type != WarpNodeType::kVirtualSource)
|
||||
continue;
|
||||
|
||||
QJsonObject nodeJson;
|
||||
nodeJson[QStringLiteral("name")] =
|
||||
QString::fromStdString(data->info.name);
|
||||
nodeJson[QStringLiteral("media_class")] =
|
||||
QString::fromStdString(data->info.media_class);
|
||||
int channels = static_cast<int>(
|
||||
std::max(data->inputPorts.size(), data->outputPorts.size()));
|
||||
nodeJson[QStringLiteral("channels")] = channels > 0 ? channels : 2;
|
||||
QPointF pos =
|
||||
m_model->nodeData(nodeObj->nodeId(), QtNodes::NodeRole::Position)
|
||||
.toPointF();
|
||||
nodeJson[QStringLiteral("x")] = pos.x();
|
||||
nodeJson[QStringLiteral("y")] = pos.y();
|
||||
|
||||
nodesJson.append(nodeJson);
|
||||
selectedNames.insert(data->info.name);
|
||||
sum += pos;
|
||||
++count;
|
||||
}
|
||||
|
||||
if (nodesJson.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::unordered_map<uint32_t, std::pair<std::string, std::string>> portOwner;
|
||||
for (auto qtId : m_model->allNodeIds()) {
|
||||
const WarpNodeData *data = m_model->warpNodeData(qtId);
|
||||
if (!data || selectedNames.find(data->info.name) == selectedNames.end())
|
||||
continue;
|
||||
for (const auto &port : data->outputPorts) {
|
||||
portOwner[port.id.value] = {data->info.name, port.name};
|
||||
}
|
||||
for (const auto &port : data->inputPorts) {
|
||||
portOwner[port.id.value] = {data->info.name, port.name};
|
||||
}
|
||||
}
|
||||
|
||||
QJsonArray linksJson;
|
||||
auto linksResult = m_client->ListLinks();
|
||||
if (linksResult.ok()) {
|
||||
for (const auto &link : linksResult.value) {
|
||||
auto outIt = portOwner.find(link.output_port.value);
|
||||
auto inIt = portOwner.find(link.input_port.value);
|
||||
if (outIt != portOwner.end() && inIt != portOwner.end()) {
|
||||
QJsonObject linkJson;
|
||||
linkJson[QStringLiteral("source")] = QString::fromStdString(
|
||||
outIt->second.first + ":" + outIt->second.second);
|
||||
linkJson[QStringLiteral("target")] = QString::fromStdString(
|
||||
inIt->second.first + ":" + inIt->second.second);
|
||||
linksJson.append(linkJson);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QJsonObject root;
|
||||
root[QStringLiteral("nodes")] = nodesJson;
|
||||
root[QStringLiteral("links")] = linksJson;
|
||||
root[QStringLiteral("center_x")] = count > 0 ? sum.x() / count : 0.0;
|
||||
root[QStringLiteral("center_y")] = count > 0 ? sum.y() / count : 0.0;
|
||||
root[QStringLiteral("version")] = 1;
|
||||
|
||||
m_clipboardJson = root;
|
||||
|
||||
QJsonDocument doc(root);
|
||||
auto *mime = new QMimeData();
|
||||
mime->setData(QStringLiteral("application/warppipe-virtual-graph"),
|
||||
doc.toJson(QJsonDocument::Compact));
|
||||
mime->setText(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)));
|
||||
QGuiApplication::clipboard()->setMimeData(mime);
|
||||
}
|
||||
|
||||
void GraphEditorWidget::pasteSelection(const QPointF &offset) {
|
||||
if (!m_client || !m_model) {
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonObject root;
|
||||
const QMimeData *mime = QGuiApplication::clipboard()->mimeData();
|
||||
if (mime &&
|
||||
mime->hasFormat(
|
||||
QStringLiteral("application/warppipe-virtual-graph"))) {
|
||||
root = QJsonDocument::fromJson(
|
||||
mime->data(QStringLiteral(
|
||||
"application/warppipe-virtual-graph")))
|
||||
.object();
|
||||
} else if (!m_clipboardJson.isEmpty()) {
|
||||
root = m_clipboardJson;
|
||||
}
|
||||
|
||||
if (root.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::unordered_set<std::string> existingNames;
|
||||
auto nodesResult = m_client->ListNodes();
|
||||
if (nodesResult.ok()) {
|
||||
for (const auto &node : nodesResult.value) {
|
||||
existingNames.insert(node.name);
|
||||
}
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::string> nameMap;
|
||||
|
||||
const QJsonArray nodesArray =
|
||||
root[QStringLiteral("nodes")].toArray();
|
||||
for (const auto &entry : nodesArray) {
|
||||
QJsonObject nodeObj = entry.toObject();
|
||||
std::string baseName =
|
||||
nodeObj[QStringLiteral("name")].toString().toStdString();
|
||||
std::string mediaClass =
|
||||
nodeObj[QStringLiteral("media_class")].toString().toStdString();
|
||||
double x = nodeObj[QStringLiteral("x")].toDouble();
|
||||
double y = nodeObj[QStringLiteral("y")].toDouble();
|
||||
|
||||
if (baseName.empty())
|
||||
continue;
|
||||
|
||||
std::string newName = baseName + " Copy";
|
||||
int suffix = 2;
|
||||
while (existingNames.count(newName)) {
|
||||
newName = baseName + " Copy " + std::to_string(suffix++);
|
||||
}
|
||||
existingNames.insert(newName);
|
||||
nameMap[baseName] = newName;
|
||||
|
||||
m_model->setPendingPosition(newName, QPointF(x, y) + offset);
|
||||
|
||||
bool isSink =
|
||||
mediaClass == "Audio/Sink" || mediaClass == "Audio/Duplex";
|
||||
if (isSink) {
|
||||
m_client->CreateVirtualSink(newName);
|
||||
} else {
|
||||
m_client->CreateVirtualSource(newName);
|
||||
}
|
||||
}
|
||||
|
||||
const QJsonArray linksArray =
|
||||
root[QStringLiteral("links")].toArray();
|
||||
for (const auto &entry : linksArray) {
|
||||
QJsonObject linkObj = entry.toObject();
|
||||
std::string source =
|
||||
linkObj[QStringLiteral("source")].toString().toStdString();
|
||||
std::string target =
|
||||
linkObj[QStringLiteral("target")].toString().toStdString();
|
||||
|
||||
auto splitKey = [](const std::string &s)
|
||||
-> std::pair<std::string, std::string> {
|
||||
auto pos = s.rfind(':');
|
||||
if (pos == std::string::npos)
|
||||
return {s, ""};
|
||||
return {s.substr(0, pos), s.substr(pos + 1)};
|
||||
};
|
||||
|
||||
auto [outName, outPort] = splitKey(source);
|
||||
auto [inName, inPort] = splitKey(target);
|
||||
|
||||
auto outIt = nameMap.find(outName);
|
||||
auto inIt = nameMap.find(inName);
|
||||
if (outIt == nameMap.end() || inIt == nameMap.end())
|
||||
continue;
|
||||
|
||||
PendingPasteLink pending;
|
||||
pending.outNodeName = outIt->second;
|
||||
pending.outPortName = outPort;
|
||||
pending.inNodeName = inIt->second;
|
||||
pending.inPortName = inPort;
|
||||
m_pendingPasteLinks.push_back(pending);
|
||||
}
|
||||
|
||||
m_model->refreshFromClient();
|
||||
tryResolvePendingLinks();
|
||||
}
|
||||
|
||||
void GraphEditorWidget::duplicateSelection() {
|
||||
copySelection();
|
||||
pasteSelection(QPointF(40, 40));
|
||||
}
|
||||
|
||||
void GraphEditorWidget::tryResolvePendingLinks() {
|
||||
if (m_pendingPasteLinks.empty() || !m_client) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto nodesResult = m_client->ListNodes();
|
||||
if (!nodesResult.ok()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<PendingPasteLink> remaining;
|
||||
|
||||
for (const auto &pending : m_pendingPasteLinks) {
|
||||
warppipe::PortId outPortId{0};
|
||||
warppipe::PortId inPortId{0};
|
||||
bool foundOut = false;
|
||||
bool foundIn = false;
|
||||
|
||||
for (const auto &node : nodesResult.value) {
|
||||
if (!foundOut && node.name == pending.outNodeName) {
|
||||
auto portsResult = m_client->ListPorts(node.id);
|
||||
if (portsResult.ok()) {
|
||||
for (const auto &port : portsResult.value) {
|
||||
if (!port.is_input && port.name == pending.outPortName) {
|
||||
outPortId = port.id;
|
||||
foundOut = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!foundIn && node.name == pending.inNodeName) {
|
||||
auto portsResult = m_client->ListPorts(node.id);
|
||||
if (portsResult.ok()) {
|
||||
for (const auto &port : portsResult.value) {
|
||||
if (port.is_input && port.name == pending.inPortName) {
|
||||
inPortId = port.id;
|
||||
foundIn = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundOut && foundIn) {
|
||||
m_client->CreateLink(outPortId, inPortId, warppipe::LinkOptions{});
|
||||
} else {
|
||||
remaining.push_back(pending);
|
||||
}
|
||||
}
|
||||
|
||||
m_pendingPasteLinks = remaining;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@
|
|||
|
||||
#include <warppipe/warppipe.hpp>
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace QtNodes {
|
||||
class BasicGraphicsScene;
|
||||
class GraphicsView;
|
||||
|
|
@ -13,10 +17,13 @@ class GraphicsView;
|
|||
class WarpGraphModel;
|
||||
class QLabel;
|
||||
class QTimer;
|
||||
class DeleteVirtualNodeCommand;
|
||||
|
||||
class GraphEditorWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
friend class DeleteVirtualNodeCommand;
|
||||
|
||||
public:
|
||||
explicit GraphEditorWidget(warppipe::Client *client,
|
||||
QWidget *parent = nullptr);
|
||||
|
|
@ -40,6 +47,20 @@ private:
|
|||
void createVirtualNode(bool isSink, const QPointF &scenePos);
|
||||
void captureDebugScreenshot(const QString &event);
|
||||
|
||||
void deleteSelection();
|
||||
void copySelection();
|
||||
void pasteSelection(const QPointF &offset);
|
||||
void duplicateSelection();
|
||||
void removeDefaultActions();
|
||||
void tryResolvePendingLinks();
|
||||
|
||||
struct PendingPasteLink {
|
||||
std::string outNodeName;
|
||||
std::string outPortName;
|
||||
std::string inNodeName;
|
||||
std::string inPortName;
|
||||
};
|
||||
|
||||
warppipe::Client *m_client = nullptr;
|
||||
WarpGraphModel *m_model = nullptr;
|
||||
QtNodes::BasicGraphicsScene *m_scene = nullptr;
|
||||
|
|
@ -49,4 +70,6 @@ private:
|
|||
QString m_layoutPath;
|
||||
QString m_debugScreenshotDir;
|
||||
bool m_graphReady = false;
|
||||
QJsonObject m_clipboardJson;
|
||||
std::vector<PendingPasteLink> m_pendingPasteLinks;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -615,6 +615,15 @@ bool WarpGraphModel::isGhost(QtNodes::NodeId nodeId) const {
|
|||
return m_ghostNodes.find(nodeId) != m_ghostNodes.end();
|
||||
}
|
||||
|
||||
uint32_t WarpGraphModel::findPwNodeIdByName(const std::string &name) const {
|
||||
for (const auto &[qtId, data] : m_nodes) {
|
||||
if (data.info.name == name) {
|
||||
return data.info.id.value;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
WarpNodeType
|
||||
WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) {
|
||||
const std::string &mc = info.media_class;
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ public:
|
|||
void setPendingPosition(const std::string &nodeName, QPointF pos);
|
||||
static WarpNodeType classifyNode(const warppipe::NodeInfo &info);
|
||||
|
||||
uint32_t findPwNodeIdByName(const std::string &name) const;
|
||||
|
||||
void saveLayout(const QString &path) const;
|
||||
bool loadLayout(const QString &path);
|
||||
void autoArrange();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
#include <warppipe/warppipe.hpp>
|
||||
|
||||
#include "../../gui/GraphEditorWidget.h"
|
||||
#include "../../gui/WarpGraphModel.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
|
@ -453,3 +455,74 @@ TEST_CASE("volume meter streams are filtered") {
|
|||
auto qtId = model.qtNodeIdForPw(100150);
|
||||
REQUIRE(qtId == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("findPwNodeIdByName returns correct id") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(100200, "find-me-node", "Audio/Sink")).ok());
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(100201, "other-node", "Audio/Source")).ok());
|
||||
|
||||
WarpGraphModel model(tc.client.get());
|
||||
model.refreshFromClient();
|
||||
|
||||
REQUIRE(model.findPwNodeIdByName("find-me-node") == 100200);
|
||||
REQUIRE(model.findPwNodeIdByName("other-node") == 100201);
|
||||
REQUIRE(model.findPwNodeIdByName("nonexistent") == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("GraphEditorWidget registers custom keyboard actions") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
GraphEditorWidget widget(tc.client.get());
|
||||
|
||||
QStringList actionTexts;
|
||||
for (auto *action : widget.findChildren<QAction *>()) {
|
||||
if (!action->text().isEmpty()) {
|
||||
actionTexts.append(action->text());
|
||||
}
|
||||
}
|
||||
|
||||
REQUIRE(actionTexts.contains("Delete Selection"));
|
||||
REQUIRE(actionTexts.contains("Copy Selection"));
|
||||
REQUIRE(actionTexts.contains("Paste Selection"));
|
||||
REQUIRE(actionTexts.contains("Duplicate Selection"));
|
||||
REQUIRE(actionTexts.contains("Auto-Arrange"));
|
||||
}
|
||||
|
||||
TEST_CASE("GraphEditorWidget reflects injected nodes") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(100210, "warppipe-widget-test", "Audio/Sink")).ok());
|
||||
REQUIRE(tc.client->Test_InsertPort(
|
||||
MakePort(100211, 100210, "FL", true)).ok());
|
||||
|
||||
GraphEditorWidget widget(tc.client.get());
|
||||
REQUIRE(widget.nodeCount() >= 1);
|
||||
}
|
||||
|
||||
TEST_CASE("findPwNodeIdByName returns 0 for ghost nodes without pw mapping") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(100220, "ghost-lookup", "Stream/Output/Audio", "App")).ok());
|
||||
|
||||
WarpGraphModel model(tc.client.get());
|
||||
model.refreshFromClient();
|
||||
REQUIRE(model.findPwNodeIdByName("ghost-lookup") == 100220);
|
||||
|
||||
REQUIRE(tc.client->Test_RemoveGlobal(100220).ok());
|
||||
model.refreshFromClient();
|
||||
|
||||
REQUIRE(model.findPwNodeIdByName("ghost-lookup") == 100220);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue