minor patches

This commit is contained in:
Gregory Bednov 2026-03-02 00:33:40 +03:00
commit 891067ac38
9 changed files with 506 additions and 55 deletions

View file

@ -7,6 +7,7 @@
#include <QStatusBar>
#include <QDialog>
#include <QTabWidget>
#include <QTreeWidget>
#include <QVBoxLayout>
#include <QFormLayout>
#include <QLineEdit>
@ -68,6 +69,23 @@ static QPageLayout::Orientation pageOrientationFromMeta(const QVariantMap& meta)
return QPageLayout::Landscape;
}
static QVariantMap stripPerDiagramState(QVariantMap meta) {
meta.remove("state");
meta.remove("working");
meta.remove("draft");
meta.remove("recommended");
meta.remove("publication");
return meta;
}
static QVariantMap mergeWithDiagramState(QVariantMap globalMeta, const QVariantMap& diagramMeta) {
for (const char* key : {"state", "working", "draft", "recommended", "publication"}) {
const QString k = QString::fromLatin1(key);
if (diagramMeta.contains(k)) globalMeta[k] = diagramMeta.value(k);
}
return globalMeta;
}
MainWindow::MainWindow(const QString& startupPath, QWidget* parent)
: QMainWindow(parent)
{
@ -91,10 +109,10 @@ void MainWindow::setupUi() {
m_scene = new DiagramScene(this);
connect(m_scene, &DiagramScene::changed, this, [this]{ markDirty(true); });
connect(m_scene, &DiagramScene::metaChanged, this, [this](const QVariantMap& meta){
m_welcomeState = meta;
m_welcomeState = stripPerDiagramState(meta);
markDirty(true);
});
m_scene->applyMeta(m_welcomeState);
m_scene->applyMeta(mergeWithDiagramState(m_welcomeState, m_scene->meta()));
}
m_view = new QGraphicsView(m_scene, this);
m_view->setRenderHint(QPainter::Antialiasing, true);
@ -155,6 +173,12 @@ void MainWindow::setupActions() {
});
viewMenu->addAction(actZoomOut);
auto* actNodeTree = new QAction(tr("Node tree…"), this);
connect(actNodeTree, &QAction::triggered, this, [this]{
showNodeTreeDialog();
});
viewMenu->addAction(actNodeTree);
m_actDarkMode = new QAction(tr("Dark mode"), this);
m_actDarkMode->setCheckable(true);
m_actDarkMode->setChecked(m_welcomeState.value("darkMode", false).toBool());
@ -173,7 +197,7 @@ void MainWindow::setupActions() {
m_welcomeState["resolvedDarkMode"] = effectiveDarkMode();
m_actDarkMode->setEnabled(!followSystem);
applyAppPalette(m_welcomeState.value("resolvedDarkMode").toBool());
m_scene->applyMeta(m_welcomeState);
m_scene->applyMeta(mergeWithDiagramState(m_welcomeState, m_scene->meta()));
if (mark) markDirty(true);
};
connect(m_actDarkMode, &QAction::toggled, this, [applyModes]{ applyModes(true); });
@ -237,7 +261,7 @@ void MainWindow::setupActions() {
QVariantMap root;
root["diagram"] = exported.value("diagram");
root["children"] = exported.value("children");
root["meta"] = m_welcomeState;
root["meta"] = stripPerDiagramState(m_welcomeState);
QJsonDocument doc = QJsonDocument::fromVariant(root);
QFile f(outPath);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
@ -272,15 +296,18 @@ void MainWindow::setupActions() {
scope->addItems({tr("Current diagram"), tr("All diagrams")});
auto* pageNumbers = new QCheckBox(tr("Number pages in footer (NUMBER field)"), &dlg);
pageNumbers->setChecked(false);
auto* forceLight = new QCheckBox(tr("Use a light theme regardless of the document theme"), &dlg);
forceLight->setChecked(true);
form->addRow(tr("Export scope:"), scope);
form->addRow(QString(), pageNumbers);
form->addRow(QString(), forceLight);
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) return;
exportPdf(scope->currentIndex() == 1, pageNumbers->isChecked());
exportPdf(scope->currentIndex() == 1, pageNumbers->isChecked(), forceLight->isChecked());
});
connect(actCallMech, &QAction::triggered, this, [this]{
@ -415,11 +442,11 @@ bool MainWindow::showWelcome() {
? QString::fromUtf8("\u20BD")
: currencySymbolForLocale(loc);
auto* currency = new QComboBox(units);
currency->setEditable(false);
currency->setEditable(true);
currency->addItem(curSym);
if (curSym != "$") currency->addItem("$");
const QString existingCur = m_welcomeState.value("currency").toString();
if (!existingCur.isEmpty() && currency->findText(existingCur) >= 0) {
if (!existingCur.isEmpty()) {
currency->setCurrentText(existingCur);
} else {
currency->setCurrentIndex(0);
@ -507,10 +534,10 @@ void MainWindow::resetScene() {
m_scene = new DiagramScene(this);
connect(m_scene, &DiagramScene::changed, this, [this]{ markDirty(true); });
connect(m_scene, &DiagramScene::metaChanged, this, [this](const QVariantMap& meta){
m_welcomeState = meta;
m_welcomeState = stripPerDiagramState(meta);
markDirty(true);
});
m_scene->applyMeta(m_welcomeState);
m_scene->applyMeta(mergeWithDiagramState(m_welcomeState, m_scene->meta()));
if (m_view) m_view->setScene(m_scene);
if (old) old->deleteLater();
}
@ -518,8 +545,8 @@ void MainWindow::resetScene() {
void MainWindow::newDiagram() {
if (!showWelcome()) return;
resetScene();
m_scene->createBlockAt(QPointF(-200, -50))->setPos(-300, -150);
m_scene->createBlockAt(QPointF(200, -50))->setPos(200, -150);
const QRectF bounds = m_scene->contentRect().isNull() ? m_scene->sceneRect() : m_scene->contentRect();
m_scene->createBlockAt(bounds.center());
m_currentFile.clear();
markDirty(false);
}
@ -540,7 +567,8 @@ bool MainWindow::promptStartup() {
}
if (!showWelcome()) return false;
resetScene();
m_scene->createBlockAt(QPointF(-100, -50));
const QRectF bounds = m_scene->contentRect().isNull() ? m_scene->sceneRect() : m_scene->contentRect();
m_scene->createBlockAt(bounds.center());
m_currentFile.clear();
markDirty(false);
return true;
@ -566,7 +594,7 @@ bool MainWindow::loadDiagramFromPath(const QString& path) {
return false;
}
if (root.contains("meta")) {
m_welcomeState = root.value("meta").toMap();
m_welcomeState = stripPerDiagramState(root.value("meta").toMap());
}
if (m_actDarkMode && m_actFollowSystemTheme) {
const QSignalBlocker b2(m_actDarkMode);
@ -577,13 +605,13 @@ bool MainWindow::loadDiagramFromPath(const QString& path) {
}
m_welcomeState["resolvedDarkMode"] = effectiveDarkMode();
applyAppPalette(m_welcomeState.value("resolvedDarkMode").toBool());
m_scene->applyMeta(m_welcomeState);
m_scene->applyMeta(mergeWithDiagramState(m_welcomeState, m_scene->meta()));
m_currentFile = path;
markDirty(false);
return true;
}
bool MainWindow::exportPdf(bool allDiagrams, bool numberPages) {
bool MainWindow::exportPdf(bool allDiagrams, bool numberPages, bool forceLightTheme) {
QString path = QFileDialog::getSaveFileName(this, tr("Export to PDF"), QString(), tr(kPdfFileFilter));
if (path.isEmpty()) return false;
if (QFileInfo(path).suffix().isEmpty()) path += ".pdf";
@ -612,6 +640,10 @@ bool MainWindow::exportPdf(bool allDiagrams, bool numberPages) {
} else {
meta.remove("footerNumberOverride");
}
if (forceLightTheme) {
meta["darkMode"] = false;
meta["resolvedDarkMode"] = false;
}
ds->applyMeta(meta);
}
const QRect target = writer.pageLayout().paintRectPixels(writer.resolution());
@ -648,14 +680,14 @@ bool MainWindow::exportPdf(bool allDiagrams, bool numberPages) {
collectKeys(QString(), exported.value("diagram").toMap());
DiagramScene tempScene;
tempScene.applyMeta(m_welcomeState);
tempScene.applyMeta(mergeWithDiagramState(m_welcomeState, tempScene.meta()));
QVariantMap mapRoot;
mapRoot["diagram"] = exported.value("diagram");
mapRoot["children"] = children;
bool first = true;
if (tempScene.importFromVariant(mapRoot)) {
tempScene.applyMeta(m_welcomeState);
tempScene.applyMeta(mergeWithDiagramState(m_welcomeState, tempScene.meta()));
renderScene(&tempScene, false);
first = false;
}
@ -674,7 +706,7 @@ bool MainWindow::exportPdf(bool allDiagrams, bool numberPages) {
mapOne["children"] = relChildren;
if (!tempScene.importFromVariant(mapOne)) continue;
tempScene.applyMeta(m_welcomeState);
tempScene.applyMeta(mergeWithDiagramState(m_welcomeState, tempScene.meta()));
renderScene(&tempScene, !first);
first = false;
}
@ -721,6 +753,119 @@ bool MainWindow::openDiagramPath(const QString& path) {
return loadDiagramFromPath(path);
}
void MainWindow::showNodeTreeDialog() {
if (!m_scene) return;
const QVariantMap exported = m_scene->exportToVariant();
const QVariantMap rootDiagram = exported.value("diagram").toMap();
const QVariantMap children = exported.value("children").toMap();
auto* dlg = new QDialog(this);
dlg->setWindowTitle(tr("Node tree"));
auto* layout = new QVBoxLayout(dlg);
auto* tree = new QTreeWidget(dlg);
tree->setHeaderHidden(true);
tree->setExpandsOnDoubleClick(false);
auto itemText = [](const QVariantMap& block) {
const QString num = block.value("number").toString();
const QString title = block.value("title").toString();
if (num.isEmpty()) return title;
if (title.isEmpty()) return num;
return QStringLiteral("%1 %2").arg(num, title);
};
std::function<void(QTreeWidgetItem*, const QVariantMap&, const QString&)> addSubtree;
addSubtree = [&](QTreeWidgetItem* parent, const QVariantMap& diagram, const QString& prefix) {
const QVariantList blockList = diagram.value("blocks").toList();
QVector<QVariant> blocks;
blocks.reserve(blockList.size());
for (const QVariant& v : blockList) blocks.push_back(v);
std::sort(blocks.begin(), blocks.end(), [](const QVariant& a, const QVariant& b) {
const QVariantMap ma = a.toMap();
const QVariantMap mb = b.toMap();
const QString na = ma.value("number").toString();
const QString nb = mb.value("number").toString();
if (na == nb) return ma.value("id", -1).toInt() < mb.value("id", -1).toInt();
return na.localeAwareCompare(nb) < 0;
});
for (const QVariant& vb : blocks) {
const QVariantMap block = vb.toMap();
const int id = block.value("id", -1).toInt();
auto* it = new QTreeWidgetItem();
it->setText(0, itemText(block));
it->setData(0, Qt::UserRole, prefix); // diagram path containing this block
it->setData(0, Qt::UserRole + 1, id); // block id in that diagram
parent->addChild(it);
if (id < 0) continue;
const QString key = prefix.isEmpty() ? QString::number(id) : (prefix + "/" + QString::number(id));
if (children.contains(key)) {
addSubtree(it, children.value(key).toMap(), key);
}
}
};
const QString rootName = m_welcomeState.value("title").toString().isEmpty()
? tr("Model")
: m_welcomeState.value("title").toString();
auto* root = new QTreeWidgetItem(tree);
root->setText(0, QStringLiteral("A-0 %1").arg(rootName));
root->setData(0, Qt::UserRole, QString());
root->setData(0, Qt::UserRole + 1, -1);
tree->addTopLevelItem(root);
addSubtree(root, rootDiagram, QString());
tree->expandAll();
connect(tree, &QTreeWidget::itemDoubleClicked, this, [this, tree](QTreeWidgetItem* item, int) {
if (!item || !m_scene) return;
const QString path = item->data(0, Qt::UserRole).toString();
const int blockId = item->data(0, Qt::UserRole + 1).toInt();
while (m_scene->goUp()) {}
const QStringList parts = path.split('/', Qt::SkipEmptyParts);
for (const QString& part : parts) {
bool ok = false;
const int id = part.toInt(&ok);
if (!ok) break;
BlockItem* target = nullptr;
for (QGraphicsItem* gi : m_scene->items()) {
if (auto* b = qgraphicsitem_cast<BlockItem*>(gi)) {
if (b->id() == id) {
target = b;
break;
}
}
}
if (!target || !m_scene->goDownIntoBlock(target)) break;
}
m_scene->clearSelection();
if (blockId >= 0) {
for (QGraphicsItem* gi : m_scene->items()) {
if (auto* b = qgraphicsitem_cast<BlockItem*>(gi)) {
if (b->id() == blockId) {
b->setSelected(true);
if (m_view) m_view->centerOn(b);
break;
}
}
}
} else if (m_view) {
m_view->centerOn(m_scene->sceneRect().center());
}
tree->setCurrentItem(item);
});
layout->addWidget(tree);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, dlg);
connect(buttons, &QDialogButtonBox::rejected, dlg, &QDialog::reject);
connect(buttons, &QDialogButtonBox::accepted, dlg, &QDialog::accept);
layout->addWidget(buttons);
dlg->resize(640, 520);
dlg->exec();
dlg->deleteLater();
}
void MainWindow::closeEvent(QCloseEvent* e) {
if (!m_dirty) {
e->accept();

View file

@ -40,10 +40,11 @@ private:
void newDiagram();
bool promptStartup();
bool loadDiagramFromPath(const QString& path);
bool exportPdf(bool allDiagrams, bool numberPages);
bool exportPdf(bool allDiagrams, bool numberPages, bool forceLightTheme);
bool effectiveDarkMode() const;
void applyAppPalette(bool darkMode);
void autoInitialsFromAuthor(QLineEdit* author, QLineEdit* initials);
void showNodeTreeDialog();
void markDirty(bool dirty);
void updateWindowTitle();
};

View file

@ -30,6 +30,24 @@ static QRectF labelDragHitRect(const QGraphicsSimpleTextItem* item) {
// Generous hit area makes label dragging much less fiddly.
return item->sceneBoundingRect().adjusted(-16, -10, 16, 10);
}
QRectF ArrowItem::boundingRect() const {
QRectF r = QGraphicsPathItem::boundingRect();
if (m_labelItem && m_labelItem->isVisible()) {
r = r.united(mapRectFromScene(labelDragHitRect(m_labelItem)));
}
return r;
}
QPainterPath ArrowItem::shape() const {
QPainterPath s = QGraphicsPathItem::shape();
if (m_labelItem && m_labelItem->isVisible()) {
QPainterPath labelZone;
labelZone.addRect(mapRectFromScene(labelDragHitRect(m_labelItem)));
s = s.united(labelZone);
}
return s;
}
QColor ArrowItem::s_lineColor = QColor(10, 10, 10);
QColor ArrowItem::s_textColor = QColor(10, 10, 10);
@ -162,6 +180,18 @@ void ArrowItem::setLabelLocked(bool locked) {
m_labelLocked = locked;
}
void ArrowItem::setFromTunneled(bool v) {
if (m_from.tunneled == v) return;
m_from.tunneled = v;
updatePath();
}
void ArrowItem::setToTunneled(bool v) {
if (m_to.tunneled == v) return;
m_to.tunneled = v;
updatePath();
}
bool ArrowItem::adjustEndpoint(Endpoint& ep, const QPointF& delta) {
if (ep.block) {
auto seg = ep.block->portSegment(ep.port);
@ -484,7 +514,7 @@ QPointF ArrowItem::portDirection(const Endpoint& ep, bool isFrom, const QPointF&
const qreal db = std::abs(p.y() - r.bottom());
const qreal m = std::min({dl, dr, dt, db});
if (m == dl) return isFrom ? QPointF(1,0) : QPointF(-1,0);
if (m == dr) return isFrom ? QPointF(-1,0) : QPointF(1,0); // right edge is target-only
if (m == dr) return QPointF(-1,0); // right-edge targets must be approached from inside (left)
if (m == dt) return isFrom ? QPointF(0,1) : QPointF(0,-1);
if (m == db) return isFrom ? QPointF(0,-1) : QPointF(0,1);
}
@ -540,13 +570,6 @@ QVector<QPointF> ArrowItem::computePolyline() const {
: QPointF(0, ((a - b).y() >= 0 ? 1 : -1)),
QPointF(-1,0))
: portDirection(m_to, false, QPointF(-1,0), scene());
// ensure right border always behaves like an input (approach from left)
if (!m_hasTempEnd && m_to.scenePos && scene()) {
const qreal rightX = scene()->sceneRect().right();
if (almostEqual(m_to.scenePos->x(), rightX)) {
dirB = QPointF(-1, 0);
}
}
auto expandRect = [](const QRectF& r, qreal m) {
return QRectF(r.x()-m, r.y()-m, r.width()+2*m, r.height()+2*m);
@ -645,7 +668,22 @@ void ArrowItem::updateLabelItem(const QVector<QPointF>& pts) {
}
const QPointF p = scenePortPos(*m_interfaceEdge);
const QRectF br = m_labelItem->boundingRect();
m_labelItem->setPos(p + m_labelOffset + QPointF(10, -br.height() * 0.5));
QPointF sideOffset;
switch (m_interfaceEdge->port) {
case BlockItem::Port::Input:
sideOffset = QPointF(10, -br.height() * 0.5); // right of left frame side
break;
case BlockItem::Port::Output:
sideOffset = QPointF(-10 - br.width(), -br.height() * 0.5); // left of right frame side
break;
case BlockItem::Port::Control:
sideOffset = QPointF(-br.width() * 0.5, 10); // below top frame side
break;
case BlockItem::Port::Mechanism:
sideOffset = QPointF(-br.width() * 0.5, -10 - br.height()); // above bottom frame side
break;
}
m_labelItem->setPos(p + m_labelOffset + sideOffset);
m_labelItem->setVisible(true);
return;
}
@ -689,7 +727,12 @@ void ArrowItem::updateLabelItem(const QVector<QPointF>& pts) {
// Обновляем маршрут ортогонально, добавляя/схлопывая сегменты при необходимости.
void ArrowItem::updatePath() {
QPen themedPen = pen();
themedPen.setColor(m_customColor.value_or(s_lineColor));
QColor lineColor = m_customColor.value_or(s_lineColor);
if (m_labelDragging) {
lineColor = lineColor.lighter(150);
}
themedPen.setColor(lineColor);
themedPen.setWidthF(m_labelDragging ? 2.2 : 1.4);
if (m_isCallMechanism) {
themedPen.setStyle(Qt::DashLine);
}
@ -702,18 +745,19 @@ void ArrowItem::updatePath() {
QVector<QPointF> pts = computePolyline();
m_lastPolyline = pts;
const QVector<QPointF>& drawPts = pts;
QPainterPath p;
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]);
if (!drawPts.isEmpty() && !stubOnly) {
p.moveTo(drawPts.first());
for (int i = 1; i < drawPts.size(); ++i) {
p.lineTo(drawPts[i]);
}
}
// interface stub circle at edge
if (m_isInterface && m_interfaceEdge && m_interfaceStubOnly) {
if (m_isInterface && m_interfaceEdge && m_interfaceStubOnly && !m_interfaceEdge->tunneled) {
const QPointF stubPos = scenePortPos(*m_interfaceEdge);
const qreal r = 6.0;
p.addEllipse(stubPos, r, r);
@ -721,10 +765,18 @@ void ArrowItem::updatePath() {
// наконечник стрелки
// 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];
if (!stubOnly && drawPts.size() >= 2 && !m_to.junction) {
const QPointF tip = drawPts.last();
const QPointF from = drawPts[drawPts.size() - 2];
drawArrowHead(p, tip, from);
if (m_to.tunneled) {
drawTunnelBrackets(p, tip, from);
}
}
if (!stubOnly && drawPts.size() >= 2 && m_from.tunneled) {
const QPointF tip = drawPts.first();
const QPointF from = tip - normalizedOr(drawPts[1] - tip, QPointF(1, 0));
drawTunnelBrackets(p, tip, from);
}
// Обновляем геометрию, чтобы сцена инвалидацировала старую и новую области.
@ -748,7 +800,7 @@ void ArrowItem::hoverMoveEvent(QGraphicsSceneHoverEvent* e) {
const QVector<QPointF> pts = computePolyline();
QPointF pos = e->scenePos();
if (!m_labelLocked && m_labelItem && labelDragHitRect(m_labelItem).contains(pos)) {
if (m_labelItem && labelDragHitRect(m_labelItem).contains(pos)) {
setCursor(Qt::SizeAllCursor);
QGraphicsPathItem::hoverMoveEvent(e);
return;
@ -777,10 +829,11 @@ void ArrowItem::mousePressEvent(QGraphicsSceneMouseEvent* e) {
// 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())) {
if (m_labelItem && labelDragHitRect(m_labelItem).contains(e->scenePos())) {
m_labelDragging = true;
m_labelDragLastScene = e->scenePos();
setCursor(Qt::SizeAllCursor);
updatePath();
e->accept();
return;
}
@ -844,7 +897,7 @@ void ArrowItem::mouseMoveEvent(QGraphicsSceneMouseEvent* e) {
const QPointF delta = e->scenePos() - m_labelDragLastScene;
m_labelDragLastScene = e->scenePos();
m_labelOffset += delta;
updateLabelItem(m_lastPolyline.isEmpty() ? computePolyline() : m_lastPolyline);
updatePath();
e->accept();
return;
}
@ -900,6 +953,7 @@ void ArrowItem::mouseReleaseEvent(QGraphicsSceneMouseEvent* e) {
if (m_labelDragging && e->button() == Qt::LeftButton) {
m_labelDragging = false;
unsetCursor();
updatePath();
if (auto* ds = qobject_cast<DiagramScene*>(scene())) {
ds->requestSnapshot();
}
@ -970,3 +1024,30 @@ void ArrowItem::drawArrowHead(QPainterPath& path, const QPointF& tip, const QPoi
path.lineTo(tip);
path.lineTo(right);
}
void ArrowItem::drawTunnelBrackets(QPainterPath& path, const QPointF& tip, const QPointF& from) {
const QPointF v = tip - from;
const qreal len = std::hypot(v.x(), v.y());
if (len < 0.001) return;
const QPointF dir = v / len;
const QPointF n(-dir.y(), dir.x());
// Pull marker inward so both brackets remain visible even when tip is on the frame edge.
const QPointF c = tip;
const qreal h = 9.0;
const qreal w = 5.0;
const qreal gap = 8.5;
// Rotate tunnel marker by 90 degrees around arrow-end center.
const QPointF l0 = c - n * gap - dir * h;
const QPointF l1 = c - n * (gap + w);
const QPointF l2 = c - n * gap + dir * h;
path.moveTo(l0);
path.quadTo(l1, l2);
const QPointF r0 = c + n * gap - dir * h;
const QPointF r1 = c + n * (gap + w);
const QPointF r2 = c + n * gap + dir * h;
path.moveTo(r0);
path.quadTo(r1, r2);
}

View file

@ -19,6 +19,7 @@ public:
BlockItem::Port port = BlockItem::Port::Input;
std::optional<QPointF> localPos; // точка на стороне блока (в локальных координатах)
std::optional<QPointF> scenePos; // произвольная точка на сцене (например, граница)
bool tunneled = false;
};
ArrowItem(QGraphicsItem* parent = nullptr);
@ -66,6 +67,10 @@ public:
bool isCallMechanism() const { return m_isCallMechanism; }
void setCallRefId(int id) { m_callRefId = id; }
int callRefId() const { return m_callRefId; }
void setFromTunneled(bool v);
void setToTunneled(bool v);
bool isFromTunneled() const { return m_from.tunneled; }
bool isToTunneled() const { return m_to.tunneled; }
void updatePath();
std::optional<QPointF> hitTest(const QPointF& scenePos, qreal radius) const;
@ -104,6 +109,7 @@ private:
QPointF m_lastDragScenePos;
void drawArrowHead(QPainterPath& path, const QPointF& tip, const QPointF& from);
void drawTunnelBrackets(QPainterPath& path, const QPointF& tip, const QPointF& from);
QVector<QPointF> computePolyline() const;
static QPointF portDirection(const Endpoint& ep, bool isFrom, const QPointF& fallbackDir, const QGraphicsScene* scene);
static QVector<QPointF> simplifyPolyline(const QVector<QPointF>& pts);
@ -111,6 +117,8 @@ private:
void updateLabelItem(const QVector<QPointF>& pts);
protected:
QRectF boundingRect() const override;
QPainterPath shape() const override;
void hoverMoveEvent(QGraphicsSceneHoverEvent* e) override;
void mousePressEvent(QGraphicsSceneMouseEvent* e) override;
void mouseMoveEvent(QGraphicsSceneMouseEvent* e) override;

View file

@ -12,6 +12,7 @@
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QLineEdit>
#include <QPlainTextEdit>
#include <QCheckBox>
#include <QVBoxLayout>
#include <QLocale>
@ -285,7 +286,10 @@ void BlockItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent* e) {
auto* layout = new QVBoxLayout(dlg);
auto* form = new QFormLayout();
auto* titleEdit = new QLineEdit(m_title, dlg);
auto* titleEdit = new QPlainTextEdit(dlg);
titleEdit->setPlainText(m_title);
titleEdit->setTabChangesFocus(true);
titleEdit->setMinimumHeight(72);
form->addRow(tr("Title:"), titleEdit);
auto affixes = currencyAffixes();
@ -312,8 +316,10 @@ void BlockItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent* e) {
bool changed = false;
if (dlg->exec() == QDialog::Accepted) {
if (m_title != titleEdit->text()) {
setTitle(titleEdit->text());
QString newTitle = titleEdit->toPlainText();
newTitle.replace('\r', "");
if (m_title != newTitle) {
setTitle(newTitle);
changed = true;
}
std::optional<qreal> newPrice;

View file

@ -298,6 +298,14 @@ bool DiagramScene::tryStartArrowDrag(const QPointF& scenePos, Qt::KeyboardModifi
bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) {
if (!m_dragArrow) return false;
auto markSubdiagramOriginTunnel = [this](ArrowItem* arrow) {
if (!arrow || m_currentBlockId < 0) return;
auto from = arrow->from();
if (!from.tunneled) {
from.tunneled = true;
arrow->setFrom(from);
}
};
const auto itemsUnder = items(scenePos);
// попадание в интерфейсный круг
@ -357,6 +365,7 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) {
m_dragArrow->setFrom(stubEp);
m_dragArrow->setTo(blockEp);
}
markSubdiagramOriginTunnel(m_dragArrow);
m_dragArrow->setLabelLocked(true);
m_dragArrow->finalize();
}
@ -366,6 +375,7 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) {
to.port = port;
to.localPos = localPos;
m_dragArrow->setTo(to);
markSubdiagramOriginTunnel(m_dragArrow);
m_dragArrow->finalize();
}
@ -398,6 +408,7 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) {
m_dragArrow->setFrom(stubEp);
m_dragArrow->setTo(jun);
}
markSubdiagramOriginTunnel(m_dragArrow);
m_dragArrow->setLabelLocked(true);
m_dragArrow->finalize();
}
@ -406,6 +417,7 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) {
to.junction = j;
to.port = BlockItem::Port::Input;
m_dragArrow->setTo(to);
markSubdiagramOriginTunnel(m_dragArrow);
m_dragArrow->finalize();
}
@ -462,6 +474,7 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) {
m_dragArrow->setLabelHidden(hideInherited);
m_dragArrow->setLabelInherited(true);
m_dragArrow->setLabelSource(sourceRoot);
markSubdiagramOriginTunnel(m_dragArrow);
m_dragArrow->finalize();
m_dragArrow = nullptr;
@ -485,6 +498,7 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) {
to.scenePos = edgePoint;
to.port = BlockItem::Port::Input;
m_dragArrow->setTo(to);
markSubdiagramOriginTunnel(m_dragArrow);
m_dragArrow->finalize();
m_dragArrow = nullptr;
m_dragFromBlock.clear();
@ -558,6 +572,15 @@ bool DiagramScene::tryBranchAtArrow(const QPointF& scenePos) {
DiagramScene::Snapshot DiagramScene::captureSnapshot() const {
Snapshot s;
QString state = m_meta.value("state").toString();
if (state.isEmpty()) {
if (m_meta.value("publication", false).toBool()) state = "publication";
else if (m_meta.value("recommended", false).toBool()) state = "recommended";
else if (m_meta.value("draft", false).toBool()) state = "draft";
else state = "working";
}
s.state = state;
s.hasState = true;
QSet<BlockItem*> blockSet;
QSet<JunctionItem*> junctionSet;
@ -582,6 +605,7 @@ DiagramScene::Snapshot DiagramScene::captureSnapshot() const {
out.scenePos = *ep.scenePos;
out.port = ep.port;
}
out.tunneled = ep.tunneled;
return out;
};
@ -699,6 +723,7 @@ for (const auto& j : snap.junctions) {
case Snapshot::Endpoint::Kind::None:
break;
}
out.tunneled = ep.tunneled;
return out;
};
@ -733,7 +758,20 @@ for (const auto& j : snap.junctions) {
ar->finalize();
}
if (snap.hasState) {
const QString state = snap.state.isEmpty() ? QStringLiteral("working") : snap.state;
m_meta["state"] = state;
m_meta["working"] = (state == "working");
m_meta["draft"] = (state == "draft");
m_meta["recommended"] = (state == "recommended");
m_meta["publication"] = (state == "publication");
}
m_restoringSnapshot = false;
if (m_headerFooter) {
m_headerFooter->setMeta(m_meta);
m_headerFooter->update();
}
updateCallMechanismLabels();
if (resetHistoryState) {
resetHistory(captureSnapshot());
@ -833,6 +871,7 @@ bool DiagramScene::goDownIntoSelected() {
bool DiagramScene::goUp() {
if (m_hierarchy.isEmpty()) return false;
const int childBlockId = m_currentBlockId;
Snapshot child = captureSnapshot();
if (m_currentBlockId >= 0) {
m_children[currentPathKey()] = child;
@ -851,6 +890,87 @@ bool DiagramScene::goUp() {
m_currentBlockId = parent.blockId;
m_currentPrefix = parent.prefix;
restoreSnapshot(parent.snapshot);
if (childBlockId >= 0) {
BlockItem* parentBlock = nullptr;
for (QGraphicsItem* it : items()) {
if (auto* blk = qgraphicsitem_cast<BlockItem*>(it)) {
if (blk->id() == childBlockId) {
parentBlock = blk;
break;
}
}
}
if (parentBlock) {
const QRectF frame = m_contentRect.isNull() ? sceneRect() : m_contentRect;
auto normClamp = [](qreal v) { return std::clamp(v, 0.0, 1.0); };
auto frameEdgePoint = [&](BlockItem::Port port, qreal t) {
switch (port) {
case BlockItem::Port::Input: return QPointF(frame.left(), frame.top() + t * frame.height());
case BlockItem::Port::Output: return QPointF(frame.right(), frame.top() + t * frame.height());
case BlockItem::Port::Control: return QPointF(frame.left() + t * frame.width(), frame.top());
case BlockItem::Port::Mechanism: return QPointF(frame.left() + t * frame.width(), frame.bottom());
}
return QPointF();
};
auto edgeKey = [](const BlockItem::Port port, const QPointF& p) {
return QStringLiteral("%1:%2:%3").arg(int(port)).arg(p.x(), 0, 'f', 1).arg(p.y(), 0, 'f', 1);
};
auto labelKey = [](const BlockItem::Port port, const QString& lbl) {
return QStringLiteral("%1:%2").arg(int(port)).arg(lbl);
};
QSet<QString> tunneledEdgeKeys;
QSet<QString> tunneledLabelKeys;
for (const auto& ar : child.arrows) {
if (!(ar.isInterface && ar.isInterfaceStub)) continue;
if (!ar.interfaceEdge.tunneled) continue;
tunneledEdgeKeys.insert(edgeKey(ar.interfaceEdge.port, ar.interfaceEdge.scenePos.value_or(QPointF())));
if (!ar.label.isEmpty()) tunneledLabelKeys.insert(labelKey(ar.interfaceEdge.port, ar.label));
}
const QRectF br = parentBlock->mapRectToScene(parentBlock->boundingRect());
auto tY = [&](const QPointF& p) { return normClamp((p.y() - br.top()) / br.height()); };
auto tX = [&](const QPointF& p) { return normClamp((p.x() - br.left()) / br.width()); };
auto scenePosFor = [](const ArrowItem::Endpoint& ep) {
if (ep.block) {
if (ep.localPos) return ep.block->mapToScene(*ep.localPos);
return ep.block->portScenePos(ep.port);
}
if (ep.junction) return ep.junction->scenePos();
if (ep.scenePos) return *ep.scenePos;
return QPointF();
};
for (QGraphicsItem* it : items()) {
auto* a = qgraphicsitem_cast<ArrowItem*>(it);
if (!a) continue;
auto f = a->from();
auto t = a->to();
bool changed = false;
if (t.block == parentBlock) {
const QPointF portPos = scenePosFor(t);
const qreal tt = (t.port == BlockItem::Port::Control || t.port == BlockItem::Port::Mechanism) ? tX(portPos) : tY(portPos);
const QPointF edgeScene = frameEdgePoint(t.port, tt);
const bool tun = tunneledEdgeKeys.contains(edgeKey(t.port, edgeScene)) ||
(!a->label().isEmpty() && tunneledLabelKeys.contains(labelKey(t.port, a->label())));
if (t.tunneled != tun) { t.tunneled = tun; changed = true; }
}
if (f.block == parentBlock) {
const QPointF portPos = scenePosFor(f);
const qreal tt = (f.port == BlockItem::Port::Control || f.port == BlockItem::Port::Mechanism) ? tX(portPos) : tY(portPos);
const QPointF edgeScene = frameEdgePoint(f.port, tt);
const bool tun = tunneledEdgeKeys.contains(edgeKey(f.port, edgeScene)) ||
(!a->label().isEmpty() && tunneledLabelKeys.contains(labelKey(f.port, a->label())));
if (f.tunneled != tun) { f.tunneled = tun; changed = true; }
}
if (changed) {
a->setFrom(f);
a->setTo(t);
a->finalize();
}
}
}
}
return true;
}
@ -870,6 +990,16 @@ bool DiagramScene::goDownIntoBlock(BlockItem* b) {
if (!(ar.isInterface && ar.isInterfaceStub)) preserved.push_back(ar);
}
child.arrows = std::move(preserved);
} else {
QString state = m_meta.value("state").toString();
if (state.isEmpty()) {
if (m_meta.value("publication", false).toBool()) state = "publication";
else if (m_meta.value("recommended", false).toBool()) state = "recommended";
else if (m_meta.value("draft", false).toBool()) state = "draft";
else state = "working";
}
child.state = state;
child.hasState = true;
}
// build interface stubs from current parent connections
@ -951,11 +1081,14 @@ bool DiagramScene::goDownIntoBlock(BlockItem* b) {
Snapshot::Arrow ar;
ar.from = makeSceneEndpoint(edgeScene, t.port);
ar.to = ar.from;
ar.label = a->label();
ar.label = t.tunneled ? QString() : a->label();
ar.isInterface = true;
ar.isInterfaceStub = true;
ar.labelLocked = true;
ar.interfaceEdge = ar.from;
ar.from.tunneled = t.tunneled;
ar.to.tunneled = t.tunneled;
ar.interfaceEdge.tunneled = t.tunneled;
const QString key = ar.label.isEmpty() ? edgeKey(ar.interfaceEdge) : labelKey(ar);
if (!usedInterfaces.contains(key)) {
usedInterfaces.insert(key);
@ -968,11 +1101,14 @@ bool DiagramScene::goDownIntoBlock(BlockItem* b) {
Snapshot::Arrow ar;
ar.from = makeSceneEndpoint(edgeScene, f.port);
ar.to = ar.from;
ar.label = a->label();
ar.label = f.tunneled ? QString() : a->label();
ar.isInterface = true;
ar.isInterfaceStub = true;
ar.labelLocked = true;
ar.interfaceEdge = ar.to;
ar.from.tunneled = f.tunneled;
ar.to.tunneled = f.tunneled;
ar.interfaceEdge.tunneled = f.tunneled;
const QString key = ar.label.isEmpty() ? edgeKey(ar.interfaceEdge) : labelKey(ar);
if (!usedInterfaces.contains(key)) {
usedInterfaces.insert(key);
@ -1223,7 +1359,23 @@ void DiagramScene::deleteSelection() {
}
for (ArrowItem* a : arrowsToDelete) {
if (a->isInterface()) {
a->resetInterfaceStub();
if (a->isInterfaceStub()) {
auto edge = a->interfaceEdge();
if (edge) {
edge->tunneled = true;
a->setInterfaceStub(*edge, QString());
a->setLabelHidden(true);
a->setLabelInherited(false);
a->setLabelSource(nullptr);
a->setInterfaceIsStub(true);
a->setLabelLocked(true);
a->finalize();
} else {
a->resetInterfaceStub();
}
} else {
a->resetInterfaceStub();
}
} else {
delete a;
}
@ -1357,6 +1509,7 @@ QJsonObject endpointToJson(const DiagramScene::Snapshot::Endpoint& ep) {
o["kind"] = int(ep.kind);
o["id"] = ep.id;
o["port"] = int(ep.port);
o["tunneled"] = ep.tunneled;
if (ep.localPos) {
o["localPos"] = QJsonArray{ep.localPos->x(), ep.localPos->y()};
}
@ -1371,6 +1524,7 @@ DiagramScene::Snapshot::Endpoint endpointFromJson(const QJsonObject& o) {
ep.kind = DiagramScene::Snapshot::Endpoint::Kind(o.value("kind").toInt());
ep.id = o.value("id").toInt(-1);
ep.port = BlockItem::Port(o.value("port").toInt(int(BlockItem::Port::Input)));
ep.tunneled = o.value("tunneled").toBool(false);
if (o.contains("localPos")) {
const auto arr = o.value("localPos").toArray();
if (arr.size() == 2) ep.localPos = QPointF(arr[0].toDouble(), arr[1].toDouble());
@ -1430,6 +1584,7 @@ QVariantMap DiagramScene::exportToVariant() {
arrows.append(o);
}
root["arrows"] = arrows;
if (snap.hasState) root["state"] = snap.state;
return root.toVariantMap();
};
@ -1454,10 +1609,27 @@ QVariantMap DiagramScene::exportToVariant() {
}
bool DiagramScene::importFromVariant(const QVariantMap& map) {
auto toSnap = [](const QVariantMap& vm, DiagramScene* self) -> std::optional<Snapshot> {
const QVariantMap rootMeta = map.value("meta").toMap();
auto toSnap = [&rootMeta](const QVariantMap& vm, DiagramScene* self) -> std::optional<Snapshot> {
QJsonObject root = QJsonObject::fromVariantMap(vm);
if (!root.contains("blocks") || !root.contains("arrows")) return std::nullopt;
Snapshot snap;
if (root.contains("state")) {
snap.state = root.value("state").toString();
snap.hasState = true;
} else {
QString fallback = rootMeta.value("state").toString();
if (fallback.isEmpty()) {
if (rootMeta.value("publication", false).toBool()) fallback = "publication";
else if (rootMeta.value("recommended", false).toBool()) fallback = "recommended";
else if (rootMeta.value("draft", false).toBool()) fallback = "draft";
else if (rootMeta.value("working", false).toBool()) fallback = "working";
}
if (!fallback.isEmpty()) {
snap.state = fallback;
snap.hasState = true;
}
}
for (const auto& vb : root.value("blocks").toArray()) {
const auto o = vb.toObject();
const auto posArr = o.value("pos").toArray();

View file

@ -51,6 +51,7 @@ private:
BlockItem::Port port = BlockItem::Port::Input;
std::optional<QPointF> localPos;
std::optional<QPointF> scenePos;
bool tunneled = false;
};
struct Block { int id; QString title; QPointF pos; bool hasDecomp = false; QString number; std::optional<qreal> price; std::optional<QString> color; };
struct Junction { int id; QPointF pos; };
@ -72,6 +73,8 @@ private:
bool callMechanism = false;
int callRefId = -1;
};
QString state;
bool hasState = false;
QVector<Block> blocks;
QVector<Junction> junctions;
QVector<Arrow> arrows;

View file

@ -34,18 +34,51 @@ public:
int main(int argc, char** argv) {
IdefApplication app(argc, argv);
const QLocale systemLocale = QLocale::system();
QLocale::setDefault(systemLocale);
QTranslator translator;
const QLocale locale;
const QString baseName = QStringLiteral("idef0_%1").arg(locale.name());
QStringList candidates;
auto addCandidate = [&](const QString& c) {
if (c.isEmpty()) return;
const QString normalized = QString(c).replace('-', '_');
if (normalized == QStringLiteral("C") || normalized == QStringLiteral("POSIX")) return;
candidates << normalized;
const QString shortName = normalized.section('_', 0, 0);
if (!shortName.isEmpty()) candidates << shortName;
};
addCandidate(QLocale().name());
addCandidate(systemLocale.name());
for (const QString& lang : systemLocale.uiLanguages()) {
addCandidate(lang);
}
addCandidate(QString::fromLocal8Bit(qgetenv("LANG")).section('.', 0, 0));
candidates.removeDuplicates();
if (candidates.isEmpty()) candidates << QStringLiteral("en");
const QStringList searchPaths = {
QDir::currentPath() + "/translations",
QCoreApplication::applicationDirPath() + "/../share/idef0/translations",
":/i18n"
};
for (const QString& path : searchPaths) {
if (translator.load(baseName, path)) {
app.installTranslator(&translator);
break;
bool loaded = false;
for (const QString& loc : candidates) {
const QString baseName = QStringLiteral("idef0_%1").arg(loc);
for (const QString& path : searchPaths) {
if (translator.load(baseName, path)) {
app.installTranslator(&translator);
loaded = true;
break;
}
}
if (loaded) break;
}
if (!loaded) {
for (const QString& path : searchPaths) {
if (translator.load(QStringLiteral("idef0_en"), path)) {
app.installTranslator(&translator);
break;
}
}
}
@ -62,7 +95,7 @@ int main(int argc, char** argv) {
app.fileOpenHandler = [&w](const QString& path) {
w.openDiagramPath(path);
};
w.resize(1200, 800);
w.resize(1150, 850);
w.show();
return app.exec();
}

View file

@ -19,7 +19,9 @@ static void ensureTranslator(Idef0Host* host) {
QStringList candidates;
auto addCandidate = [&](const QString& c) {
if (!c.isEmpty() && !c.contains("C")) candidates << c;
if (c.isEmpty()) return;
if (c == QStringLiteral("C") || c == QStringLiteral("POSIX")) return;
candidates << c;
};
addCandidate(baseLocale);
addCandidate(shortLocale);