From b257c1a7cf9e59330a742b8ae86a822efb27eb19 Mon Sep 17 00:00:00 2001 From: Ilya Ashikhmin Date: Thu, 2 Jul 2026 23:25:06 +0200 Subject: [PATCH] feat(scene): add interactive graphics scene with grid, ports and styled edges - SVG node items with snap-to-grid dragging; ports appear on focus/hover and highlight compatible targets during a connection drag - Orthogonally routed edge items with line styles, head/tail decorations (arrow, circle, diamond, bar), selection halo and title labels; later edges jump crossings with arcs when enabled - Grid background view with cursor-anchored zoom, middle-button pan, rubber-band selection and palette drag-and-drop - Undoable commands: add/move/delete/connect/property/paste with clipboard fragments; light/dark/high-contrast themes - Flow overlay: animated dashes, direction and density follow flow rate Co-Authored-By: Claude Fable 5 --- src/CMakeLists.txt | 19 ++ src/scene/Commands.cpp | 276 +++++++++++++++++++++++ src/scene/Commands.h | 115 ++++++++++ src/scene/DiagramScene.cpp | 448 +++++++++++++++++++++++++++++++++++++ src/scene/DiagramScene.h | 108 +++++++++ src/scene/EdgeItem.cpp | 167 ++++++++++++++ src/scene/EdgeItem.h | 44 ++++ src/scene/GridView.cpp | 164 ++++++++++++++ src/scene/GridView.h | 53 +++++ src/scene/NodeItem.cpp | 183 +++++++++++++++ src/scene/NodeItem.h | 53 +++++ src/scene/PortItem.cpp | 126 +++++++++++ src/scene/PortItem.h | 46 ++++ src/scene/Theme.cpp | 70 ++++++ src/scene/Theme.h | 31 +++ tests/CMakeLists.txt | 2 + tests/tst_scene.cpp | 215 ++++++++++++++++++ tests/tst_undo.cpp | 137 ++++++++++++ 18 files changed, 2257 insertions(+) create mode 100644 src/scene/Commands.cpp create mode 100644 src/scene/Commands.h create mode 100644 src/scene/DiagramScene.cpp create mode 100644 src/scene/DiagramScene.h create mode 100644 src/scene/EdgeItem.cpp create mode 100644 src/scene/EdgeItem.h create mode 100644 src/scene/GridView.cpp create mode 100644 src/scene/GridView.h create mode 100644 src/scene/NodeItem.cpp create mode 100644 src/scene/NodeItem.h create mode 100644 src/scene/PortItem.cpp create mode 100644 src/scene/PortItem.h create mode 100644 src/scene/Theme.cpp create mode 100644 src/scene/Theme.h create mode 100644 tests/tst_scene.cpp create mode 100644 tests/tst_undo.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c30754a..eebde9c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -15,3 +15,22 @@ add_library(diagcore STATIC ) target_include_directories(diagcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(diagcore PUBLIC Qt6::Core Qt6::Gui) + +# Scene: QGraphicsScene items and interaction. +add_library(diagscene STATIC + scene/Theme.h + scene/Theme.cpp + scene/Commands.h + scene/Commands.cpp + scene/DiagramScene.h + scene/DiagramScene.cpp + scene/NodeItem.h + scene/NodeItem.cpp + scene/PortItem.h + scene/PortItem.cpp + scene/EdgeItem.h + scene/EdgeItem.cpp + scene/GridView.h + scene/GridView.cpp +) +target_link_libraries(diagscene PUBLIC diagcore Qt6::Widgets Qt6::Svg) diff --git a/src/scene/Commands.cpp b/src/scene/Commands.cpp new file mode 100644 index 0000000..367e4a7 --- /dev/null +++ b/src/scene/Commands.cpp @@ -0,0 +1,276 @@ +#include "Commands.h" + +#include "core/NetworkModel.h" + +#include + +namespace diag { + +namespace { + +QJsonObject nodeToJson(const Node& n) +{ + QJsonObject o; + o.insert(QLatin1String("id"), n.id.toString(QUuid::WithoutBraces)); + o.insert(QLatin1String("type"), n.typeId); + o.insert(QLatin1String("pos"), QJsonArray{n.pos.x(), n.pos.y()}); + o.insert(QLatin1String("props"), QJsonObject::fromVariantMap(n.props)); + return o; +} + +QJsonObject edgeToJson(const Edge& e) +{ + QJsonObject o; + o.insert(QLatin1String("id"), e.id.toString(QUuid::WithoutBraces)); + o.insert(QLatin1String("from"), e.fromNode.toString(QUuid::WithoutBraces)); + o.insert(QLatin1String("fromPort"), e.fromPort); + o.insert(QLatin1String("to"), e.toNode.toString(QUuid::WithoutBraces)); + o.insert(QLatin1String("toPort"), e.toPort); + o.insert(QLatin1String("props"), QJsonObject::fromVariantMap(e.props)); + return o; +} + +void restoreNodes(NetworkModel* model, const QJsonArray& nodes) +{ + for (const auto& nv : nodes) { + const QJsonObject o = nv.toObject(); + const QJsonArray pos = o.value(QLatin1String("pos")).toArray(); + const QUuid id = model->addNode(o.value(QLatin1String("type")).toString(), + {pos.at(0).toDouble(), pos.at(1).toDouble()}, + QUuid(o.value(QLatin1String("id")).toString())); + const QVariantMap props = o.value(QLatin1String("props")).toObject().toVariantMap(); + for (auto it = props.begin(); it != props.end(); ++it) + model->setNodeProperty(id, it.key(), it.value()); + } +} + +void restoreEdges(NetworkModel* model, const QJsonArray& edges) +{ + for (const auto& ev : edges) { + const QJsonObject o = ev.toObject(); + const QUuid id = model->addEdge(QUuid(o.value(QLatin1String("from")).toString()), + o.value(QLatin1String("fromPort")).toString(), + QUuid(o.value(QLatin1String("to")).toString()), + o.value(QLatin1String("toPort")).toString(), + QUuid(o.value(QLatin1String("id")).toString())); + const QVariantMap props = o.value(QLatin1String("props")).toObject().toVariantMap(); + for (auto it = props.begin(); it != props.end(); ++it) + model->setEdgeProperty(id, it.key(), it.value()); + } +} + +} // namespace + +QJsonObject fragmentFromSelection(const NetworkModel& model, const QList& nodes, + const QList& edges) +{ + QJsonArray nodeArr; + for (QUuid id : nodes) + if (const Node* n = model.node(id)) + nodeArr.append(nodeToJson(*n)); + + // Edges: explicitly selected ones plus edges fully inside the node set. + QList edgeIds = edges; + for (QUuid nid : nodes) { + for (QUuid eid : model.edgesOfNode(nid)) { + const Edge* e = model.edge(eid); + if (nodes.contains(e->fromNode) && nodes.contains(e->toNode) + && !edgeIds.contains(eid)) + edgeIds.append(eid); + } + } + QJsonArray edgeArr; + for (QUuid id : edgeIds) + if (const Edge* e = model.edge(id)) + edgeArr.append(edgeToJson(*e)); + + QJsonObject frag; + frag.insert(QLatin1String("nodes"), nodeArr); + frag.insert(QLatin1String("edges"), edgeArr); + return frag; +} + +// --- AddNodeCommand --- + +AddNodeCommand::AddNodeCommand(NetworkModel* model, const QString& typeId, QPointF pos, + QUndoCommand* parent) + : QUndoCommand(QObject::tr("Add node"), parent), m_model(model), m_typeId(typeId), m_pos(pos) +{ +} + +void AddNodeCommand::redo() +{ + m_id = m_model->addNode(m_typeId, m_pos, m_id); +} + +void AddNodeCommand::undo() +{ + m_model->removeNode(m_id); +} + +// --- AddEdgeCommand --- + +AddEdgeCommand::AddEdgeCommand(NetworkModel* model, QUuid fromNode, const QString& fromPort, + QUuid toNode, const QString& toPort, QUndoCommand* parent) + : QUndoCommand(QObject::tr("Connect"), parent) + , m_model(model) + , m_fromNode(fromNode) + , m_fromPort(fromPort) + , m_toNode(toNode) + , m_toPort(toPort) +{ +} + +void AddEdgeCommand::redo() +{ + m_id = m_model->addEdge(m_fromNode, m_fromPort, m_toNode, m_toPort, m_id); +} + +void AddEdgeCommand::undo() +{ + m_model->removeEdge(m_id); +} + +// --- MoveNodesCommand --- + +MoveNodesCommand::MoveNodesCommand(NetworkModel* model, const QList& moves, + QUndoCommand* parent) + : QUndoCommand(QObject::tr("Move"), parent), m_model(model), m_moves(moves) +{ +} + +void MoveNodesCommand::redo() +{ + for (const auto& m : m_moves) + m_model->moveNode(m.id, m.to); +} + +void MoveNodesCommand::undo() +{ + for (const auto& m : m_moves) + m_model->moveNode(m.id, m.from); +} + +// --- RemoveItemsCommand --- + +RemoveItemsCommand::RemoveItemsCommand(NetworkModel* model, const QList& nodes, + const QList& edges, QUndoCommand* parent) + : QUndoCommand(QObject::tr("Delete"), parent), m_model(model), m_nodes(nodes) +{ + // Capture full state now: attached edges disappear with their nodes. + QList allEdges = edges; + for (QUuid nid : nodes) + for (QUuid eid : model->edgesOfNode(nid)) + if (!allEdges.contains(eid)) + allEdges.append(eid); + const QJsonObject frag = fragmentFromSelection(*model, nodes, allEdges); + m_nodeJson = frag.value(QLatin1String("nodes")).toArray(); + QJsonArray edgeArr; + for (QUuid id : allEdges) + if (model->edge(id)) { + for (const auto& ev : frag.value(QLatin1String("edges")).toArray()) + if (QUuid(ev.toObject().value(QLatin1String("id")).toString()) == id) + edgeArr.append(ev); + } + m_edgeJson = edgeArr; +} + +void RemoveItemsCommand::redo() +{ + for (const auto& ev : m_edgeJson) + m_model->removeEdge(QUuid(ev.toObject().value(QLatin1String("id")).toString())); + for (QUuid id : m_nodes) + m_model->removeNode(id); +} + +void RemoveItemsCommand::undo() +{ + restoreNodes(m_model, m_nodeJson); + restoreEdges(m_model, m_edgeJson); +} + +// --- SetPropertyCommand --- + +SetPropertyCommand::SetPropertyCommand(NetworkModel* model, Target target, QUuid id, + const QString& key, const QVariant& value, + QUndoCommand* parent) + : QUndoCommand(QObject::tr("Change %1").arg(key), parent) + , m_model(model) + , m_target(target) + , m_id(id) + , m_key(key) + , m_new(value) +{ + m_old = target == NodeTarget ? model->nodeProperty(id, key) : model->edgeProperty(id, key); +} + +void SetPropertyCommand::redo() +{ + if (m_target == NodeTarget) + m_model->setNodeProperty(m_id, m_key, m_new); + else + m_model->setEdgeProperty(m_id, m_key, m_new); +} + +void SetPropertyCommand::undo() +{ + if (m_target == NodeTarget) + m_model->setNodeProperty(m_id, m_key, m_old); + else + m_model->setEdgeProperty(m_id, m_key, m_old); +} + +// --- PasteCommand --- + +PasteCommand::PasteCommand(NetworkModel* model, const QJsonObject& fragment, QPointF offset, + QUndoCommand* parent) + : QUndoCommand(QObject::tr("Paste"), parent), m_model(model) +{ + // Remap ids once so repeated redo() recreates identical objects. + QHash idMap; + QJsonArray nodes; + for (const auto& nv : fragment.value(QLatin1String("nodes")).toArray()) { + QJsonObject o = nv.toObject(); + const QString oldId = o.value(QLatin1String("id")).toString(); + const QString newId = QUuid::createUuid().toString(QUuid::WithoutBraces); + idMap.insert(oldId, newId); + o.insert(QLatin1String("id"), newId); + const QJsonArray pos = o.value(QLatin1String("pos")).toArray(); + o.insert(QLatin1String("pos"), QJsonArray{pos.at(0).toDouble() + offset.x(), + pos.at(1).toDouble() + offset.y()}); + nodes.append(o); + m_nodeIds.append(QUuid(newId)); + } + QJsonArray edges; + for (const auto& ev : fragment.value(QLatin1String("edges")).toArray()) { + QJsonObject o = ev.toObject(); + const QString from = o.value(QLatin1String("from")).toString(); + const QString to = o.value(QLatin1String("to")).toString(); + if (!idMap.contains(from) || !idMap.contains(to)) + continue; + const QString newId = QUuid::createUuid().toString(QUuid::WithoutBraces); + o.insert(QLatin1String("id"), newId); + o.insert(QLatin1String("from"), idMap.value(from)); + o.insert(QLatin1String("to"), idMap.value(to)); + edges.append(o); + m_edgeIds.append(QUuid(newId)); + } + m_fragment.insert(QLatin1String("nodes"), nodes); + m_fragment.insert(QLatin1String("edges"), edges); +} + +void PasteCommand::redo() +{ + restoreNodes(m_model, m_fragment.value(QLatin1String("nodes")).toArray()); + restoreEdges(m_model, m_fragment.value(QLatin1String("edges")).toArray()); +} + +void PasteCommand::undo() +{ + for (QUuid id : m_edgeIds) + m_model->removeEdge(id); + for (QUuid id : m_nodeIds) + m_model->removeNode(id); +} + +} // namespace diag diff --git a/src/scene/Commands.h b/src/scene/Commands.h new file mode 100644 index 0000000..7226401 --- /dev/null +++ b/src/scene/Commands.h @@ -0,0 +1,115 @@ +#pragma once + +#include "core/Types.h" + +#include +#include +#include + +namespace diag { + +class NetworkModel; + +class AddNodeCommand : public QUndoCommand { +public: + AddNodeCommand(NetworkModel* model, const QString& typeId, QPointF pos, + QUndoCommand* parent = nullptr); + void redo() override; + void undo() override; + QUuid nodeId() const { return m_id; } + +private: + NetworkModel* m_model; + QString m_typeId; + QPointF m_pos; + QUuid m_id; +}; + +class AddEdgeCommand : public QUndoCommand { +public: + AddEdgeCommand(NetworkModel* model, QUuid fromNode, const QString& fromPort, QUuid toNode, + const QString& toPort, QUndoCommand* parent = nullptr); + void redo() override; + void undo() override; + QUuid edgeId() const { return m_id; } + +private: + NetworkModel* m_model; + QUuid m_fromNode; + QString m_fromPort; + QUuid m_toNode; + QString m_toPort; + QUuid m_id; +}; + +class MoveNodesCommand : public QUndoCommand { +public: + struct Move { + QUuid id; + QPointF from; + QPointF to; + }; + MoveNodesCommand(NetworkModel* model, const QList& moves, + QUndoCommand* parent = nullptr); + void redo() override; + void undo() override; + +private: + NetworkModel* m_model; + QList m_moves; +}; + +// Removes nodes (with attached edges) and explicitly selected edges; +// undo restores everything with original ids and properties. +class RemoveItemsCommand : public QUndoCommand { +public: + RemoveItemsCommand(NetworkModel* model, const QList& nodes, const QList& edges, + QUndoCommand* parent = nullptr); + void redo() override; + void undo() override; + +private: + NetworkModel* m_model; + QList m_nodes; + QJsonArray m_nodeJson; + QJsonArray m_edgeJson; // all removed edges (attached + selected) +}; + +class SetPropertyCommand : public QUndoCommand { +public: + enum Target { NodeTarget, EdgeTarget }; + SetPropertyCommand(NetworkModel* model, Target target, QUuid id, const QString& key, + const QVariant& value, QUndoCommand* parent = nullptr); + void redo() override; + void undo() override; + +private: + NetworkModel* m_model; + Target m_target; + QUuid m_id; + QString m_key; + QVariant m_old; + QVariant m_new; +}; + +// Inserts a JSON fragment (from copy) with fresh ids at an offset. +class PasteCommand : public QUndoCommand { +public: + PasteCommand(NetworkModel* model, const QJsonObject& fragment, QPointF offset, + QUndoCommand* parent = nullptr); + void redo() override; + void undo() override; + QList pastedNodes() const { return m_nodeIds; } + +private: + NetworkModel* m_model; + QJsonObject m_fragment; // ids already remapped to fresh ones + QList m_nodeIds; + QList m_edgeIds; +}; + +// Serialize a node/edge subset for clipboard or removal restore. +QJsonObject fragmentFromSelection(const NetworkModel& model, const QList& nodes, + const QList& edges); + +} // namespace diag diff --git a/src/scene/DiagramScene.cpp b/src/scene/DiagramScene.cpp new file mode 100644 index 0000000..f3b8b85 --- /dev/null +++ b/src/scene/DiagramScene.cpp @@ -0,0 +1,448 @@ +#include "DiagramScene.h" + +#include "Commands.h" +#include "EdgeItem.h" +#include "NodeItem.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace diag { + +namespace { +const char* kClipboardMime = "application/x-pipediagram-fragment"; +} + +DiagramScene::DiagramScene(NetworkModel* model, QUndoStack* undoStack, QObject* parent) + : QGraphicsScene(parent), m_model(model), m_undo(undoStack) +{ + setSceneRect(-2000, -2000, 6000, 6000); + + connect(m_model, &NetworkModel::nodeAdded, this, &DiagramScene::onNodeAdded); + connect(m_model, &NetworkModel::nodeRemoved, this, &DiagramScene::onNodeRemoved); + connect(m_model, &NetworkModel::nodeMoved, this, &DiagramScene::onNodeMoved); + connect(m_model, &NetworkModel::nodeChanged, this, [this](QUuid id) { + if (NodeItem* item = m_nodeItems.value(id)) + item->update(); + }); + connect(m_model, &NetworkModel::edgeAdded, this, &DiagramScene::onEdgeAdded); + connect(m_model, &NetworkModel::edgeRemoved, this, &DiagramScene::onEdgeRemoved); + connect(m_model, &NetworkModel::edgeChanged, this, [this](QUuid id) { + if (EdgeItem* item = m_edgeItems.value(id)) + item->update(); + }); + connect(m_model, &NetworkModel::flowChanged, this, [this](QUuid) { + recomputeMaxFlow(); + for (auto* e : std::as_const(m_edgeItems)) + e->update(); + }); + connect(m_model, &NetworkModel::modelReset, this, &DiagramScene::rebuildAll); + + m_animTimer = new QTimer(this); + m_animTimer->setInterval(33); + connect(m_animTimer, &QTimer::timeout, this, [this] { + m_animPhase += 1.0; + for (auto* e : std::as_const(m_edgeItems)) + e->update(); + }); + + rebuildAll(); +} + +void DiagramScene::setTheme(const Theme& theme) +{ + m_theme = theme; + for (auto* v : views()) + v->viewport()->update(); + update(); + for (auto* n : std::as_const(m_nodeItems)) + n->update(); + for (auto* e : std::as_const(m_edgeItems)) + e->update(); +} + +void DiagramScene::setRouteConfig(const RouteConfig& cfg) +{ + m_routeConfig = cfg; + rerouteAll(); + for (auto* v : views()) + v->viewport()->update(); +} + +QSvgRenderer* DiagramScene::rendererFor(const QString& typeId) +{ + if (m_renderers.contains(typeId)) + return m_renderers.value(typeId); + const NodeType* type = m_model->registry()->nodeType(typeId); + QSvgRenderer* renderer = nullptr; + if (type && !type->svgPath.isEmpty()) + renderer = new QSvgRenderer(type->svgPath, this); + m_renderers.insert(typeId, renderer); + return renderer; +} + +// --- model sync --- + +void DiagramScene::onNodeAdded(QUuid id) +{ + auto* item = new NodeItem(this, id); + addItem(item); + m_nodeItems.insert(id, item); +} + +void DiagramScene::onNodeRemoved(QUuid id) +{ + if (NodeItem* item = m_nodeItems.take(id)) { + removeItem(item); + delete item; + } +} + +void DiagramScene::onNodeMoved(QUuid id) +{ + if (NodeItem* item = m_nodeItems.value(id)) + item->syncFromModel(); + // Re-route the moved node's edges; crossings can change anywhere, so + // recompute hops globally (cheap at typical diagram sizes). + rerouteAll(); +} + +void DiagramScene::onEdgeAdded(QUuid id) +{ + auto* item = new EdgeItem(this, id); + addItem(item); + m_edgeItems.insert(id, item); + rerouteAll(); +} + +void DiagramScene::onEdgeRemoved(QUuid id) +{ + if (EdgeItem* item = m_edgeItems.take(id)) { + removeItem(item); + delete item; + } + rerouteAll(); +} + +void DiagramScene::rebuildAll() +{ + cancelConnection(); + for (auto* item : std::as_const(m_edgeItems)) { + removeItem(item); + delete item; + } + m_edgeItems.clear(); + for (auto* item : std::as_const(m_nodeItems)) { + removeItem(item); + delete item; + } + m_nodeItems.clear(); + for (QUuid id : m_model->nodeIds()) + onNodeAdded(id); + for (QUuid id : m_model->edgeIds()) { + auto* item = new EdgeItem(this, id); + addItem(item); + m_edgeItems.insert(id, item); + } + rerouteAll(); + recomputeMaxFlow(); +} + +void DiagramScene::rerouteAll() +{ + const QList order = m_model->edgeIds(); + QList> polys; + polys.reserve(order.size()); + + for (QUuid id : order) { + const Edge* e = m_model->edge(id); + const NodeType* fromType = m_model->nodeTypeOf(e->fromNode); + const NodeType* toType = m_model->nodeTypeOf(e->toNode); + const PortSpec* fromPort = fromType ? fromType->port(e->fromPort) : nullptr; + const PortSpec* toPort = toType ? toType->port(e->toPort) : nullptr; + const QPointF a = m_model->portScenePos(e->fromNode, e->fromPort); + const QPointF b = m_model->portScenePos(e->toNode, e->toPort); + polys.append(Router::route(a, fromPort ? fromPort->side : Side::Right, b, + toPort ? toPort->side : Side::Left, + m_routeConfig.gridStep)); + } + + for (int i = 0; i < order.size(); ++i) { + EdgeItem* item = m_edgeItems.value(order.at(i)); + if (!item) + continue; + // Later edges jump over earlier ones. + QList hops; + for (int j = 0; j < i; ++j) + hops.append(Router::crossings(polys.at(i), polys.at(j))); + item->setRoute(polys.at(i), hops); + } +} + +void DiagramScene::recomputeMaxFlow() +{ + m_maxAbsFlow = 0.0; + for (QUuid id : m_model->edgeIds()) + m_maxAbsFlow = qMax(m_maxAbsFlow, qAbs(m_model->edge(id)->flowRate)); +} + +// --- connection drag --- + +void DiagramScene::startConnection(PortItem* port) +{ + cancelConnection(); + m_connectSource = port; + m_connectPreview = new QGraphicsPathItem(); + QPen pen(m_theme.selection, 2, Qt::DashLine); + m_connectPreview->setPen(pen); + m_connectPreview->setZValue(3); + addItem(m_connectPreview); + for (auto* n : std::as_const(m_nodeItems)) + n->updatePortVisibility(); + emit connectionMessage(tr("Drag to a highlighted port to connect; Esc cancels")); +} + +PortItem::ConnectState DiagramScene::connectStateFor(const PortItem* port) const +{ + if (!m_connectSource || port == m_connectSource) + return PortItem::ConnectState::Normal; + const bool ok = m_model->canConnect(m_connectSource->nodeItem()->nodeId(), + m_connectSource->spec().id, port->nodeItem()->nodeId(), + port->spec().id); + return ok ? PortItem::ConnectState::Compatible : PortItem::ConnectState::Incompatible; +} + +void DiagramScene::updateConnectionDrag(QPointF scenePos) +{ + if (!m_connectSource || !m_connectPreview) + return; + const QPointF start = m_connectSource->scenePos(); + const Side startSide = m_connectSource->spec().side; + Side endSide; + const QPointF d = scenePos - start; + if (qAbs(d.x()) >= qAbs(d.y())) + endSide = d.x() >= 0 ? Side::Left : Side::Right; + else + endSide = d.y() >= 0 ? Side::Top : Side::Bottom; + // Snap the preview end to a compatible port when hovering one. + QPointF end = scenePos; + if (PortItem* target = portAt(scenePos); + target && connectStateFor(target) == PortItem::ConnectState::Compatible) { + end = target->scenePos(); + endSide = target->spec().side; + } + const auto poly = Router::route(start, startSide, end, endSide, m_routeConfig.gridStep); + m_connectPreview->setPath(Router::toPath(poly, {}, m_routeConfig.arcRadius)); +} + +PortItem* DiagramScene::portAt(QPointF scenePos) const +{ + // Tolerant hit test: ports are small, accept hits within a radius. + const QRectF probe(scenePos.x() - 10, scenePos.y() - 10, 20, 20); + PortItem* best = nullptr; + double bestDist = 1e18; + for (QGraphicsItem* item : items(probe, Qt::IntersectsItemBoundingRect)) { + if (auto* port = dynamic_cast(item)) { + const double dist = QLineF(port->scenePos(), scenePos).length(); + if (dist < bestDist) { + bestDist = dist; + best = port; + } + } + } + return best; +} + +bool DiagramScene::finishConnection(QPointF scenePos) +{ + if (!m_connectSource) + return false; + PortItem* source = m_connectSource; + PortItem* target = portAt(scenePos); + bool ok = false; + if (target && target != source) { + QString reason; + if (m_model->canConnect(source->nodeItem()->nodeId(), source->spec().id, + target->nodeItem()->nodeId(), target->spec().id, &reason)) { + m_undo->push(new AddEdgeCommand(m_model, source->nodeItem()->nodeId(), + source->spec().id, target->nodeItem()->nodeId(), + target->spec().id)); + emit connectionMessage(tr("Connected")); + ok = true; + } else { + emit connectionMessage(reason); + } + } else { + emit connectionMessage(QString()); + } + cancelConnection(); + return ok; +} + +void DiagramScene::cancelConnection() +{ + m_connectSource = nullptr; + if (m_connectPreview) { + removeItem(m_connectPreview); + delete m_connectPreview; + m_connectPreview = nullptr; + } + for (auto* n : std::as_const(m_nodeItems)) + n->updatePortVisibility(); +} + +// --- editing operations --- + +QUuid DiagramScene::dropNode(const QString& typeId, QPointF scenePos) +{ + const NodeType* type = m_model->registry()->nodeType(typeId); + if (!type) + return {}; + QPointF pos = scenePos - QPointF(type->size.width() / 2, type->size.height() / 2); + if (snapEnabled()) + pos = Router::snapPoint(pos, m_routeConfig.gridStep); + auto* cmd = new AddNodeCommand(m_model, typeId, pos); + m_undo->push(cmd); + return cmd->nodeId(); +} + +void DiagramScene::deleteSelection() +{ + QList nodes; + QList edges; + for (QGraphicsItem* item : selectedItems()) { + if (auto* n = dynamic_cast(item)) + nodes.append(n->nodeId()); + else if (auto* e = dynamic_cast(item)) + edges.append(e->edgeId()); + } + if (nodes.isEmpty() && edges.isEmpty()) + return; + m_undo->push(new RemoveItemsCommand(m_model, nodes, edges)); +} + +void DiagramScene::copySelection() +{ + QList nodes; + QList edges; + for (QGraphicsItem* item : selectedItems()) { + if (auto* n = dynamic_cast(item)) + nodes.append(n->nodeId()); + else if (auto* e = dynamic_cast(item)) + edges.append(e->edgeId()); + } + if (nodes.isEmpty()) + return; + const QJsonObject frag = fragmentFromSelection(*m_model, nodes, edges); + auto* mime = new QMimeData(); + mime->setData(QLatin1String(kClipboardMime), QJsonDocument(frag).toJson()); + QGuiApplication::clipboard()->setMimeData(mime); +} + +void DiagramScene::cutSelection() +{ + copySelection(); + deleteSelection(); +} + +bool DiagramScene::canPaste() const +{ + const QMimeData* mime = QGuiApplication::clipboard()->mimeData(); + return mime && mime->hasFormat(QLatin1String(kClipboardMime)); +} + +void DiagramScene::paste() +{ + if (!canPaste()) + return; + const QByteArray data = + QGuiApplication::clipboard()->mimeData()->data(QLatin1String(kClipboardMime)); + const QJsonObject frag = QJsonDocument::fromJson(data).object(); + const double step = m_routeConfig.gridStep; + auto* cmd = new PasteCommand(m_model, frag, {step, step}); + m_undo->push(cmd); + clearSelection(); + for (QUuid id : cmd->pastedNodes()) + if (NodeItem* item = m_nodeItems.value(id)) + item->setSelected(true); +} + +void DiagramScene::selectAll() +{ + for (auto* n : std::as_const(m_nodeItems)) + n->setSelected(true); + for (auto* e : std::as_const(m_edgeItems)) + e->setSelected(true); +} + +// --- node dragging --- + +void DiagramScene::captureMoveOrigin() +{ + m_moveOrigin.clear(); + for (QGraphicsItem* item : selectedItems()) + if (auto* n = dynamic_cast(item)) + m_moveOrigin.insert(n->nodeId(), n->pos()); +} + +void DiagramScene::commitMove() +{ + QList moves; + for (auto it = m_moveOrigin.constBegin(); it != m_moveOrigin.constEnd(); ++it) { + const Node* n = m_model->node(it.key()); + if (n && n->pos != it.value()) + moves.append({it.key(), it.value(), n->pos}); + } + m_moveOrigin.clear(); + if (moves.isEmpty()) + return; + // Positions are already applied by the live drag; pushing the command + // re-applies them (no-op) and records the undo state. + m_undo->push(new MoveNodesCommand(m_model, moves)); +} + +// --- flow animation --- + +void DiagramScene::setFlowAnimationEnabled(bool enabled) +{ + if (m_animating == enabled) + return; + m_animating = enabled; + if (enabled) + m_animTimer->start(); + else + m_animTimer->stop(); + for (auto* e : std::as_const(m_edgeItems)) + e->update(); +} + +// --- events --- + +void DiagramScene::mouseMoveEvent(QGraphicsSceneMouseEvent* event) +{ + if (isConnecting()) + updateConnectionDrag(event->scenePos()); + QGraphicsScene::mouseMoveEvent(event); +} + +void DiagramScene::keyPressEvent(QKeyEvent* event) +{ + if (event->key() == Qt::Key_Escape && isConnecting()) { + cancelConnection(); + emit connectionMessage(QString()); + event->accept(); + return; + } + QGraphicsScene::keyPressEvent(event); +} + +} // namespace diag diff --git a/src/scene/DiagramScene.h b/src/scene/DiagramScene.h new file mode 100644 index 0000000..a46eeba --- /dev/null +++ b/src/scene/DiagramScene.h @@ -0,0 +1,108 @@ +#pragma once + +#include "PortItem.h" +#include "Theme.h" +#include "core/NetworkModel.h" +#include "core/Router.h" + +#include +#include +#include + +class QGraphicsPathItem; +class QSvgRenderer; +class QTimer; + +namespace diag { + +class EdgeItem; +class NodeItem; + +// Observes the NetworkModel and keeps graphics items in sync. Hosts the +// interactive connection drag, snapping/routing configuration, theme, undoable +// editing operations and the flow animation clock. +class DiagramScene : public QGraphicsScene { + Q_OBJECT +public: + explicit DiagramScene(NetworkModel* model, QUndoStack* undoStack, QObject* parent = nullptr); + + NetworkModel* model() const { return m_model; } + QUndoStack* undoStack() const { return m_undo; } + + const Theme& theme() const { return m_theme; } + void setTheme(const Theme& theme); + + const RouteConfig& routeConfig() const { return m_routeConfig; } + void setRouteConfig(const RouteConfig& cfg); + bool snapEnabled() const { return m_routeConfig.snapToGrid; } + + NodeItem* nodeItem(QUuid id) const { return m_nodeItems.value(id); } + EdgeItem* edgeItem(QUuid id) const { return m_edgeItems.value(id); } + QSvgRenderer* rendererFor(const QString& typeId); + + // --- interactive connection drag (started from a PortItem) --- + void startConnection(PortItem* port); + void updateConnectionDrag(QPointF scenePos); + bool finishConnection(QPointF scenePos); + void cancelConnection(); + bool isConnecting() const { return m_connectSource != nullptr; } + PortItem::ConnectState connectStateFor(const PortItem* port) const; + + // --- editing operations (all undoable) --- + QUuid dropNode(const QString& typeId, QPointF scenePos); + void deleteSelection(); + void copySelection(); + void cutSelection(); + void paste(); + bool canPaste() const; + void selectAll(); + + // Node-drag bookkeeping (called by NodeItem). + void captureMoveOrigin(); + void commitMove(); + + // --- flow animation --- + void setFlowAnimationEnabled(bool enabled); + bool flowAnimationEnabled() const { return m_animating; } + double animationPhase() const { return m_animPhase; } + double maxAbsFlow() const { return m_maxAbsFlow; } + +signals: + void connectionMessage(const QString& text); // status-bar feedback + +protected: + void mouseMoveEvent(QGraphicsSceneMouseEvent* event) override; + void keyPressEvent(QKeyEvent* event) override; + +private: + void onNodeAdded(QUuid id); + void onNodeRemoved(QUuid id); + void onNodeMoved(QUuid id); + void onEdgeAdded(QUuid id); + void onEdgeRemoved(QUuid id); + void rebuildAll(); + void rerouteAll(); + void recomputeMaxFlow(); + PortItem* portAt(QPointF scenePos) const; + + NetworkModel* m_model; + QUndoStack* m_undo; + Theme m_theme = Theme::make(ColorScheme::Light); + RouteConfig m_routeConfig; + + QHash m_nodeItems; + QHash m_edgeItems; + QHash m_renderers; + + PortItem* m_connectSource = nullptr; + QGraphicsPathItem* m_connectPreview = nullptr; + + QHash m_moveOrigin; + + QTimer* m_animTimer; + bool m_animating = false; + double m_animPhase = 0.0; + double m_maxAbsFlow = 0.0; +}; + +} // namespace diag diff --git a/src/scene/EdgeItem.cpp b/src/scene/EdgeItem.cpp new file mode 100644 index 0000000..402abc5 --- /dev/null +++ b/src/scene/EdgeItem.cpp @@ -0,0 +1,167 @@ +#include "EdgeItem.h" + +#include "DiagramScene.h" +#include "core/NetworkModel.h" +#include "core/Router.h" + +#include +#include +#include + +namespace diag { + +EdgeItem::EdgeItem(DiagramScene* scene, QUuid edgeId) : m_scene(scene), m_id(edgeId) +{ + setFlag(ItemIsSelectable); + setZValue(0); + setAcceptHoverEvents(true); +} + +NetworkModel* EdgeItem::model() const +{ + return m_scene->model(); +} + +void EdgeItem::setRoute(const QList& poly, const QList& hops) +{ + prepareGeometryChange(); + m_poly = poly; + m_hops = hops; + const RouteConfig& cfg = m_scene->routeConfig(); + m_path = Router::toPath(poly, cfg.arcOnIntersection ? hops : QList{}, + cfg.arcRadius); + update(); +} + +QRectF EdgeItem::boundingRect() const +{ + const double m = 14; + return m_path.boundingRect().adjusted(-m, -m, m, m); +} + +QPainterPath EdgeItem::shape() const +{ + QPainterPathStroker stroker; + stroker.setWidth(qMax(10.0, basePen().widthF() + 6)); + return stroker.createStroke(m_path); +} + +QPen EdgeItem::basePen() const +{ + const Theme& theme = m_scene->theme(); + const Edge* e = model()->edge(m_id); + QColor color; + const QString colorName = model()->edgeProperty(m_id, QStringLiteral("color")).toString(); + if (QColor::isValidColorName(colorName)) + color = QColor(colorName); + else + color = theme.relationColor(e ? model()->registry()->relation(e->relation) : nullptr); + + const double width = model()->edgeProperty(m_id, QStringLiteral("lineWidth")).toDouble(); + const QString style = model()->edgeProperty(m_id, QStringLiteral("lineStyle")).toString(); + Qt::PenStyle penStyle = Qt::SolidLine; + if (style == QLatin1String("Dashed")) + penStyle = Qt::DashLine; + else if (style == QLatin1String("Dotted")) + penStyle = Qt::DotLine; + else if (style == QLatin1String("DashDot")) + penStyle = Qt::DashDotLine; + + QPen pen(color, qMax(0.5, width), penStyle, Qt::FlatCap, Qt::MiterJoin); + return pen; +} + +void EdgeItem::paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*) +{ + if (m_poly.size() < 2) + return; + painter->setRenderHint(QPainter::Antialiasing); + const Theme& theme = m_scene->theme(); + const QPen pen = basePen(); + + if (isSelected()) { + QColor halo = theme.selection; + halo.setAlpha(90); + painter->setPen(QPen(halo, pen.widthF() + 6, Qt::SolidLine, Qt::RoundCap)); + painter->setBrush(Qt::NoBrush); + painter->drawPath(m_path); + } + + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(m_path); + + // End decorations sit on the raw polyline ends (arcs never reach ends). + const QString head = model()->edgeProperty(m_id, QStringLiteral("headDecoration")).toString(); + const QString tail = model()->edgeProperty(m_id, QStringLiteral("tailDecoration")).toString(); + drawDecoration(painter, head, m_poly.last(), m_poly.at(m_poly.size() - 2), pen.color(), + pen.widthF()); + drawDecoration(painter, tail, m_poly.first(), m_poly.at(1), pen.color(), pen.widthF()); + + if (m_scene->flowAnimationEnabled()) + drawFlowOverlay(painter, m_path, pen.widthF()); + + const QString title = model()->edgeProperty(m_id, QStringLiteral("title")).toString(); + if (!title.isEmpty()) { + const QPointF mid = Router::pointAt(m_poly, 0.5); + painter->setPen(theme.label); + QFont f = painter->font(); + f.setPointSizeF(8.0); + painter->setFont(f); + painter->drawText(QRectF(mid.x() - 60, mid.y() - 18, 120, 14), Qt::AlignCenter, title); + } +} + +void EdgeItem::drawDecoration(QPainter* painter, const QString& kind, QPointF tip, QPointF back, + const QColor& color, double width) const +{ + if (kind.isEmpty() || kind == QLatin1String("None")) + return; + QLineF dir(back, tip); + if (dir.length() < 1e-6) + return; + dir.setLength(1.0); + const QPointF u = dir.p2() - dir.p1(); // unit vector toward the tip + const QPointF n(-u.y(), u.x()); // unit normal + const double s = qMax(8.0, width * 3.5); // decoration size + + painter->save(); + painter->setPen(QPen(color, qMax(1.0, width * 0.8))); + painter->setBrush(color); + if (kind == QLatin1String("Arrow")) { + QPolygonF poly{tip, tip - u * s + n * s * 0.45, tip - u * s - n * s * 0.45}; + painter->drawPolygon(poly); + } else if (kind == QLatin1String("Circle")) { + painter->drawEllipse(tip - u * s * 0.5, s * 0.5, s * 0.5); + } else if (kind == QLatin1String("Diamond")) { + const QPointF c = tip - u * s * 0.5; + QPolygonF poly{tip, c + n * s * 0.4, tip - u * s, c - n * s * 0.4}; + painter->drawPolygon(poly); + } else if (kind == QLatin1String("Bar")) { + painter->setPen(QPen(color, qMax(1.5, width))); + painter->drawLine(tip + n * s * 0.5, tip - n * s * 0.5); + } + painter->restore(); +} + +void EdgeItem::drawFlowOverlay(QPainter* painter, const QPainterPath& path, double width) const +{ + const Edge* e = model()->edge(m_id); + if (!e || qAbs(e->flowRate) < 1e-9) + return; + const double maxFlow = qMax(1e-9, m_scene->maxAbsFlow()); + const double ratio = qBound(0.15, qAbs(e->flowRate) / maxFlow, 1.0); + + // Moving dashes: speed and thickness encode volume, offset sign encodes + // direction (positive flow runs from tail to head). + QPen pen(m_scene->theme().flowOverlay, qMax(1.0, width * (0.25 + 0.45 * ratio)), + Qt::CustomDashLine, Qt::FlatCap); + pen.setDashPattern({1.2, 3.2}); + const double dir = e->flowRate > 0 ? -1.0 : 1.0; + pen.setDashOffset(dir * m_scene->animationPhase() * (0.4 + 1.6 * ratio)); + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(path); +} + +} // namespace diag diff --git a/src/scene/EdgeItem.h b/src/scene/EdgeItem.h new file mode 100644 index 0000000..8caa83f --- /dev/null +++ b/src/scene/EdgeItem.h @@ -0,0 +1,44 @@ +#pragma once + +#include "core/Types.h" + +#include + +namespace diag { + +class DiagramScene; +class NetworkModel; + +// An edge (pipe/cable) routed orthogonally between two ports. Supports line +// styles, head/tail decorations (arrow, circle, diamond, bar) and an animated +// flow overlay whose direction and density follow the solver result. +class EdgeItem : public QGraphicsObject { + Q_OBJECT +public: + EdgeItem(DiagramScene* scene, QUuid edgeId); + + QUuid edgeId() const { return m_id; } + NetworkModel* model() const; + + const QList& polyline() const { return m_poly; } + void setRoute(const QList& poly, const QList& hops); + + QRectF boundingRect() const override; + QPainterPath shape() const override; + void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, + QWidget* widget) override; + +private: + QPen basePen() const; + void drawDecoration(QPainter* painter, const QString& kind, QPointF tip, QPointF back, + const QColor& color, double width) const; + void drawFlowOverlay(QPainter* painter, const QPainterPath& path, double width) const; + + DiagramScene* m_scene; + QUuid m_id; + QList m_poly; + QList m_hops; + QPainterPath m_path; +}; + +} // namespace diag diff --git a/src/scene/GridView.cpp b/src/scene/GridView.cpp new file mode 100644 index 0000000..afc1891 --- /dev/null +++ b/src/scene/GridView.cpp @@ -0,0 +1,164 @@ +#include "GridView.h" + +#include "DiagramScene.h" + +#include +#include +#include +#include +#include + +namespace diag { + +GridView::GridView(DiagramScene* scene, QWidget* parent) + : QGraphicsView(scene, parent), m_scene(scene) +{ + setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform); + setDragMode(RubberBandDrag); + setTransformationAnchor(AnchorUnderMouse); + setResizeAnchor(AnchorViewCenter); + setAcceptDrops(true); + setMouseTracking(true); + viewport()->setMouseTracking(true); +} + +double GridView::zoomFactor() const +{ + return transform().m11(); +} + +void GridView::applyZoom(double factor) +{ + const double current = zoomFactor(); + const double target = qBound(0.1, current * factor, 8.0); + scale(target / current, target / current); + emit zoomChanged(zoomFactor()); +} + +void GridView::zoomIn() +{ + applyZoom(1.2); +} + +void GridView::zoomOut() +{ + applyZoom(1.0 / 1.2); +} + +void GridView::zoomReset() +{ + resetTransform(); + emit zoomChanged(zoomFactor()); +} + +void GridView::zoomToFit() +{ + const QRectF bounds = scene()->itemsBoundingRect(); + if (bounds.isEmpty()) + return; + fitInView(bounds.adjusted(-40, -40, 40, 40), Qt::KeepAspectRatio); + emit zoomChanged(zoomFactor()); +} + +void GridView::drawBackground(QPainter* painter, const QRectF& rect) +{ + const Theme& theme = m_scene->theme(); + painter->fillRect(rect, theme.canvasBackground); + + const double step = m_scene->routeConfig().gridStep; + if (step <= 0 || zoomFactor() * step < 4) + return; + + const double left = std::floor(rect.left() / step) * step; + const double top = std::floor(rect.top() / step) * step; + QList minor; + QList major; + const int majorEvery = 5; + for (double x = left; x <= rect.right(); x += step) { + const bool isMajor = qAbs(std::fmod(x, step * majorEvery)) < 1e-6; + (isMajor ? major : minor).append(QLineF(x, rect.top(), x, rect.bottom())); + } + for (double y = top; y <= rect.bottom(); y += step) { + const bool isMajor = qAbs(std::fmod(y, step * majorEvery)) < 1e-6; + (isMajor ? major : minor).append(QLineF(rect.left(), y, rect.right(), y)); + } + painter->setPen(QPen(theme.gridMinor, 0)); + painter->drawLines(minor.data(), minor.size()); + painter->setPen(QPen(theme.gridMajor, 0)); + painter->drawLines(major.data(), major.size()); +} + +void GridView::wheelEvent(QWheelEvent* event) +{ + if (event->modifiers() & Qt::ControlModifier) { + applyZoom(event->angleDelta().y() > 0 ? 1.15 : 1.0 / 1.15); + event->accept(); + return; + } + QGraphicsView::wheelEvent(event); +} + +void GridView::mousePressEvent(QMouseEvent* event) +{ + if (event->button() == Qt::MiddleButton) { + m_panning = true; + m_panStart = event->pos(); + setCursor(Qt::ClosedHandCursor); + event->accept(); + return; + } + QGraphicsView::mousePressEvent(event); +} + +void GridView::mouseMoveEvent(QMouseEvent* event) +{ + emit cursorMoved(mapToScene(event->pos())); + if (m_panning) { + const QPoint d = event->pos() - m_panStart; + m_panStart = event->pos(); + horizontalScrollBar()->setValue(horizontalScrollBar()->value() - d.x()); + verticalScrollBar()->setValue(verticalScrollBar()->value() - d.y()); + event->accept(); + return; + } + QGraphicsView::mouseMoveEvent(event); +} + +void GridView::mouseReleaseEvent(QMouseEvent* event) +{ + if (event->button() == Qt::MiddleButton && m_panning) { + m_panning = false; + unsetCursor(); + event->accept(); + return; + } + QGraphicsView::mouseReleaseEvent(event); +} + +void GridView::dragEnterEvent(QDragEnterEvent* event) +{ + if (event->mimeData()->hasFormat(QLatin1String(kNodeTypeMime))) + event->acceptProposedAction(); +} + +void GridView::dragMoveEvent(QDragMoveEvent* event) +{ + if (event->mimeData()->hasFormat(QLatin1String(kNodeTypeMime))) + event->acceptProposedAction(); +} + +void GridView::dropEvent(QDropEvent* event) +{ + if (handleNodeTypeDrop(event->mimeData(), event->position().toPoint())) + event->acceptProposedAction(); +} + +bool GridView::handleNodeTypeDrop(const QMimeData* mime, QPoint viewportPos) +{ + if (!mime || !mime->hasFormat(QLatin1String(kNodeTypeMime))) + return false; + const QString typeId = QString::fromUtf8(mime->data(QLatin1String(kNodeTypeMime))); + return !m_scene->dropNode(typeId, mapToScene(viewportPos)).isNull(); +} + +} // namespace diag diff --git a/src/scene/GridView.h b/src/scene/GridView.h new file mode 100644 index 0000000..58151ee --- /dev/null +++ b/src/scene/GridView.h @@ -0,0 +1,53 @@ +#pragma once + +#include + +class QMimeData; + +namespace diag { + +class DiagramScene; + +// Canvas view: draws the rectangular grid, zooms with Ctrl+wheel (anchored +// under the cursor), pans with the middle mouse button, rubber-band selects, +// and accepts node-type drops from the palette. +class GridView : public QGraphicsView { + Q_OBJECT +public: + static constexpr const char* kNodeTypeMime = "application/x-diag-nodetype"; + + explicit GridView(DiagramScene* scene, QWidget* parent = nullptr); + + void zoomIn(); + void zoomOut(); + void zoomReset(); + void zoomToFit(); + double zoomFactor() const; + + // Decodes a node-type mime payload and drops it at the viewport position; + // returns true when a node was created. Called from dropEvent. + bool handleNodeTypeDrop(const QMimeData* mime, QPoint viewportPos); + +signals: + void zoomChanged(double factor); + void cursorMoved(QPointF scenePos); + +protected: + void drawBackground(QPainter* painter, const QRectF& rect) override; + void wheelEvent(QWheelEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + void dragEnterEvent(QDragEnterEvent* event) override; + void dragMoveEvent(QDragMoveEvent* event) override; + void dropEvent(QDropEvent* event) override; + +private: + void applyZoom(double factor); + + DiagramScene* m_scene; + bool m_panning = false; + QPoint m_panStart; +}; + +} // namespace diag diff --git a/src/scene/NodeItem.cpp b/src/scene/NodeItem.cpp new file mode 100644 index 0000000..b6edbc4 --- /dev/null +++ b/src/scene/NodeItem.cpp @@ -0,0 +1,183 @@ +#include "NodeItem.h" + +#include "DiagramScene.h" +#include "PortItem.h" +#include "core/NetworkModel.h" +#include "core/Router.h" + +#include +#include + +namespace diag { + +namespace { +constexpr double kTitleHeight = 18.0; +constexpr double kMargin = 4.0; +} + +NodeItem::NodeItem(DiagramScene* scene, QUuid nodeId) : m_scene(scene), m_id(nodeId) +{ + setFlags(ItemIsMovable | ItemIsSelectable | ItemSendsGeometryChanges); + setAcceptHoverEvents(true); + setZValue(1); + + const NodeType* type = nodeType(); + if (type) { + for (const auto& spec : type->ports) { + auto* port = new PortItem(this, spec); + port->setPos(spec.pos.x() * type->size.width(), spec.pos.y() * type->size.height()); + m_ports.append(port); + } + } + syncFromModel(); + updatePortVisibility(); +} + +NetworkModel* NodeItem::model() const +{ + return m_scene->model(); +} + +const NodeType* NodeItem::nodeType() const +{ + return model()->nodeTypeOf(m_id); +} + +PortItem* NodeItem::portItem(const QString& portId) const +{ + for (auto* p : m_ports) + if (p->spec().id == portId) + return p; + return nullptr; +} + +void NodeItem::syncFromModel() +{ + const Node* n = model()->node(m_id); + if (!n) + return; + m_syncing = true; + if (pos() != n->pos) + setPos(n->pos); + m_syncing = false; + update(); +} + +void NodeItem::updatePortVisibility() +{ + const bool connecting = m_scene->isConnecting(); + for (auto* p : m_ports) { + bool visible = isSelected() || m_hovered; + if (connecting) { + const auto state = m_scene->connectStateFor(p); + visible = visible || state == PortItem::ConnectState::Compatible; + p->setConnectState(state); + } else { + p->setConnectState(PortItem::ConnectState::Normal); + } + p->setVisible(visible); + } +} + +QRectF NodeItem::nodeRect() const +{ + const NodeType* type = nodeType(); + const QSizeF s = type ? type->size : QSizeF(60, 60); + return {0, 0, s.width(), s.height()}; +} + +QRectF NodeItem::boundingRect() const +{ + return nodeRect().adjusted(-kMargin - 4, -kMargin - 4, kMargin + 4, + kMargin + kTitleHeight + 4); +} + +QPainterPath NodeItem::shape() const +{ + QPainterPath path; + path.addRect(nodeRect().adjusted(-kMargin, -kMargin, kMargin, kMargin)); + return path; +} + +void NodeItem::paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*) +{ + const Theme& theme = m_scene->theme(); + const QRectF rect = nodeRect(); + painter->setRenderHint(QPainter::Antialiasing); + + if (QSvgRenderer* renderer = m_scene->rendererFor(model()->node(m_id)->typeId); + renderer && renderer->isValid()) { + renderer->render(painter, rect); + } else { + painter->setPen(QPen(theme.nodeOutline, 1.5)); + painter->setBrush(Qt::NoBrush); + painter->drawRoundedRect(rect, 6, 6); + const NodeType* type = nodeType(); + painter->drawText(rect, Qt::AlignCenter, type ? type->name : QStringLiteral("?")); + } + + if (isSelected()) { + QPen pen(theme.selection, 1.2, Qt::DashLine); + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->drawRect(rect.adjusted(-kMargin, -kMargin, kMargin, kMargin)); + } + + const QString title = model()->nodeProperty(m_id, QStringLiteral("title")).toString(); + const QString text = title.isEmpty() ? (nodeType() ? nodeType()->name : QString()) : title; + if (!text.isEmpty()) { + const QString colorName = + model()->nodeProperty(m_id, QStringLiteral("labelColor")).toString(); + const QColor c = QColor::isValidColorName(colorName) ? QColor(colorName) : theme.label; + painter->setPen(c); + QFont f = painter->font(); + f.setPointSizeF(8.5); + painter->setFont(f); + painter->drawText(QRectF(rect.left() - 30, rect.bottom() + kMargin, rect.width() + 60, + kTitleHeight), + Qt::AlignHCenter | Qt::AlignTop, text); + } +} + +QVariant NodeItem::itemChange(GraphicsItemChange change, const QVariant& value) +{ + if (change == ItemPositionChange && !m_syncing && m_scene->snapEnabled()) { + return Router::snapPoint(value.toPointF(), m_scene->routeConfig().gridStep); + } + if (change == ItemPositionHasChanged && !m_syncing) { + // Live-sync the model while dragging so edges follow; the undoable + // MoveNodesCommand is created on mouse release. + model()->moveNode(m_id, pos()); + } + if (change == ItemSelectedHasChanged) { + updatePortVisibility(); + update(); + } + return QGraphicsItem::itemChange(change, value); +} + +void NodeItem::mousePressEvent(QGraphicsSceneMouseEvent* event) +{ + QGraphicsItem::mousePressEvent(event); + m_scene->captureMoveOrigin(); +} + +void NodeItem::mouseReleaseEvent(QGraphicsSceneMouseEvent* event) +{ + QGraphicsItem::mouseReleaseEvent(event); + m_scene->commitMove(); +} + +void NodeItem::hoverEnterEvent(QGraphicsSceneHoverEvent*) +{ + m_hovered = true; + updatePortVisibility(); +} + +void NodeItem::hoverLeaveEvent(QGraphicsSceneHoverEvent*) +{ + m_hovered = false; + updatePortVisibility(); +} + +} // namespace diag diff --git a/src/scene/NodeItem.h b/src/scene/NodeItem.h new file mode 100644 index 0000000..9749d23 --- /dev/null +++ b/src/scene/NodeItem.h @@ -0,0 +1,53 @@ +#pragma once + +#include "core/Types.h" + +#include + +namespace diag { + +class DiagramScene; +class NetworkModel; +class PortItem; +class QSvgRendererPtr; + +// A node on the canvas, rendered from the node type's SVG. Ports appear when +// the node is selected or hovered, or while a compatible connection drag is +// running. Dragging snaps to the grid and is undoable. +class NodeItem : public QGraphicsItem { +public: + NodeItem(DiagramScene* scene, QUuid nodeId); + + QUuid nodeId() const { return m_id; } + NetworkModel* model() const; + const NodeType* nodeType() const; + QList portItems() const { return m_ports; } + PortItem* portItem(const QString& portId) const; + + // Re-read model state (position, title) after external changes. + void syncFromModel(); + void updatePortVisibility(); + + QRectF boundingRect() const override; + QPainterPath shape() const override; + void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, + QWidget* widget) override; + +protected: + QVariant itemChange(GraphicsItemChange change, const QVariant& value) override; + void mousePressEvent(QGraphicsSceneMouseEvent* event) override; + void mouseReleaseEvent(QGraphicsSceneMouseEvent* event) override; + void hoverEnterEvent(QGraphicsSceneHoverEvent* event) override; + void hoverLeaveEvent(QGraphicsSceneHoverEvent* event) override; + +private: + QRectF nodeRect() const; + + DiagramScene* m_scene; + QUuid m_id; + QList m_ports; + bool m_syncing = false; + bool m_hovered = false; +}; + +} // namespace diag diff --git a/src/scene/PortItem.cpp b/src/scene/PortItem.cpp new file mode 100644 index 0000000..3179ec5 --- /dev/null +++ b/src/scene/PortItem.cpp @@ -0,0 +1,126 @@ +#include "PortItem.h" + +#include "DiagramScene.h" +#include "NodeItem.h" + +#include +#include +#include + +namespace diag { + +namespace { +constexpr double kRadius = 5.0; +} + +PortItem::PortItem(NodeItem* parent, const PortSpec& spec) : QGraphicsItem(parent), m_node(parent), m_spec(spec) +{ + setAcceptHoverEvents(true); + setCursor(Qt::CrossCursor); + setZValue(2); + const Relation* rel = parent->model()->registry()->relation(spec.relation); + setToolTip(QStringLiteral("%1 · %2 · %3") + .arg(spec.id, rel ? rel->name : spec.relation, + portDirectionToString(spec.direction))); +} + +DiagramScene* PortItem::diagramScene() const +{ + return qobject_cast(scene()); +} + +void PortItem::setConnectState(ConnectState s) +{ + if (m_state == s) + return; + m_state = s; + update(); +} + +QRectF PortItem::boundingRect() const +{ + const double r = kRadius + 3; + return {-r, -r, 2 * r, 2 * r}; +} + +void PortItem::paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*) +{ + const DiagramScene* ds = diagramScene(); + const Theme& theme = ds->theme(); + const Relation* rel = m_node->model()->registry()->relation(m_spec.relation); + QColor fill = theme.relationColor(rel); + QColor stroke = theme.portStroke; + + double r = kRadius; + switch (m_state) { + case ConnectState::Compatible: + r = kRadius + 2; + stroke = theme.selection; + break; + case ConnectState::Incompatible: + fill.setAlpha(60); + stroke.setAlpha(60); + break; + case ConnectState::Normal: + if (m_hovered) + r = kRadius + 1.5; + break; + } + + painter->setRenderHint(QPainter::Antialiasing); + painter->setPen(QPen(stroke, m_state == ConnectState::Compatible ? 2.0 : 1.2)); + if (m_spec.direction == PortDirection::In) { + // Hollow ring marks an input. + painter->setBrush(theme.canvasBackground); + painter->drawEllipse(QPointF(0, 0), r, r); + painter->setBrush(fill); + painter->drawEllipse(QPointF(0, 0), r * 0.45, r * 0.45); + } else { + painter->setBrush(fill); + painter->drawEllipse(QPointF(0, 0), r, r); + } +} + +void PortItem::mousePressEvent(QGraphicsSceneMouseEvent* event) +{ + if (event->button() == Qt::LeftButton) { + diagramScene()->startConnection(this); + event->accept(); + return; + } + QGraphicsItem::mousePressEvent(event); +} + +void PortItem::mouseMoveEvent(QGraphicsSceneMouseEvent* event) +{ + if (diagramScene()->isConnecting()) { + diagramScene()->updateConnectionDrag(event->scenePos()); + event->accept(); + return; + } + QGraphicsItem::mouseMoveEvent(event); +} + +void PortItem::mouseReleaseEvent(QGraphicsSceneMouseEvent* event) +{ + if (diagramScene()->isConnecting()) { + diagramScene()->finishConnection(event->scenePos()); + event->accept(); + return; + } + QGraphicsItem::mouseReleaseEvent(event); +} + +void PortItem::hoverEnterEvent(QGraphicsSceneHoverEvent*) +{ + m_hovered = true; + update(); +} + +void PortItem::hoverLeaveEvent(QGraphicsSceneHoverEvent*) +{ + m_hovered = false; + update(); +} + +} // namespace diag diff --git a/src/scene/PortItem.h b/src/scene/PortItem.h new file mode 100644 index 0000000..8c7880b --- /dev/null +++ b/src/scene/PortItem.h @@ -0,0 +1,46 @@ +#pragma once + +#include "core/Types.h" + +#include + +namespace diag { + +class NodeItem; +class DiagramScene; + +// A connection point on a node. Visible when the node is focused/hovered or +// while a compatible connection drag is in progress. Pressing a port starts +// an interactive edge drag. +class PortItem : public QGraphicsItem { +public: + enum class ConnectState { Normal, Compatible, Incompatible }; + + PortItem(NodeItem* parent, const PortSpec& spec); + + const PortSpec& spec() const { return m_spec; } + NodeItem* nodeItem() const { return m_node; } + + void setConnectState(ConnectState s); + + QRectF boundingRect() const override; + void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, + QWidget* widget) override; + +protected: + void mousePressEvent(QGraphicsSceneMouseEvent* event) override; + void mouseMoveEvent(QGraphicsSceneMouseEvent* event) override; + void mouseReleaseEvent(QGraphicsSceneMouseEvent* event) override; + void hoverEnterEvent(QGraphicsSceneHoverEvent* event) override; + void hoverLeaveEvent(QGraphicsSceneHoverEvent* event) override; + +private: + DiagramScene* diagramScene() const; + + NodeItem* m_node; + PortSpec m_spec; + ConnectState m_state = ConnectState::Normal; + bool m_hovered = false; +}; + +} // namespace diag diff --git a/src/scene/Theme.cpp b/src/scene/Theme.cpp new file mode 100644 index 0000000..148eb50 --- /dev/null +++ b/src/scene/Theme.cpp @@ -0,0 +1,70 @@ +#include "Theme.h" + +namespace diag { + +QColor Theme::relationColor(const Relation* r) const +{ + if (!r) + return scheme == ColorScheme::Light ? QColor(0x50, 0x50, 0x50) : QColor(0xb0, 0xb0, 0xb0); + return scheme == ColorScheme::Light ? r->color : r->darkColor; +} + +Theme Theme::make(ColorScheme scheme) +{ + Theme t; + t.scheme = scheme; + switch (scheme) { + case ColorScheme::Light: + t.canvasBackground = QColor(0xfa, 0xfa, 0xf7); + t.gridMinor = QColor(0xe8, 0xe8, 0xe2); + t.gridMajor = QColor(0xd5, 0xd5, 0xcd); + t.nodeOutline = QColor(0x3a, 0x3a, 0x3a); + t.selection = QColor(0x1a, 0x73, 0xe8); + t.label = QColor(0x20, 0x20, 0x20); + t.portStroke = QColor(0x28, 0x28, 0x28); + t.flowOverlay = QColor(0xff, 0xff, 0xff, 0xd0); + break; + case ColorScheme::Dark: + t.canvasBackground = QColor(0x1e, 0x21, 0x26); + t.gridMinor = QColor(0x2a, 0x2e, 0x35); + t.gridMajor = QColor(0x3a, 0x40, 0x4a); + t.nodeOutline = QColor(0xd0, 0xd0, 0xd0); + t.selection = QColor(0x6a, 0xb0, 0xff); + t.label = QColor(0xe0, 0xe0, 0xe0); + t.portStroke = QColor(0xe8, 0xe8, 0xe8); + t.flowOverlay = QColor(0xff, 0xff, 0xff, 0xb0); + break; + case ColorScheme::HighContrast: + t.canvasBackground = QColor(Qt::white); + t.gridMinor = QColor(0xc8, 0xc8, 0xc8); + t.gridMajor = QColor(0x80, 0x80, 0x80); + t.nodeOutline = QColor(Qt::black); + t.selection = QColor(0xd0, 0x00, 0x70); + t.label = QColor(Qt::black); + t.portStroke = QColor(Qt::black); + t.flowOverlay = QColor(0x00, 0x00, 0x00, 0xd0); + break; + } + return t; +} + +QString Theme::schemeName(ColorScheme scheme) +{ + switch (scheme) { + case ColorScheme::Light: return QStringLiteral("Light"); + case ColorScheme::Dark: return QStringLiteral("Dark"); + case ColorScheme::HighContrast: return QStringLiteral("High contrast"); + } + return QStringLiteral("Light"); +} + +ColorScheme Theme::schemeFromName(const QString& name) +{ + if (name == QLatin1String("Dark")) + return ColorScheme::Dark; + if (name == QLatin1String("High contrast")) + return ColorScheme::HighContrast; + return ColorScheme::Light; +} + +} // namespace diag diff --git a/src/scene/Theme.h b/src/scene/Theme.h new file mode 100644 index 0000000..9f0fafd --- /dev/null +++ b/src/scene/Theme.h @@ -0,0 +1,31 @@ +#pragma once + +#include "core/Types.h" + +#include + +namespace diag { + +enum class ColorScheme { Light, Dark, HighContrast }; + +// Canvas palette per color scheme; relation colors come from the registry +// (light or dark variant picked here). +struct Theme { + ColorScheme scheme = ColorScheme::Light; + QColor canvasBackground; + QColor gridMinor; + QColor gridMajor; + QColor nodeOutline; + QColor selection; + QColor label; + QColor portStroke; + QColor flowOverlay; + + QColor relationColor(const Relation* r) const; + + static Theme make(ColorScheme scheme); + static QString schemeName(ColorScheme scheme); + static ColorScheme schemeFromName(const QString& name); +}; + +} // namespace diag diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dcc0d08..c719f4e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,3 +11,5 @@ diag_add_test(tst_properties diagcore) diag_add_test(tst_router diagcore) diag_add_test(tst_solver diagcore) diag_add_test(tst_json diagcore) +diag_add_test(tst_undo diagscene) +diag_add_test(tst_scene diagscene) diff --git a/tests/tst_scene.cpp b/tests/tst_scene.cpp new file mode 100644 index 0000000..552af73 --- /dev/null +++ b/tests/tst_scene.cpp @@ -0,0 +1,215 @@ +#include "TestFixtures.h" +#include "core/NetworkModel.h" +#include "scene/DiagramScene.h" +#include "scene/EdgeItem.h" +#include "scene/GridView.h" +#include "scene/NodeItem.h" +#include "scene/PortItem.h" + +#include +#include +#include +#include + +using namespace diag; + +class TestScene : public QObject { + Q_OBJECT +private slots: + void init() + { + fixtures::fillRegistry(m_reg); + delete m_scene; + delete m_model; + m_model = new NetworkModel(&m_reg, this); + m_undo = new QUndoStack(m_model); + m_scene = new DiagramScene(m_model, m_undo, this); + } + + void dropNode_createsSnappedItemAndUndoes() + { + const QUuid id = m_scene->dropNode(QStringLiteral("pump"), {107, 93}); + QVERIFY(!id.isNull()); + QVERIFY(m_scene->nodeItem(id)); + // Center (107,93) minus half size (30,30) = (77,63), snapped to 20-grid. + QCOMPARE(m_model->node(id)->pos, QPointF(80, 60)); + QCOMPARE(m_scene->nodeItem(id)->pos(), QPointF(80, 60)); + m_undo->undo(); + QVERIFY(!m_scene->nodeItem(id)); + } + + void nodeDrag_snapsAndIsUndoable() + { + const QUuid id = m_scene->dropNode(QStringLiteral("pump"), {100, 100}); + NodeItem* item = m_scene->nodeItem(id); + const QPointF before = item->pos(); + + // Simulate the drag protocol NodeItem uses. + item->setSelected(true); + m_scene->captureMoveOrigin(); + item->setPos(before + QPointF(37, 42)); // snapped by itemChange + m_scene->commitMove(); + + QCOMPARE(item->pos(), before + QPointF(40, 40)); + QCOMPARE(m_model->node(id)->pos, before + QPointF(40, 40)); + m_undo->undo(); + QCOMPARE(m_model->node(id)->pos, before); + QCOMPARE(item->pos(), before); + } + + void ports_visibleOnlyWhenFocused() + { + const QUuid id = m_scene->dropNode(QStringLiteral("pump"), {100, 100}); + NodeItem* item = m_scene->nodeItem(id); + QCOMPARE(item->portItems().size(), 2); + for (auto* p : item->portItems()) + QVERIFY(!p->isVisible()); + item->setSelected(true); + for (auto* p : item->portItems()) + QVERIFY(p->isVisible()); + item->setSelected(false); + for (auto* p : item->portItems()) + QVERIFY(!p->isVisible()); + } + + void interactiveConnect_compatiblePortsHighlighted() + { + const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0}); + const QUuid pump = m_scene->dropNode(QStringLiteral("pump"), {300, 0}); + const QUuid gen = m_scene->dropNode(QStringLiteral("generator"), {300, 200}); + PortItem* srcOut = m_scene->nodeItem(src)->portItem(QStringLiteral("out")); + PortItem* pumpIn = m_scene->nodeItem(pump)->portItem(QStringLiteral("in")); + PortItem* pumpOut = m_scene->nodeItem(pump)->portItem(QStringLiteral("out")); + PortItem* genOut = m_scene->nodeItem(gen)->portItem(QStringLiteral("out")); + + m_scene->startConnection(srcOut); + QVERIFY(m_scene->isConnecting()); + QCOMPARE(m_scene->connectStateFor(pumpIn), PortItem::ConnectState::Compatible); + QCOMPARE(m_scene->connectStateFor(pumpOut), PortItem::ConnectState::Incompatible); + QCOMPARE(m_scene->connectStateFor(genOut), PortItem::ConnectState::Incompatible); + QVERIFY(pumpIn->isVisible()); // compatible ports shown during drag + + // Finish on the compatible port: edge created through the undo stack. + QVERIFY(m_scene->finishConnection(pumpIn->scenePos())); + QVERIFY(!m_scene->isConnecting()); + QCOMPARE(m_model->edgeIds().size(), 1); + m_undo->undo(); + QCOMPARE(m_model->edgeIds().size(), 0); + } + + void interactiveConnect_rejectsIncompatible() + { + const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0}); + const QUuid gen = m_scene->dropNode(QStringLiteral("generator"), {300, 0}); + PortItem* srcOut = m_scene->nodeItem(src)->portItem(QStringLiteral("out")); + PortItem* genOut = m_scene->nodeItem(gen)->portItem(QStringLiteral("out")); + + QSignalSpy spy(m_scene, &DiagramScene::connectionMessage); + m_scene->startConnection(srcOut); + QVERIFY(!m_scene->finishConnection(genOut->scenePos())); + QCOMPARE(m_model->edgeIds().size(), 0); + QVERIFY(spy.count() >= 2); // start message + rejection reason + } + + void edgeRoute_isOrthogonalBetweenPorts() + { + const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0}); + const QUuid pump = m_scene->dropNode(QStringLiteral("pump"), {400, 200}); + const QUuid e = m_model->addEdge(src, "out", pump, "in"); + EdgeItem* item = m_scene->edgeItem(e); + QVERIFY(item); + const auto& poly = item->polyline(); + QVERIFY(poly.size() >= 2); + QCOMPARE(poly.first(), m_model->portScenePos(src, QStringLiteral("out"))); + QCOMPARE(poly.last(), m_model->portScenePos(pump, QStringLiteral("in"))); + for (int i = 0; i + 1 < poly.size(); ++i) + QVERIFY(qAbs(poly[i].x() - poly[i + 1].x()) < 1e-6 + || qAbs(poly[i].y() - poly[i + 1].y()) < 1e-6); + } + + void edgesFollowNodeMove() + { + const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0}); + const QUuid pump = m_scene->dropNode(QStringLiteral("pump"), {400, 0}); + const QUuid e = m_model->addEdge(src, "out", pump, "in"); + m_model->moveNode(pump, {400, 300}); + QCOMPARE(m_scene->edgeItem(e)->polyline().last(), + m_model->portScenePos(pump, QStringLiteral("in"))); + } + + void copyPasteDelete_roundTrip() + { + const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0}); + const QUuid pump = m_scene->dropNode(QStringLiteral("pump"), {300, 0}); + m_model->addEdge(src, "out", pump, "in"); + m_scene->nodeItem(src)->setSelected(true); + m_scene->nodeItem(pump)->setSelected(true); + + m_scene->copySelection(); + QVERIFY(m_scene->canPaste()); + m_scene->paste(); + QCOMPARE(m_model->nodeIds().size(), 4); + QCOMPARE(m_model->edgeIds().size(), 2); + // Pasted nodes become the new selection. + QCOMPARE(m_scene->selectedItems().size(), 2); + + m_scene->selectAll(); + m_scene->deleteSelection(); + QCOMPARE(m_model->nodeIds().size(), 0); + QCOMPARE(m_model->edgeIds().size(), 0); + m_undo->undo(); + QCOMPARE(m_model->nodeIds().size(), 4); + QCOMPARE(m_model->edgeIds().size(), 2); + } + + void crossingEdges_getArcWhenEnabled() + { + // Build two crossing water lines: (0,0)->(400,0) area and a vertical + // crossing through them. + const QUuid s1 = m_scene->dropNode(QStringLiteral("source"), {0, 100}); + const QUuid c1 = m_scene->dropNode(QStringLiteral("consumer"), {400, 100}); + const QUuid j1 = m_scene->dropNode(QStringLiteral("junction"), {200, 0}); + const QUuid j2 = m_scene->dropNode(QStringLiteral("junction"), {200, 240}); + m_model->addEdge(s1, "out", c1, "in"); + const QUuid crossing = m_model->addEdge(j1, "s", j2, "n"); + + EdgeItem* item = m_scene->edgeItem(crossing); + const double plainLen = Router::polylineLength(item->polyline()); + // With arcs on (default), the painted path detours around the first edge. + RouteConfig cfg = m_scene->routeConfig(); + QVERIFY(cfg.arcOnIntersection); + const auto hops = Router::crossings(item->polyline(), + m_scene->edgeItem(m_model->edgeIds().first())->polyline()); + QCOMPARE(hops.size(), 1); + + cfg.arcOnIntersection = false; + m_scene->setRouteConfig(cfg); // still routes, no arcs — no crash, same polyline + QCOMPARE(Router::polylineLength(m_scene->edgeItem(crossing)->polyline()), plainLen); + } + + void gridView_dropMimeCreatesNode() + { + GridView view(m_scene); + view.resize(600, 400); + view.show(); + + QMimeData mime; + mime.setData(QLatin1String(GridView::kNodeTypeMime), QByteArrayLiteral("pump")); + QVERIFY(view.handleNodeTypeDrop(&mime, QPoint(300, 200))); + QCOMPARE(m_model->nodeIds().size(), 1); + + QMimeData other; + other.setData(QStringLiteral("text/plain"), QByteArrayLiteral("x")); + QVERIFY(!view.handleNodeTypeDrop(&other, QPoint(300, 200))); + QCOMPARE(m_model->nodeIds().size(), 1); + } + +private: + TypeRegistry m_reg; + NetworkModel* m_model = nullptr; + QUndoStack* m_undo = nullptr; + DiagramScene* m_scene = nullptr; +}; + +QTEST_MAIN(TestScene) +#include "tst_scene.moc" diff --git a/tests/tst_undo.cpp b/tests/tst_undo.cpp new file mode 100644 index 0000000..f129135 --- /dev/null +++ b/tests/tst_undo.cpp @@ -0,0 +1,137 @@ +#include "TestFixtures.h" +#include "core/NetworkModel.h" +#include "scene/Commands.h" + +#include +#include + +using namespace diag; + +class TestUndo : public QObject { + Q_OBJECT +private slots: + void init() + { + fixtures::fillRegistry(m_reg); + delete m_model; + m_model = new NetworkModel(&m_reg, this); + m_undo.clear(); + } + + void addNode_undoRedo() + { + auto* cmd = new AddNodeCommand(m_model, QStringLiteral("pump"), {100, 100}); + m_undo.push(cmd); + const QUuid id = cmd->nodeId(); + QVERIFY(m_model->node(id)); + m_undo.undo(); + QVERIFY(!m_model->node(id)); + m_undo.redo(); + QVERIFY(m_model->node(id)); // same id restored + } + + void addEdge_undoRedo() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0}); + auto* cmd = new AddEdgeCommand(m_model, src, QStringLiteral("out"), pump, + QStringLiteral("in")); + m_undo.push(cmd); + QVERIFY(m_model->edge(cmd->edgeId())); + m_undo.undo(); + QCOMPARE(m_model->edgeIds().size(), 0); + m_undo.redo(); + QCOMPARE(m_model->edgeIds().size(), 1); + } + + void moveNodes_undoRestoresPositions() + { + const QUuid a = m_model->addNode(QStringLiteral("pump"), {0, 0}); + const QUuid b = m_model->addNode(QStringLiteral("pump"), {100, 0}); + m_undo.push(new MoveNodesCommand( + m_model, {{a, {0, 0}, {40, 40}}, {b, {100, 0}, {140, 40}}})); + QCOMPARE(m_model->node(a)->pos, QPointF(40, 40)); + m_undo.undo(); + QCOMPARE(m_model->node(a)->pos, QPointF(0, 0)); + QCOMPARE(m_model->node(b)->pos, QPointF(100, 0)); + } + + void removeItems_undoRestoresEdgesAndProps() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0}); + const QUuid c = m_model->addNode(QStringLiteral("consumer"), {400, 0}); + const QUuid e1 = m_model->addEdge(src, "out", pump, "in"); + const QUuid e2 = m_model->addEdge(pump, "out", c, "in"); + m_model->setEdgeProperty(e1, "diameter", 150.0); + m_model->setNodeProperty(pump, "title", QStringLiteral("P-1")); + + // Deleting the pump takes both attached edges with it. + m_undo.push(new RemoveItemsCommand(m_model, {pump}, {})); + QCOMPARE(m_model->nodeIds().size(), 2); + QCOMPARE(m_model->edgeIds().size(), 0); + + m_undo.undo(); + QCOMPARE(m_model->nodeIds().size(), 3); + QCOMPARE(m_model->edgeIds().size(), 2); + QVERIFY(m_model->edge(e2)); + QCOMPARE(m_model->edgeProperty(e1, "diameter").toDouble(), 150.0); + QCOMPARE(m_model->nodeProperty(pump, "title").toString(), QStringLiteral("P-1")); + } + + void setProperty_undoRedo() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + m_undo.push(new SetPropertyCommand(m_model, SetPropertyCommand::NodeTarget, src, + QStringLiteral("supply"), 25.0)); + QCOMPARE(m_model->nodeProperty(src, "supply").toDouble(), 25.0); + m_undo.undo(); + QCOMPARE(m_model->nodeProperty(src, "supply").toDouble(), 10.0); // spec default + } + + void paste_remapsIdsAndOffsets() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0}); + m_model->addEdge(src, "out", pump, "in"); + + const QJsonObject frag = fragmentFromSelection(*m_model, {src, pump}, {}); + auto* cmd = new PasteCommand(m_model, frag, {20, 20}); + m_undo.push(cmd); + + QCOMPARE(m_model->nodeIds().size(), 4); + QCOMPARE(m_model->edgeIds().size(), 2); // inner edge duplicated + const QList pasted = cmd->pastedNodes(); + QCOMPARE(pasted.size(), 2); + QVERIFY(!pasted.contains(src)); + QCOMPARE(m_model->node(pasted.first())->pos, + m_model->node(src)->pos + QPointF(20, 20)); + + m_undo.undo(); + QCOMPARE(m_model->nodeIds().size(), 2); + QCOMPARE(m_model->edgeIds().size(), 1); + m_undo.redo(); + QCOMPARE(m_model->nodeIds().size(), 4); + } + + void copyFragment_skipsDanglingEdges() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0}); + m_model->addEdge(src, "out", pump, "in"); + // Only the pump is copied: the edge to the source must not paste. + const QJsonObject frag = fragmentFromSelection(*m_model, {pump}, {}); + auto* cmd = new PasteCommand(m_model, frag, {0, 40}); + m_undo.push(cmd); + QCOMPARE(m_model->nodeIds().size(), 3); + QCOMPARE(m_model->edgeIds().size(), 1); + } + +private: + TypeRegistry m_reg; + NetworkModel* m_model = nullptr; + QUndoStack m_undo; +}; + +QTEST_GUILESS_MAIN(TestUndo) +#include "tst_undo.moc"