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:
parent
81f2a9ff8b
commit
b257c1a7cf
@ -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
276
src/scene/Commands.cpp
Normal 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
115
src/scene/Commands.h
Normal 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
448
src/scene/DiagramScene.cpp
Normal 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
108
src/scene/DiagramScene.h
Normal 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
167
src/scene/EdgeItem.cpp
Normal 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
44
src/scene/EdgeItem.h
Normal 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
164
src/scene/GridView.cpp
Normal 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
53
src/scene/GridView.h
Normal 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
183
src/scene/NodeItem.cpp
Normal 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
53
src/scene/NodeItem.h
Normal 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
126
src/scene/PortItem.cpp
Normal 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
46
src/scene/PortItem.h
Normal 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
70
src/scene/Theme.cpp
Normal 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
31
src/scene/Theme.h
Normal 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
|
||||
@ -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
215
tests/tst_scene.cpp
Normal 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
137
tests/tst_undo.cpp
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user