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 <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:25:06 +02:00
parent 81f2a9ff8b
commit b257c1a7cf
18 changed files with 2257 additions and 0 deletions

View File

@ -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)

276
src/scene/Commands.cpp Normal file
View File

@ -0,0 +1,276 @@
#include "Commands.h"
#include "core/NetworkModel.h"
#include <QJsonArray>
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<QUuid>& nodes,
const QList<QUuid>& 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<QUuid> 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<Move>& 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<QUuid>& nodes,
const QList<QUuid>& 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<QUuid> 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<QString, QString> 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

115
src/scene/Commands.h Normal file
View File

@ -0,0 +1,115 @@
#pragma once
#include "core/Types.h"
#include <QJsonArray>
#include <QJsonObject>
#include <QUndoCommand>
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<Move>& moves,
QUndoCommand* parent = nullptr);
void redo() override;
void undo() override;
private:
NetworkModel* m_model;
QList<Move> 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<QUuid>& nodes, const QList<QUuid>& edges,
QUndoCommand* parent = nullptr);
void redo() override;
void undo() override;
private:
NetworkModel* m_model;
QList<QUuid> 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<QUuid> pastedNodes() const { return m_nodeIds; }
private:
NetworkModel* m_model;
QJsonObject m_fragment; // ids already remapped to fresh ones
QList<QUuid> m_nodeIds;
QList<QUuid> m_edgeIds;
};
// Serialize a node/edge subset for clipboard or removal restore.
QJsonObject fragmentFromSelection(const NetworkModel& model, const QList<QUuid>& nodes,
const QList<QUuid>& edges);
} // namespace diag

448
src/scene/DiagramScene.cpp Normal file
View File

@ -0,0 +1,448 @@
#include "DiagramScene.h"
#include "Commands.h"
#include "EdgeItem.h"
#include "NodeItem.h"
#include <QClipboard>
#include <QGraphicsPathItem>
#include <QGraphicsView>
#include <QLineF>
#include <QGraphicsSceneMouseEvent>
#include <QGuiApplication>
#include <QJsonDocument>
#include <QKeyEvent>
#include <QMimeData>
#include <QSvgRenderer>
#include <QTimer>
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<QUuid> order = m_model->edgeIds();
QList<QList<QPointF>> 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<QPointF> 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<PortItem*>(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<QUuid> nodes;
QList<QUuid> edges;
for (QGraphicsItem* item : selectedItems()) {
if (auto* n = dynamic_cast<NodeItem*>(item))
nodes.append(n->nodeId());
else if (auto* e = dynamic_cast<EdgeItem*>(item))
edges.append(e->edgeId());
}
if (nodes.isEmpty() && edges.isEmpty())
return;
m_undo->push(new RemoveItemsCommand(m_model, nodes, edges));
}
void DiagramScene::copySelection()
{
QList<QUuid> nodes;
QList<QUuid> edges;
for (QGraphicsItem* item : selectedItems()) {
if (auto* n = dynamic_cast<NodeItem*>(item))
nodes.append(n->nodeId());
else if (auto* e = dynamic_cast<EdgeItem*>(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<NodeItem*>(item))
m_moveOrigin.insert(n->nodeId(), n->pos());
}
void DiagramScene::commitMove()
{
QList<MoveNodesCommand::Move> 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

108
src/scene/DiagramScene.h Normal file
View File

@ -0,0 +1,108 @@
#pragma once
#include "PortItem.h"
#include "Theme.h"
#include "core/NetworkModel.h"
#include "core/Router.h"
#include <QGraphicsScene>
#include <QHash>
#include <QUndoStack>
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<QUuid, NodeItem*> m_nodeItems;
QHash<QUuid, EdgeItem*> m_edgeItems;
QHash<QString, QSvgRenderer*> m_renderers;
PortItem* m_connectSource = nullptr;
QGraphicsPathItem* m_connectPreview = nullptr;
QHash<QUuid, QPointF> m_moveOrigin;
QTimer* m_animTimer;
bool m_animating = false;
double m_animPhase = 0.0;
double m_maxAbsFlow = 0.0;
};
} // namespace diag

167
src/scene/EdgeItem.cpp Normal file
View File

@ -0,0 +1,167 @@
#include "EdgeItem.h"
#include "DiagramScene.h"
#include "core/NetworkModel.h"
#include "core/Router.h"
#include <QPainter>
#include <QPainterPathStroker>
#include <QtMath>
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<QPointF>& poly, const QList<QPointF>& hops)
{
prepareGeometryChange();
m_poly = poly;
m_hops = hops;
const RouteConfig& cfg = m_scene->routeConfig();
m_path = Router::toPath(poly, cfg.arcOnIntersection ? hops : QList<QPointF>{},
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

44
src/scene/EdgeItem.h Normal file
View File

@ -0,0 +1,44 @@
#pragma once
#include "core/Types.h"
#include <QGraphicsObject>
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<QPointF>& polyline() const { return m_poly; }
void setRoute(const QList<QPointF>& poly, const QList<QPointF>& 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<QPointF> m_poly;
QList<QPointF> m_hops;
QPainterPath m_path;
};
} // namespace diag

164
src/scene/GridView.cpp Normal file
View File

@ -0,0 +1,164 @@
#include "GridView.h"
#include "DiagramScene.h"
#include <QMimeData>
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
#include <QWheelEvent>
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<QLineF> minor;
QList<QLineF> 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

53
src/scene/GridView.h Normal file
View File

@ -0,0 +1,53 @@
#pragma once
#include <QGraphicsView>
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

183
src/scene/NodeItem.cpp Normal file
View File

@ -0,0 +1,183 @@
#include "NodeItem.h"
#include "DiagramScene.h"
#include "PortItem.h"
#include "core/NetworkModel.h"
#include "core/Router.h"
#include <QPainter>
#include <QSvgRenderer>
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

53
src/scene/NodeItem.h Normal file
View File

@ -0,0 +1,53 @@
#pragma once
#include "core/Types.h"
#include <QGraphicsItem>
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<PortItem*> 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<PortItem*> m_ports;
bool m_syncing = false;
bool m_hovered = false;
};
} // namespace diag

126
src/scene/PortItem.cpp Normal file
View File

@ -0,0 +1,126 @@
#include "PortItem.h"
#include "DiagramScene.h"
#include "NodeItem.h"
#include <QCursor>
#include <QGraphicsSceneMouseEvent>
#include <QPainter>
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<DiagramScene*>(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

46
src/scene/PortItem.h Normal file
View File

@ -0,0 +1,46 @@
#pragma once
#include "core/Types.h"
#include <QGraphicsItem>
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

70
src/scene/Theme.cpp Normal file
View File

@ -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

31
src/scene/Theme.h Normal file
View File

@ -0,0 +1,31 @@
#pragma once
#include "core/Types.h"
#include <QColor>
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

View File

@ -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)

215
tests/tst_scene.cpp Normal file
View File

@ -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 <QMimeData>
#include <QSignalSpy>
#include <QTest>
#include <QUndoStack>
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"

137
tests/tst_undo.cpp Normal file
View File

@ -0,0 +1,137 @@
#include "TestFixtures.h"
#include "core/NetworkModel.h"
#include "scene/Commands.h"
#include <QTest>
#include <QUndoStack>
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<QUuid> 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"