modified: CMakeLists.txt

modified:   src/MainWindow.cpp
	modified:   src/MainWindow.h
	modified:   src/items/ArrowItem.cpp
	modified:   src/items/ArrowItem.h
	modified:   src/items/BlockItem.cpp
	modified:   src/items/BlockItem.h
	modified:   src/items/DiagramScene.cpp
	modified:   src/items/DiagramScene.h
	new file:   src/items/HeaderFooterItem.cpp
	new file:   src/items/HeaderFooterItem.h
	modified:   src/items/JunctionItem.cpp
	modified:   src/items/JunctionItem.h
	modified:   src/main.cpp
This commit is contained in:
Gregory Bednov 2026-02-25 18:25:51 +03:00
commit f6f0598ff2
14 changed files with 2543 additions and 96 deletions

View file

@ -10,16 +10,32 @@
#include <QCursor>
#include <QInputDialog>
#include <QLineEdit>
#include <QDebug>
#include <QObject>
#include "DiagramScene.h"
#include <algorithm>
#include <QSet>
#include <queue>
#include <vector>
#include <limits>
#include <utility>
static bool almostEqual(qreal a, qreal b, qreal eps = 1e-6);
static bool almostEqual(const QPointF& a, const QPointF& b, qreal eps = 1e-6);
static QPointF scenePortPos(const ArrowItem::Endpoint& ep);
static QRectF labelDragHitRect(const QGraphicsSimpleTextItem* item) {
if (!item || !item->isVisible()) return QRectF();
// Generous hit area makes label dragging much less fiddly.
return item->sceneBoundingRect().adjusted(-16, -10, 16, 10);
}
QColor ArrowItem::s_lineColor = QColor(10, 10, 10);
QColor ArrowItem::s_textColor = QColor(10, 10, 10);
ArrowItem::ArrowItem(QGraphicsItem* parent)
: QGraphicsPathItem(parent)
{
QPen pen(QColor(10,10,10));
QPen pen(s_lineColor);
pen.setWidthF(1.4);
pen.setCapStyle(Qt::RoundCap);
pen.setJoinStyle(Qt::RoundJoin);
@ -28,15 +44,21 @@ ArrowItem::ArrowItem(QGraphicsItem* parent)
setAcceptHoverEvents(true);
setAcceptedMouseButtons(Qt::LeftButton);
setFlag(QGraphicsItem::ItemIsSelectable, true);
m_labelItem = new QGraphicsSimpleTextItem(this);
m_labelItem->setBrush(QColor(10, 10, 10));
m_labelItem->setBrush(s_textColor);
m_labelItem->setZValue(2);
m_labelItem->setFlag(QGraphicsItem::ItemIgnoresTransformations, true);
m_labelItem->setVisible(false);
m_labelOffset = QPointF(0, 0);
}
void ArrowItem::setVisualTheme(const QColor& lineColor, const QColor& textColor) {
s_lineColor = lineColor;
s_textColor = textColor;
}
ArrowItem::~ArrowItem() {
if (m_from.block) m_from.block->removeArrow(this);
if (m_to.block) m_to.block->removeArrow(this);
@ -49,7 +71,7 @@ void ArrowItem::setLabel(const QString& text) {
m_label = text;
if (m_labelItem) {
m_labelItem->setText(m_label);
m_labelItem->setVisible(!m_label.isEmpty());
m_labelItem->setVisible(!m_label.isEmpty() && !m_labelHidden);
}
updateLabelItem(computePolyline());
}
@ -66,6 +88,65 @@ void ArrowItem::setLabelOffset(const QPointF& off) {
updateLabelItem(m_lastPolyline.isEmpty() ? computePolyline() : m_lastPolyline);
}
void ArrowItem::setLabelHidden(bool hidden) {
m_labelHidden = hidden;
updateLabelItem(m_lastPolyline.isEmpty() ? computePolyline() : m_lastPolyline);
}
void ArrowItem::setLabelInherited(bool inherited) {
m_labelInherited = inherited;
}
void ArrowItem::setLabelSource(ArrowItem* src) {
m_labelSource = src;
}
ArrowItem* ArrowItem::labelSourceRoot() const {
const ArrowItem* cur = this;
QSet<const ArrowItem*> visited;
while (cur) {
if (visited.contains(cur)) break;
visited.insert(cur);
if (!cur->m_labelSource) return const_cast<ArrowItem*>(cur);
cur = cur->m_labelSource;
}
return const_cast<ArrowItem*>(cur);
}
void ArrowItem::setInterfaceStub(const Endpoint& edge, const QString& label) {
m_isInterface = true;
m_interfaceStubOnly = true;
m_labelLocked = true;
m_labelHidden = false;
m_interfaceEdge = edge;
setLabel(label);
const QPointF posScene = scenePortPos(edge);
Endpoint stub = edge;
stub.scenePos = posScene;
stub.block = nullptr;
stub.junction = nullptr;
stub.localPos.reset();
setFrom(stub);
setTo(stub);
finalize();
}
void ArrowItem::resetInterfaceStub() {
if (!m_isInterface || !m_interfaceEdge) return;
setInterfaceStub(*m_interfaceEdge, m_label);
}
void ArrowItem::setInterfaceIsStub(bool stub) {
m_interfaceStubOnly = stub;
updatePath();
}
void ArrowItem::setLabelLocked(bool locked) {
m_labelLocked = locked;
}
bool ArrowItem::adjustEndpoint(Endpoint& ep, const QPointF& delta) {
if (ep.block) {
auto seg = ep.block->portSegment(ep.port);
@ -84,7 +165,11 @@ bool ArrowItem::adjustEndpoint(Endpoint& ep, const QPointF& delta) {
if (ep.scenePos) {
QPointF p = *ep.scenePos + delta;
if (scene()) {
const QRectF r = scene()->sceneRect();
QRectF r = scene()->sceneRect();
if (auto* ds = qobject_cast<DiagramScene*>(scene())) {
const QRectF cr = ds->contentRect();
if (!cr.isNull()) r = cr;
}
// snap to nearest edge it was originally on
const qreal tol = 4.0;
if (std::abs(ep.scenePos->x() - r.left()) < tol) {
@ -165,11 +250,11 @@ static QPointF normalizedOr(const QPointF& v, const QPointF& fallback) {
return v / len;
}
static bool almostEqual(qreal a, qreal b, qreal eps = 1e-6) {
static bool almostEqual(qreal a, qreal b, qreal eps) {
return std::abs(a - b) < eps;
}
static bool almostEqual(const QPointF& a, const QPointF& b, qreal eps = 1e-6) {
static bool almostEqual(const QPointF& a, const QPointF& b, qreal eps) {
return almostEqual(a.x(), b.x(), eps) && almostEqual(a.y(), b.y(), eps);
}
@ -423,6 +508,11 @@ QVector<QPointF> ArrowItem::simplifyPolyline(const QVector<QPointF>& ptsIn) {
}
QVector<QPointF> ArrowItem::computePolyline() const {
if (m_isInterface && m_interfaceStubOnly && m_interfaceEdge) {
const QPointF p = scenePortPos(*m_interfaceEdge);
return {p, p};
}
const qreal gap = 24.0;
QPointF a = scenePortPos(m_from);
QPointF b = m_hasTempEnd ? m_tempEndScene : scenePortPos(m_to);
@ -532,7 +622,19 @@ std::optional<QPointF> ArrowItem::hitTest(const QPointF& scenePos, qreal radius)
void ArrowItem::updateLabelItem(const QVector<QPointF>& pts) {
if (!m_labelItem) return;
if (m_label.isEmpty() || pts.size() < 2) {
const bool stubOnly = m_isInterface && m_interfaceStubOnly && m_interfaceEdge;
if (stubOnly) {
if (m_label.isEmpty() || m_labelHidden) {
m_labelItem->setVisible(false);
return;
}
const QPointF p = scenePortPos(*m_interfaceEdge);
const QRectF br = m_labelItem->boundingRect();
m_labelItem->setPos(p + m_labelOffset + QPointF(10, -br.height() * 0.5));
m_labelItem->setVisible(true);
return;
}
if (m_labelHidden || m_label.isEmpty() || pts.size() < 2) {
m_labelItem->setVisible(false);
return;
}
@ -571,21 +673,37 @@ void ArrowItem::updateLabelItem(const QVector<QPointF>& pts) {
// Обновляем маршрут ортогонально, добавляя/схлопывая сегменты при необходимости.
void ArrowItem::updatePath() {
QPen themedPen = pen();
themedPen.setColor(s_lineColor);
setPen(themedPen);
if (m_labelItem) {
m_labelItem->setBrush(s_textColor);
}
const QRectF oldSceneRect = mapRectToScene(boundingRect());
QVector<QPointF> pts = computePolyline();
m_lastPolyline = pts;
QPainterPath p;
if (!pts.isEmpty()) {
const bool stubOnly = m_isInterface && m_interfaceStubOnly;
if (!pts.isEmpty() && !stubOnly) {
p.moveTo(pts.first());
for (int i = 1; i < pts.size(); ++i) {
p.lineTo(pts[i]);
}
}
// interface stub circle at edge
if (m_isInterface && m_interfaceEdge && m_interfaceStubOnly) {
const QPointF stubPos = scenePortPos(*m_interfaceEdge);
const qreal r = 6.0;
p.addEllipse(stubPos, r, r);
}
// наконечник стрелки
if (pts.size() >= 2) {
// Draw arrow head only if the path truly ends (not branching through a junction).
if (!stubOnly && pts.size() >= 2 && !m_to.junction) {
const QPointF tip = pts.last();
const QPointF from = pts[pts.size() - 2];
drawArrowHead(p, tip, from);
@ -605,10 +723,19 @@ void ArrowItem::updatePath() {
}
void ArrowItem::hoverMoveEvent(QGraphicsSceneHoverEvent* e) {
// Keep label geometry in sync before hit-testing drag area.
updateLabelItem(m_lastPolyline.isEmpty() ? computePolyline() : m_lastPolyline);
const qreal tol = 6.0;
const QVector<QPointF> pts = computePolyline();
QPointF pos = e->scenePos();
if (!m_labelLocked && m_labelItem && labelDragHitRect(m_labelItem).contains(pos)) {
setCursor(Qt::SizeAllCursor);
QGraphicsPathItem::hoverMoveEvent(e);
return;
}
Qt::CursorShape cursor = Qt::ArrowCursor;
for (int i = 0; i + 1 < pts.size(); ++i) {
const QPointF a = pts[i];
@ -629,8 +756,10 @@ void ArrowItem::mousePressEvent(QGraphicsSceneMouseEvent* e) {
return;
}
if (m_labelItem && m_labelItem->isVisible() &&
m_labelItem->sceneBoundingRect().adjusted(-6, -4, 6, 4).contains(e->scenePos())) {
// Keep label geometry in sync before hit-testing drag area.
updateLabelItem(m_lastPolyline.isEmpty() ? computePolyline() : m_lastPolyline);
if (!m_labelLocked && m_labelItem && labelDragHitRect(m_labelItem).contains(e->scenePos())) {
m_labelDragging = true;
m_labelDragLastScene = e->scenePos();
setCursor(Qt::SizeAllCursor);
@ -642,15 +771,16 @@ void ArrowItem::mousePressEvent(QGraphicsSceneMouseEvent* e) {
const QVector<QPointF> pts = computePolyline();
const QPointF pos = e->scenePos();
// Проверим, не жмем ли по началу/концу для сдвига точки соединения.
// Shift+drag endpoint: slide connection along the same side.
const bool shift = e->modifiers().testFlag(Qt::ShiftModifier);
if (!pts.isEmpty()) {
if (QLineF(pos, pts.first()).length() <= tol) {
if (shift && QLineF(pos, pts.first()).length() <= tol) {
m_dragPart = DragPart::FromEnd;
m_lastDragScenePos = pos;
e->accept();
return;
}
if (QLineF(pos, pts.last()).length() <= tol) {
if (shift && QLineF(pos, pts.last()).length() <= tol) {
m_dragPart = DragPart::ToEnd;
m_lastDragScenePos = pos;
e->accept();
@ -738,6 +868,8 @@ void ArrowItem::mouseMoveEvent(QGraphicsSceneMouseEvent* e) {
case DragPart::BottomHorizontal:
m_bottomOffset += delta.y();
break;
case DragPart::FromEnd:
case DragPart::ToEnd:
case DragPart::None:
break;
}
@ -778,12 +910,18 @@ void ArrowItem::mouseReleaseEvent(QGraphicsSceneMouseEvent* e) {
}
void ArrowItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent* e) {
if (e->button() == Qt::LeftButton) {
if (e->button() == Qt::LeftButton && !m_labelLocked) {
bool ok = false;
const QString text = QInputDialog::getText(nullptr, "Arrow label", "Label:", QLineEdit::Normal, m_label, &ok);
const QString text = QInputDialog::getText(nullptr, QObject::tr("Arrow label"), QObject::tr("Label:"), QLineEdit::Normal, m_label, &ok);
if (ok) {
setLabel(text);
setLabelHidden(false);
setLabelInherited(false);
setLabelSource(nullptr);
if (text.isEmpty()) m_labelOffset = QPointF();
if (auto* ds = qobject_cast<DiagramScene*>(scene())) {
ds->propagateLabelFrom(this);
}
}
e->accept();
return;

View file

@ -1,8 +1,10 @@
#pragma once
#include <QGraphicsPathItem>
#include <QPointer>
#include <QSet>
#include <optional>
#include <QString>
#include <QColor>
#include "BlockItem.h"
class JunctionItem;
@ -36,10 +38,26 @@ public:
QPointF labelOffset() const { return m_labelOffset; }
void setOffsets(qreal bend, qreal top, qreal bottom);
void setLabelOffset(const QPointF& off);
void setLabelHidden(bool hidden);
bool isLabelHidden() const { return m_labelHidden; }
void setLabelInherited(bool inherited);
bool isLabelInherited() const { return m_labelInherited; }
void setLabelSource(ArrowItem* src);
ArrowItem* labelSource() const { return m_labelSource; }
ArrowItem* labelSourceRoot() const;
qreal bendOffset() const { return m_bendOffset; }
qreal topOffset() const { return m_topOffset; }
qreal bottomOffset() const { return m_bottomOffset; }
int type() const override { return Type; }
bool isInterface() const { return m_isInterface; }
bool isInterfaceStub() const { return m_isInterface && m_interfaceStubOnly; }
bool isLabelLocked() const { return m_labelLocked; }
std::optional<Endpoint> interfaceEdge() const { return m_interfaceEdge; }
void setInterfaceStub(const Endpoint& edge, const QString& label);
void setInterfaceIsStub(bool stub);
void resetInterfaceStub();
void setLabelLocked(bool locked);
static void setVisualTheme(const QColor& lineColor, const QColor& textColor);
void updatePath();
std::optional<QPointF> hitTest(const QPointF& scenePos, qreal radius) const;
@ -56,10 +74,19 @@ private:
qreal m_bottomOffset = 0.0;
QString m_label;
QGraphicsSimpleTextItem* m_labelItem = nullptr;
bool m_labelHidden = false;
bool m_labelInherited = false;
QPointF m_labelOffset; // in scene-space relative to anchor point
bool m_labelDragging = false;
QPointF m_labelDragLastScene;
QVector<QPointF> m_lastPolyline;
bool m_isInterface = false;
bool m_interfaceStubOnly = false;
bool m_labelLocked = false;
std::optional<Endpoint> m_interfaceEdge;
ArrowItem* m_labelSource = nullptr;
static QColor s_lineColor;
static QColor s_textColor;
DragPart m_dragPart = DragPart::None;
QPointF m_lastDragScenePos;

View file

@ -1,12 +1,30 @@
#include "BlockItem.h"
#include "ArrowItem.h"
#include "items/DiagramScene.h"
#include <algorithm>
#include <limits>
#include <cmath>
#include <QPainter>
#include <QGraphicsSceneMouseEvent>
#include <QInputDialog>
#include <QDialog>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QLineEdit>
#include <QCheckBox>
#include <QVBoxLayout>
#include <QLocale>
#include "DiagramScene.h"
QString BlockItem::s_currencySymbol = QStringLiteral("$");
QString BlockItem::s_currencyPlacement = QStringLiteral("1?");
QColor BlockItem::s_foregroundColor = QColor(20, 20, 20);
QColor BlockItem::s_backgroundColor = QColor(255, 255, 255);
QColor BlockItem::s_borderColor = QColor(30, 30, 30);
QColor BlockItem::s_fontColor = QColor(20, 20, 20);
QColor BlockItem::s_selectedBackgroundColor = QColor(240, 240, 255);
BlockItem::BlockItem(QString title, QGraphicsItem* parent, int id)
: QGraphicsObject(parent),
@ -27,19 +45,44 @@ void BlockItem::paint(QPainter* p, const QStyleOptionGraphicsItem*, QWidget*) {
// фон
p->setPen(Qt::NoPen);
p->setBrush(isSelected() ? QColor(240,240,255) : QColor(250,250,250));
p->setBrush(isSelected() ? s_selectedBackgroundColor : s_backgroundColor);
p->drawRoundedRect(m_rect, 6, 6);
// рамка
QPen pen(QColor(30,30,30));
QPen pen(s_borderColor);
pen.setWidthF(1.5);
p->setPen(pen);
p->setBrush(Qt::NoBrush);
p->drawRoundedRect(m_rect, 6, 6);
// dog-ear indicator when no decomposition
if (!m_hasDecomposition) {
const qreal ear = 12.0;
QPainterPath fold;
fold.moveTo(m_rect.topLeft());
fold.lineTo(m_rect.topLeft() + QPointF(ear, 0));
fold.lineTo(m_rect.topLeft() + QPointF(0, ear));
fold.closeSubpath();
p->setBrush(s_backgroundColor.darker(108));
p->drawPath(fold);
p->setPen(QPen(s_borderColor.lighter(130), 1.0));
p->drawLine(m_rect.topLeft() + QPointF(ear, 0), m_rect.topLeft() + QPointF(0, ear));
}
// заголовок
p->setPen(QColor(20,20,20));
p->setPen(s_fontColor);
p->drawText(m_rect.adjusted(10, 8, -10, -8), Qt::AlignLeft | Qt::AlignTop, m_title);
if (!m_number.isEmpty()) {
QFont f = p->font();
f.setBold(true);
p->setFont(f);
p->setPen(s_foregroundColor);
p->drawText(m_rect.adjusted(8, 4, -8, -8), Qt::AlignRight | Qt::AlignBottom, m_number);
}
if (m_price.has_value()) {
p->setPen(s_foregroundColor);
p->drawText(m_rect.adjusted(8, 4, -8, -8), Qt::AlignLeft | Qt::AlignBottom, formattedPrice());
}
}
void BlockItem::setTitle(const QString& t) {
@ -48,6 +91,48 @@ void BlockItem::setTitle(const QString& t) {
update();
}
void BlockItem::setNumber(const QString& n) {
if (n == m_number) return;
m_number = n;
update();
}
void BlockItem::setPrice(std::optional<qreal> price) {
if (m_price == price) return;
m_price = price;
update();
}
void BlockItem::setCurrencyFormat(const QString& symbol, const QString& placement) {
QString effectiveSymbol = symbol;
if (effectiveSymbol.isEmpty()) {
const QLocale loc;
effectiveSymbol = loc.currencySymbol(QLocale::CurrencySymbol);
if (effectiveSymbol.isEmpty()) {
const auto terr = loc.territory();
if (terr == QLocale::Russia || terr == QLocale::RussianFederation) {
effectiveSymbol = QString::fromUtf8("\u20BD");
} else {
effectiveSymbol = QStringLiteral("$");
}
}
}
s_currencySymbol = effectiveSymbol;
s_currencyPlacement = placement.isEmpty() ? QStringLiteral("1?") : placement;
}
void BlockItem::setVisualTheme(const QColor& foreground,
const QColor& background,
const QColor& border,
const QColor& font,
const QColor& selectedBackground) {
s_foregroundColor = foreground;
s_backgroundColor = background;
s_borderColor = border;
s_fontColor = font;
s_selectedBackgroundColor = selectedBackground;
}
QPointF BlockItem::portLocalPos(Port p) const {
const qreal x0 = m_rect.left();
const qreal x1 = m_rect.right();
@ -133,6 +218,20 @@ void BlockItem::removeArrow(ArrowItem* a) {
}
QVariant BlockItem::itemChange(GraphicsItemChange change, const QVariant& value) {
if (change == ItemPositionChange) {
const QPointF desired = value.toPointF();
if (auto* ds = qobject_cast<DiagramScene*>(scene())) {
QRectF bounds = ds->contentRect();
if (bounds.isNull()) bounds = ds->sceneRect();
QRectF rect = boundingRect().translated(desired);
QPointF clamped = desired;
if (rect.left() < bounds.left()) clamped.setX(desired.x() + (bounds.left() - rect.left()));
if (rect.right() > bounds.right()) clamped.setX(clamped.x() - (rect.right() - bounds.right()));
if (rect.top() < bounds.top()) clamped.setY(desired.y() + (bounds.top() - rect.top()));
if (rect.bottom() > bounds.bottom()) clamped.setY(clamped.y() - (rect.bottom() - bounds.bottom()));
return clamped;
}
}
if (change == ItemPositionHasChanged || change == ItemTransformHasChanged) {
// уведомим стрелки, что нужно пересчитать геометрию
for (auto& a : m_arrows) {
@ -143,9 +242,71 @@ QVariant BlockItem::itemChange(GraphicsItemChange change, const QVariant& value)
return QGraphicsObject::itemChange(change, value);
}
QPair<QString, QString> BlockItem::currencyAffixes() {
const bool prefix = s_currencyPlacement.startsWith("?");
const bool spaced = s_currencyPlacement.contains(' ');
if (prefix) {
return {s_currencySymbol + (spaced ? " " : ""), QString()};
}
return {QString(), (spaced ? " " : "") + s_currencySymbol};
}
QString BlockItem::formattedPrice() const {
if (!m_price.has_value()) return QString();
const auto affixes = currencyAffixes();
const QString value = QLocale().toString(*m_price, 'f', 2);
return affixes.first + value + affixes.second;
}
void BlockItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent* e) {
bool ok = false;
const QString t = QInputDialog::getText(nullptr, "Rename function", "Title:", QLineEdit::Normal, m_title, &ok);
if (ok) setTitle(t);
auto* dlg = new QDialog();
dlg->setWindowTitle(tr("Edit block"));
auto* layout = new QVBoxLayout(dlg);
auto* form = new QFormLayout();
auto* titleEdit = new QLineEdit(m_title, dlg);
form->addRow(tr("Title:"), titleEdit);
auto affixes = currencyAffixes();
auto* priceSpin = new QDoubleSpinBox(dlg);
priceSpin->setRange(0.0, 1e9);
priceSpin->setDecimals(2);
priceSpin->setPrefix(affixes.first);
priceSpin->setSuffix(affixes.second);
priceSpin->setValue(m_price.value_or(0.0));
auto* priceToggle = new QCheckBox(tr("Set price"), dlg);
priceToggle->setChecked(m_price.has_value());
priceSpin->setEnabled(m_price.has_value());
connect(priceToggle, &QCheckBox::toggled, priceSpin, &QWidget::setEnabled);
form->addRow(tr("Price:"), priceSpin);
form->addRow(QString(), priceToggle);
layout->addLayout(form);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dlg);
connect(buttons, &QDialogButtonBox::accepted, dlg, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, dlg, &QDialog::reject);
layout->addWidget(buttons);
bool changed = false;
if (dlg->exec() == QDialog::Accepted) {
if (m_title != titleEdit->text()) {
setTitle(titleEdit->text());
changed = true;
}
std::optional<qreal> newPrice;
if (priceToggle->isChecked()) newPrice = priceSpin->value();
if (m_price != newPrice) {
setPrice(newPrice);
changed = true;
}
if (changed) {
if (auto* ds = qobject_cast<DiagramScene*>(scene())) {
ds->requestSnapshot();
}
}
}
dlg->deleteLater();
QGraphicsObject::mouseDoubleClickEvent(e);
}

View file

@ -1,6 +1,8 @@
#pragma once
#include <QGraphicsObject>
#include <QSet>
#include <optional>
#include <QColor>
class ArrowItem;
@ -18,6 +20,10 @@ public:
QString title() const { return m_title; }
void setTitle(const QString& t);
QString number() const { return m_number; }
void setNumber(const QString& n);
bool hasDecomposition() const { return m_hasDecomposition; }
void setHasDecomposition(bool v) { m_hasDecomposition = v; update(); }
int id() const { return m_id; }
void setId(int id) { m_id = id; }
@ -29,6 +35,15 @@ public:
void addArrow(ArrowItem* a);
void removeArrow(ArrowItem* a);
std::optional<qreal> price() const { return m_price; }
void setPrice(std::optional<qreal> price);
static void setCurrencyFormat(const QString& symbol, const QString& placement);
static void setVisualTheme(const QColor& foreground,
const QColor& background,
const QColor& border,
const QColor& font,
const QColor& selectedBackground);
signals:
void geometryChanged();
@ -37,8 +52,22 @@ protected:
void mouseDoubleClickEvent(QGraphicsSceneMouseEvent* e) override;
private:
QString formattedPrice() const;
static QPair<QString, QString> currencyAffixes();
QString m_title;
QString m_number;
QRectF m_rect; // local rect
int m_id;
bool m_hasDecomposition = false;
QSet<ArrowItem*> m_arrows;
std::optional<qreal> m_price;
static QString s_currencySymbol;
static QString s_currencyPlacement;
static QColor s_foregroundColor;
static QColor s_backgroundColor;
static QColor s_borderColor;
static QColor s_fontColor;
static QColor s_selectedBackgroundColor;
};

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,13 @@
#pragma once
#include <QGraphicsScene>
#include <QPointer>
#include <optional>
#include <QVariantMap>
#include "BlockItem.h"
class ArrowItem;
class JunctionItem;
class HeaderFooterItem;
class DiagramScene final : public QGraphicsScene {
Q_OBJECT
@ -14,9 +17,21 @@ public:
BlockItem* createBlockAt(const QPointF& scenePos);
BlockItem* createBlockWithId(const QPointF& scenePos, int id, const QString& title);
void requestSnapshot();
void applyMeta(const QVariantMap& meta);
void updateMeta(const QVariantMap& patch);
QVariantMap meta() const { return m_meta; }
QRectF contentRect() const { return m_contentRect; }
QString currentNodeLabel() const;
QString currentDiagramTitle() const;
bool goDownIntoSelected();
bool goDownIntoBlock(BlockItem* b);
bool goUp();
void propagateLabelFrom(ArrowItem* root);
QVariantMap exportToVariant();
bool importFromVariant(const QVariantMap& map);
signals:
void changed();
void metaChanged(const QVariantMap& meta);
protected:
void mousePressEvent(QGraphicsSceneMouseEvent* e) override;
@ -30,10 +45,10 @@ private:
enum class Kind { None, Block, Junction, Scene } kind = Kind::None;
int id = -1;
BlockItem::Port port = BlockItem::Port::Input;
QPointF localPos;
QPointF scenePos;
std::optional<QPointF> localPos;
std::optional<QPointF> scenePos;
};
struct Block { int id; QString title; QPointF pos; };
struct Block { int id; QString title; QPointF pos; bool hasDecomp = false; QString number; std::optional<qreal> price; };
struct Junction { int id; QPointF pos; };
struct Arrow {
Endpoint from;
@ -43,13 +58,19 @@ private:
qreal bottom = 0.0;
QString label;
QPointF labelOffset;
bool labelHidden = false;
bool labelInherited = false;
bool isInterface = false;
bool isInterfaceStub = false;
bool labelLocked = false;
Endpoint interfaceEdge;
};
QVector<Block> blocks;
QVector<Junction> junctions;
QVector<Arrow> arrows;
};
struct HierNode { int blockId; Snapshot snapshot; };
struct HierNode { int blockId; Snapshot snapshot; QString prefix; };
ArrowItem* m_dragArrow = nullptr;
QPointer<BlockItem> m_dragFromBlock;
@ -57,15 +78,20 @@ private:
BlockItem::Port m_dragFromPort = BlockItem::Port::Output;
int m_nextBlockId = 1;
int m_nextJunctionId = 1;
QString m_currentPrefix = QStringLiteral("A");
bool m_snapshotScheduled = false;
bool m_restoringSnapshot = false;
bool m_itemDragActive = false;
QHash<QGraphicsItem*, QPointF> m_pressPositions;
int m_currentBlockId = -1;
QVector<HierNode> m_hierarchy;
QHash<int, Snapshot> m_children; // blockId -> saved child diagram
QHash<QString, Snapshot> m_children; // hierarchical key ("1/2/3") -> saved child diagram
ArrowItem* m_dragInterfaceStub = nullptr;
QVariantMap m_meta;
HeaderFooterItem* m_headerFooter = nullptr;
QRectF m_contentRect;
bool tryStartArrowDrag(const QPointF& scenePos);
bool tryStartArrowDrag(const QPointF& scenePos, Qt::KeyboardModifiers mods);
bool tryFinishArrowDrag(const QPointF& scenePos);
bool tryBranchAtArrow(const QPointF& scenePos);
void cancelCurrentDrag();
@ -75,14 +101,22 @@ private:
int m_historyIndex = -1;
Snapshot captureSnapshot() const;
void restoreSnapshot(const Snapshot& snap);
void restoreSnapshot(const Snapshot& snap, bool resetHistoryState = true);
void pushSnapshot();
void scheduleSnapshot();
void undo();
void maybeSnapshotMovedItems();
void resetHistory(const Snapshot& base);
void ensureFrame();
void ensureHeaderFooter();
bool childHasContent(int blockId) const;
QString currentPathKey() const;
QString childKeyFor(int blockId) const;
QString assignNumber(BlockItem* b);
enum class Edge { None, Left, Right, Top, Bottom };
Edge hitTestEdge(const QPointF& scenePos, QPointF* outScenePoint = nullptr) const;
friend QJsonObject endpointToJson(const Snapshot::Endpoint& ep);
friend Snapshot::Endpoint endpointFromJson(const QJsonObject& o);
};

View file

@ -0,0 +1,320 @@
#include "HeaderFooterItem.h"
#include "items/DiagramScene.h"
#include <QPainter>
#include <QDialog>
#include <QFormLayout>
#include <QLineEdit>
#include <QDialogButtonBox>
#include <QCheckBox>
#include <QRadioButton>
#include <QButtonGroup>
#include <QVBoxLayout>
#include <QGraphicsSceneMouseEvent>
HeaderFooterItem::HeaderFooterItem(DiagramScene* scene)
: QGraphicsObject(nullptr)
, m_scene(scene)
{
setZValue(-4);
setAcceptedMouseButtons(Qt::LeftButton);
setFlag(QGraphicsItem::ItemIsSelectable, false);
}
void HeaderFooterItem::setMeta(const QVariantMap& meta) {
m_meta = meta;
update();
}
void HeaderFooterItem::setPageRect(const QRectF& rect) {
if (m_pageRect == rect) return;
prepareGeometryChange();
m_pageRect = rect;
update();
}
void HeaderFooterItem::setShowHeaderFooter(bool showHeader, bool showFooter) {
m_showHeader = showHeader;
m_showFooter = showFooter;
update();
}
QRectF HeaderFooterItem::boundingRect() const {
return m_pageRect;
}
QString HeaderFooterItem::metaText(const QString& key, const QString& fallback) const {
const auto val = m_meta.value(key);
if (val.isNull()) return fallback;
return val.toString();
}
bool HeaderFooterItem::metaBool(const QString& key, bool fallback) const {
if (!m_meta.contains(key)) return fallback;
return m_meta.value(key).toBool();
}
bool HeaderFooterItem::hitHeaderFooter(const QPointF& scenePos) const {
if (!m_pageRect.contains(scenePos)) return false;
const qreal headerH = m_pageRect.height() * 0.12;
const qreal footerH = m_pageRect.height() * 0.08;
const qreal top = m_pageRect.top();
const qreal bottom = m_pageRect.bottom();
if (m_showHeader && scenePos.y() <= top + headerH) return true;
if (m_showFooter && scenePos.y() >= bottom - footerH) return true;
return false;
}
void HeaderFooterItem::paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*) {
if (m_pageRect.isNull()) return;
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
const bool darkMode = metaBool("resolvedDarkMode", metaBool("darkMode", false));
const QColor fg = darkMode ? QColor(235, 235, 235) : QColor(20, 20, 20);
const QColor bg = darkMode ? QColor(26, 26, 26) : QColor(255, 255, 255);
const qreal w = m_pageRect.width();
const qreal h = m_pageRect.height();
const qreal headerH = h * 0.12;
const qreal footerH = h * 0.08;
QPen linePen(fg, 1.0);
painter->setPen(linePen);
painter->setBrush(Qt::NoBrush);
QFont labelFont = painter->font();
labelFont.setPointSizeF(labelFont.pointSizeF() - 1);
painter->setFont(labelFont);
if (m_showHeader) {
QRectF headerRect(m_pageRect.left(), m_pageRect.top(), w, headerH);
painter->drawRect(headerRect);
const qreal leftW = w * 0.62;
const qreal rightW = w - leftW;
const qreal xSplit = headerRect.left() + leftW;
painter->drawLine(QPointF(xSplit, headerRect.top()), QPointF(xSplit, headerRect.bottom()));
const qreal rowH = headerH / 4.0;
for (int i = 1; i < 4; ++i) {
const qreal y = headerRect.top() + i * rowH;
painter->drawLine(QPointF(headerRect.left(), y), QPointF(xSplit, y));
}
// Left area text
const QString organization = metaText("organization");
const QString usedAt = organization.isEmpty() ? metaText("usedAt") : organization;
const QString author = metaText("author");
const QString title = metaText("title");
const QString project = title.isEmpty() ? metaText("project") : title;
const QString notes = metaText("notes");
const QString date = metaText("date");
const QString rev = metaText("rev");
auto drawRow = [&](int row, const QString& label, const QString& value) {
QRectF r(headerRect.left() + 6, headerRect.top() + row * rowH + 2, leftW - 12, rowH - 4);
painter->drawText(r, Qt::AlignLeft | Qt::AlignVCenter, label + (value.isEmpty() ? "" : " " + value));
};
drawRow(0, organization.isEmpty() ? tr("USED AT:") : tr("ORGANIZATION:"), usedAt);
drawRow(1, tr("AUTHOR:"), author);
drawRow(2, title.isEmpty() ? tr("PROJECT:") : tr("TITLE:"), project);
drawRow(3, tr("NOTES:"), notes);
// Date/rev in left area top-right corner
QRectF dateRect(headerRect.left() + leftW * 0.55, headerRect.top() + 2, leftW * 0.45 - 6, rowH - 4);
painter->drawText(dateRect, Qt::AlignRight | Qt::AlignVCenter, tr("DATE: ") + date);
QRectF revRect(headerRect.left() + leftW * 0.55, headerRect.top() + rowH + 2, leftW * 0.45 - 6, rowH - 4);
painter->drawText(revRect, Qt::AlignRight | Qt::AlignVCenter, tr("REV: ") + rev);
// Right area split
QRectF rightRect(xSplit, headerRect.top(), rightW, headerH);
const qreal statusW = rightW * 0.55;
const qreal infoW = rightW - statusW;
const qreal statusX = rightRect.left();
const qreal infoX = rightRect.left() + statusW;
painter->drawLine(QPointF(infoX, headerRect.top()), QPointF(infoX, headerRect.bottom()));
const qreal statusRowH = headerH / 4.0;
for (int i = 1; i < 4; ++i) {
const qreal y = headerRect.top() + i * statusRowH;
painter->drawLine(QPointF(statusX, y), QPointF(infoX, y));
}
QString activeState = metaText("state");
if (activeState.isEmpty()) {
if (metaBool("publication", false)) activeState = "publication";
else if (metaBool("recommended", false)) activeState = "recommended";
else if (metaBool("draft", false)) activeState = "draft";
else if (metaBool("working", false)) activeState = "working";
}
auto drawStatus = [&](int row, const QString& label, const QString& key) {
QRectF r(statusX, headerRect.top() + row * statusRowH, statusW, statusRowH);
const qreal box = statusRowH;
const qreal bx = r.left();
const qreal by = r.top();
QRectF boxRect(bx, by, box, box);
painter->setBrush(activeState == key ? fg : bg);
painter->drawRect(boxRect);
painter->setBrush(Qt::NoBrush);
QRectF textRect = r.adjusted(box + 6, 0, -6, 0);
painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, label);
};
drawStatus(0, tr("WORKING"), "working");
drawStatus(1, tr("DRAFT"), "draft");
drawStatus(2, tr("RECOMMENDED"), "recommended");
drawStatus(3, tr("PUBLICATION"), "publication");
// Info column
const qreal infoRowH = headerH / 3.0;
for (int i = 1; i < 3; ++i) {
const qreal y = headerRect.top() + i * infoRowH;
painter->drawLine(QPointF(infoX, y), QPointF(rightRect.right(), y));
}
QRectF readerRect(infoX + 6, headerRect.top() + 2, infoW - 12, infoRowH - 4);
painter->drawText(readerRect, Qt::AlignLeft | Qt::AlignVCenter, tr("READER: ") + metaText("reader"));
QRectF readerDateRect(infoX + 6, headerRect.top() + infoRowH + 2, infoW - 12, infoRowH - 4);
painter->drawText(readerDateRect, Qt::AlignLeft | Qt::AlignVCenter, tr("DATE: ") + metaText("readerDate"));
QRectF contextRect(infoX + 6, headerRect.top() + 2 * infoRowH + 2, infoW - 12, infoRowH - 4);
painter->drawText(contextRect, Qt::AlignLeft | Qt::AlignVCenter, tr("CONTEXT: ") + metaText("context"));
}
if (m_showFooter) {
QRectF footerRect(m_pageRect.left(), m_pageRect.bottom() - footerH, w, footerH);
painter->drawRect(footerRect);
const qreal leftW = w * 0.2;
const qreal rightW = w * 0.2;
const qreal middleW = w - leftW - rightW;
const qreal x1 = footerRect.left() + leftW;
const qreal x2 = x1 + middleW;
painter->drawLine(QPointF(x1, footerRect.top()), QPointF(x1, footerRect.bottom()));
painter->drawLine(QPointF(x2, footerRect.top()), QPointF(x2, footerRect.bottom()));
QString nodeValue = metaText("footerNodeOverride");
if (nodeValue.isEmpty() && m_scene) nodeValue = m_scene->currentNodeLabel();
if (nodeValue.isEmpty()) nodeValue = metaText("node", "A0");
QRectF nodeRect(footerRect.left() + 6, footerRect.top() + 2, leftW - 12, footerH - 4);
painter->drawText(nodeRect, Qt::AlignLeft | Qt::AlignVCenter, tr("NODE: ") + nodeValue);
QString titleValue = metaText("footerTitleOverride");
if (titleValue.isEmpty() && m_scene) titleValue = m_scene->currentDiagramTitle();
if (titleValue.isEmpty()) titleValue = metaText("title");
QRectF titleRect(x1 + 6, footerRect.top() + 2, middleW - 12, footerH - 4);
painter->drawText(titleRect, Qt::AlignHCenter | Qt::AlignVCenter, titleValue);
QString numberValue = metaText("footerNumberOverride");
if (numberValue.isEmpty()) numberValue = metaText("number");
QRectF numberRect(x2 + 6, footerRect.top() + 2, rightW - 12, footerH - 4);
painter->drawText(numberRect, Qt::AlignLeft | Qt::AlignVCenter, tr("NUMBER: ") + numberValue);
}
painter->restore();
}
void HeaderFooterItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent* e) {
if (!hitHeaderFooter(e->scenePos())) {
QGraphicsObject::mouseDoubleClickEvent(e);
return;
}
auto* dlg = new QDialog();
dlg->setWindowTitle(tr("Edit header/footer"));
auto* layout = new QVBoxLayout(dlg);
auto* form = new QFormLayout();
auto* usedAt = new QLineEdit(metaText("usedAt"), dlg);
auto* author = new QLineEdit(metaText("author"), dlg);
auto* project = new QLineEdit(metaText("project"), dlg);
auto* notes = new QLineEdit(metaText("notes"), dlg);
auto* date = new QLineEdit(metaText("date"), dlg);
auto* rev = new QLineEdit(metaText("rev"), dlg);
auto* reader = new QLineEdit(metaText("reader"), dlg);
auto* readerDate = new QLineEdit(metaText("readerDate"), dlg);
auto* context = new QLineEdit(metaText("context"), dlg);
auto* node = new QLineEdit(metaText("node", "A0"), dlg);
auto* title = new QLineEdit(metaText("title"), dlg);
auto* number = new QLineEdit(metaText("number"), dlg);
const QString state = metaText("state");
auto* working = new QRadioButton(tr("Working"), dlg);
auto* draft = new QRadioButton(tr("Draft"), dlg);
auto* recommended = new QRadioButton(tr("Recommended"), dlg);
auto* publication = new QRadioButton(tr("Publication"), dlg);
auto* group = new QButtonGroup(dlg);
group->addButton(working);
group->addButton(draft);
group->addButton(recommended);
group->addButton(publication);
if (state == "draft") {
draft->setChecked(true);
} else if (state == "recommended") {
recommended->setChecked(true);
} else if (state == "publication") {
publication->setChecked(true);
} else if (state == "working") {
working->setChecked(true);
} else {
// Backward-compatible: map old flags to a single selection.
if (metaBool("publication", false)) publication->setChecked(true);
else if (metaBool("recommended", false)) recommended->setChecked(true);
else if (metaBool("draft", false)) draft->setChecked(true);
else if (metaBool("working", false)) working->setChecked(true);
}
form->addRow(tr("Used at:"), usedAt);
form->addRow(tr("Author:"), author);
form->addRow(tr("Project:"), project);
form->addRow(tr("Notes:"), notes);
form->addRow(tr("Date:"), date);
form->addRow(tr("Rev:"), rev);
form->addRow(tr("Reader:"), reader);
form->addRow(tr("Reader date:"), readerDate);
form->addRow(tr("Context:"), context);
form->addRow(tr("Node:"), node);
form->addRow(tr("Title:"), title);
form->addRow(tr("Number:"), number);
form->addRow(tr("State:"), working);
form->addRow(QString(), draft);
form->addRow(QString(), recommended);
form->addRow(QString(), publication);
layout->addLayout(form);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dlg);
connect(buttons, &QDialogButtonBox::accepted, dlg, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, dlg, &QDialog::reject);
layout->addWidget(buttons);
if (dlg->exec() == QDialog::Accepted) {
QVariantMap patch;
patch["usedAt"] = usedAt->text();
patch["author"] = author->text();
patch["project"] = project->text();
patch["notes"] = notes->text();
patch["date"] = date->text();
patch["rev"] = rev->text();
patch["reader"] = reader->text();
patch["readerDate"] = readerDate->text();
patch["context"] = context->text();
patch["node"] = node->text();
patch["title"] = title->text();
patch["number"] = number->text();
QString nextState;
if (working->isChecked()) nextState = "working";
else if (draft->isChecked()) nextState = "draft";
else if (recommended->isChecked()) nextState = "recommended";
else if (publication->isChecked()) nextState = "publication";
patch["state"] = nextState;
patch["working"] = (nextState == "working");
patch["draft"] = (nextState == "draft");
patch["recommended"] = (nextState == "recommended");
patch["publication"] = (nextState == "publication");
if (m_scene) m_scene->updateMeta(patch);
}
dlg->deleteLater();
QGraphicsObject::mouseDoubleClickEvent(e);
}

View file

@ -0,0 +1,32 @@
#pragma once
#include <QGraphicsObject>
#include <QVariantMap>
class DiagramScene;
class HeaderFooterItem final : public QGraphicsObject {
Q_OBJECT
public:
explicit HeaderFooterItem(DiagramScene* scene);
void setMeta(const QVariantMap& meta);
void setPageRect(const QRectF& rect);
void setShowHeaderFooter(bool showHeader, bool showFooter);
QRectF boundingRect() const override;
void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override;
protected:
void mouseDoubleClickEvent(QGraphicsSceneMouseEvent* e) override;
private:
DiagramScene* m_scene = nullptr;
QVariantMap m_meta;
QRectF m_pageRect;
bool m_showHeader = true;
bool m_showFooter = true;
QString metaText(const QString& key, const QString& fallback = QString()) const;
bool metaBool(const QString& key, bool fallback = false) const;
bool hitHeaderFooter(const QPointF& scenePos) const;
};

View file

@ -1,10 +1,14 @@
#include "JunctionItem.h"
#include "ArrowItem.h"
#include "items/DiagramScene.h"
#include <QPainter>
#include <QStyleOptionGraphicsItem>
#include <cmath>
QColor JunctionItem::s_normalColor = QColor(30, 30, 30);
QColor JunctionItem::s_selectedColor = QColor(40, 100, 255);
JunctionItem::JunctionItem(QGraphicsItem* parent, int id)
: QGraphicsObject(parent),
m_id(id)
@ -20,10 +24,15 @@ QRectF JunctionItem::boundingRect() const {
void JunctionItem::paint(QPainter* p, const QStyleOptionGraphicsItem*, QWidget*) {
p->setRenderHint(QPainter::Antialiasing, true);
p->setPen(Qt::NoPen);
p->setBrush(isSelected() ? QColor(40, 100, 255) : QColor(30, 30, 30));
p->setBrush(isSelected() ? s_selectedColor : s_normalColor);
p->drawEllipse(QPointF(0, 0), m_radius, m_radius);
}
void JunctionItem::setVisualTheme(const QColor& normalColor, const QColor& selectedColor) {
s_normalColor = normalColor;
s_selectedColor = selectedColor;
}
void JunctionItem::addArrow(ArrowItem* a) {
m_arrows.insert(a);
}
@ -38,6 +47,20 @@ bool JunctionItem::hitTest(const QPointF& scenePos, qreal radius) const {
}
QVariant JunctionItem::itemChange(GraphicsItemChange change, const QVariant& value) {
if (change == ItemPositionChange) {
const QPointF desired = value.toPointF();
if (auto* ds = qobject_cast<DiagramScene*>(scene())) {
QRectF bounds = ds->contentRect();
if (bounds.isNull()) bounds = ds->sceneRect();
QRectF rect = boundingRect().translated(desired);
QPointF clamped = desired;
if (rect.left() < bounds.left()) clamped.setX(desired.x() + (bounds.left() - rect.left()));
if (rect.right() > bounds.right()) clamped.setX(clamped.x() - (rect.right() - bounds.right()));
if (rect.top() < bounds.top()) clamped.setY(desired.y() + (bounds.top() - rect.top()));
if (rect.bottom() > bounds.bottom()) clamped.setY(clamped.y() - (rect.bottom() - bounds.bottom()));
return clamped;
}
}
if (change == ItemPositionHasChanged || change == ItemTransformHasChanged) {
for (ArrowItem* a : m_arrows) {
if (a) a->updatePath();

View file

@ -1,6 +1,7 @@
#pragma once
#include <QGraphicsObject>
#include <QSet>
#include <QColor>
class ArrowItem;
@ -21,6 +22,7 @@ public:
bool hitTest(const QPointF& scenePos, qreal radius) const;
int id() const { return m_id; }
void setId(int id) { m_id = id; }
static void setVisualTheme(const QColor& normalColor, const QColor& selectedColor);
protected:
QVariant itemChange(GraphicsItemChange change, const QVariant& value) override;
@ -29,4 +31,6 @@ private:
qreal m_radius = 5.0;
int m_id;
QSet<ArrowItem*> m_arrows;
static QColor s_normalColor;
static QColor s_selectedColor;
};