diff --git a/CMakeLists.txt b/CMakeLists.txt index ddff53a..70583ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,8 +15,39 @@ qt_add_executable(idef0_editor src/items/BlockItem.h src/items/BlockItem.cpp src/items/ArrowItem.h src/items/ArrowItem.cpp src/items/JunctionItem.h src/items/JunctionItem.cpp + src/items/HeaderFooterItem.h src/items/HeaderFooterItem.cpp ) +if(APPLE) + set(MACOS_BUNDLE_ID "com.gregorybednov.idef0-editor") + set(MACOS_BUNDLE_NAME "IDEF0 Editor") + configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/Info.plist.in + ${CMAKE_CURRENT_BINARY_DIR}/Info.plist + @ONLY + ) + set_target_properties(idef0_editor PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME "${MACOS_BUNDLE_NAME}" + MACOSX_BUNDLE_GUI_IDENTIFIER "${MACOS_BUNDLE_ID}" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/Info.plist" + ) +endif() + +target_compile_definitions(idef0_editor PRIVATE $<$>:QT_NO_DEBUG_OUTPUT>) + target_include_directories(idef0_editor PRIVATE src) target_link_libraries(idef0_editor PRIVATE Qt6::Widgets) + +install(TARGETS idef0_editor + BUNDLE DESTINATION . + RUNTIME DESTINATION bin +) + +if(UNIX AND NOT APPLE) + install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/packaging/linux/idef0-editor.desktop + DESTINATION share/applications) + install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/packaging/linux/idef0.xml + DESTINATION share/mime/packages) +endif() diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index acd10e1..6934a7f 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -5,35 +5,825 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "items/BlockItem.h" -MainWindow::MainWindow(QWidget* parent) +static const char* kDiagramFileFilter = "IDEF0 Diagram (*.idef0);;JSON Diagram (*.json)"; +static const char* kPdfFileFilter = "PDF (*.pdf)"; + +static QPageSize::PageSizeId pageSizeFromMeta(const QVariantMap& meta) { + const QString size = meta.value("pageSize", "A4").toString(); + if (size == "A1") return QPageSize::A1; + if (size == "A2") return QPageSize::A2; + if (size == "A3") return QPageSize::A3; + return QPageSize::A4; +} + +static QPageLayout::Orientation pageOrientationFromMeta(const QVariantMap& meta) { + const QString orient = meta.value("pageOrientation").toString(); + if (orient == QObject::tr("Portrait") || orient == "Portrait") { + return QPageLayout::Portrait; + } + return QPageLayout::Landscape; +} + +MainWindow::MainWindow(const QString& startupPath, QWidget* parent) : QMainWindow(parent) { setupUi(); setupActions(); - statusBar()->showMessage("RMB: add block. LMB on port: drag arrow. Double click block: rename."); + statusBar()->showMessage(tr("RMB: add block. LMB on port: drag arrow. Double click block: rename.")); + if (!startupPath.isEmpty()) { + if (!loadDiagramFromPath(startupPath)) { + QTimer::singleShot(0, this, &QWidget::close); + return; + } + } else if (!promptStartup()) { + QTimer::singleShot(0, this, &QWidget::close); + return; + } + markDirty(false); } void MainWindow::setupUi() { - m_scene = new DiagramScene(this); + if (!m_scene) { + 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; + markDirty(true); + }); + m_scene->applyMeta(m_welcomeState); + } m_view = new QGraphicsView(m_scene, this); m_view->setRenderHint(QPainter::Antialiasing, true); m_view->setDragMode(QGraphicsView::RubberBandDrag); // Полное обновление избавляет от графических артефактов во время drag временной стрелки. m_view->setViewportUpdateMode(QGraphicsView::FullViewportUpdate); + m_view->viewport()->grabGesture(Qt::PinchGesture); + m_view->viewport()->installEventFilter(this); setCentralWidget(m_view); - - // стартовые блоки - m_scene->createBlockAt(QPointF(-200, -50))->setPos(-300, -150); - m_scene->createBlockAt(QPointF(200, -50))->setPos(200, -150); } void MainWindow::setupActions() { - auto* tb = addToolBar("Tools"); - auto* actFit = new QAction("Fit", this); + auto* fileMenu = menuBar()->addMenu(tr("&File")); + auto* actNew = new QAction(tr("New"), this); + auto* actOpen = new QAction(tr("Open…"), this); + auto* actSave = new QAction(tr("Save"), this); + auto* actSaveAs = new QAction(tr("Save As…"), this); + auto* actExportPdf = new QAction(tr("Export to PDF…"), this); + actNew->setShortcut(QKeySequence::New); + actOpen->setShortcut(QKeySequence::Open); + actSave->setShortcut(QKeySequence::Save); + actSaveAs->setShortcut(QKeySequence::SaveAs); + fileMenu->addAction(actNew); + fileMenu->addAction(actOpen); + fileMenu->addSeparator(); + fileMenu->addAction(actSave); + fileMenu->addAction(actSaveAs); + fileMenu->addSeparator(); + fileMenu->addAction(actExportPdf); + + auto* editMenu = menuBar()->addMenu(tr("&Edit")); + auto* actSwapNums = new QAction(tr("Swap numbers…"), this); + editMenu->addAction(actSwapNums); + + auto* viewMenu = menuBar()->addMenu(tr("&View")); + auto* actFit = new QAction(tr("Fit"), this); + actFit->setShortcut(Qt::Key_F); connect(actFit, &QAction::triggered, this, [this]{ m_view->fitInView(m_scene->itemsBoundingRect().adjusted(-50,-50,50,50), Qt::KeepAspectRatio); }); - tb->addAction(actFit); + viewMenu->addAction(actFit); + + auto* actZoomIn = new QAction(tr("Scale +"), this); + actZoomIn->setShortcut(QKeySequence::ZoomIn); + connect(actZoomIn, &QAction::triggered, this, [this]{ + m_view->scale(1.1, 1.1); + }); + viewMenu->addAction(actZoomIn); + + auto* actZoomOut = new QAction(tr("Scale -"), this); + actZoomOut->setShortcut(QKeySequence::ZoomOut); + connect(actZoomOut, &QAction::triggered, this, [this]{ + m_view->scale(1/1.1, 1/1.1); + }); + viewMenu->addAction(actZoomOut); + + m_actColorfulMode = new QAction(tr("Colorful mode"), this); + m_actColorfulMode->setCheckable(true); + m_actColorfulMode->setChecked(m_welcomeState.value("colorfulMode", false).toBool()); + viewMenu->addSeparator(); + viewMenu->addAction(m_actColorfulMode); + + m_actDarkMode = new QAction(tr("Dark mode"), this); + m_actDarkMode->setCheckable(true); + m_actDarkMode->setChecked(m_welcomeState.value("darkMode", false).toBool()); + viewMenu->addAction(m_actDarkMode); + + m_actFollowSystemTheme = new QAction(tr("Follow system theme"), this); + m_actFollowSystemTheme->setCheckable(true); + m_actFollowSystemTheme->setChecked(m_welcomeState.value("followSystemTheme", false).toBool()); + viewMenu->addAction(m_actFollowSystemTheme); + + m_actArrowColor = new QAction(tr("Arrow color..."), this); + m_actBorderColor = new QAction(tr("Block border color..."), this); + m_actFontColor = new QAction(tr("Block font color..."), this); + viewMenu->addAction(m_actArrowColor); + viewMenu->addAction(m_actBorderColor); + viewMenu->addAction(m_actFontColor); + + auto ensureDefaultColor = [this](const char* key, const QColor& fallback) { + QColor c(m_welcomeState.value(key).toString()); + if (!c.isValid()) m_welcomeState[key] = fallback.name(); + }; + ensureDefaultColor("arrowColor", QColor("#2b6ee6")); + ensureDefaultColor("blockBorderColor", QColor("#0f766e")); + ensureDefaultColor("blockFontColor", QColor("#991b1b")); + + auto applyModes = [this](bool mark) { + const bool colorful = m_actColorfulMode->isChecked(); + const bool followSystem = m_actFollowSystemTheme->isChecked(); + const bool dark = m_actDarkMode->isChecked(); + m_welcomeState["colorfulMode"] = colorful; + m_welcomeState["followSystemTheme"] = followSystem; + m_welcomeState["darkMode"] = dark; + m_welcomeState["resolvedDarkMode"] = effectiveDarkMode(); + + m_actArrowColor->setEnabled(colorful); + m_actBorderColor->setEnabled(colorful); + m_actFontColor->setEnabled(colorful); + m_actDarkMode->setEnabled(!followSystem); + + applyAppPalette(m_welcomeState.value("resolvedDarkMode").toBool()); + m_scene->applyMeta(m_welcomeState); + if (mark) markDirty(true); + }; + + connect(m_actColorfulMode, &QAction::toggled, this, [applyModes]{ applyModes(true); }); + connect(m_actDarkMode, &QAction::toggled, this, [applyModes]{ applyModes(true); }); + connect(m_actFollowSystemTheme, &QAction::toggled, this, [applyModes]{ applyModes(true); }); + connect(qApp->styleHints(), &QStyleHints::colorSchemeChanged, this, [applyModes](Qt::ColorScheme){ + applyModes(false); + }); + connect(m_actArrowColor, &QAction::triggered, this, [this, applyModes]{ + const QColor cur(m_welcomeState.value("arrowColor").toString()); + const QColor chosen = QColorDialog::getColor(cur.isValid() ? cur : QColor("#2b6ee6"), this, tr("Arrow color")); + if (!chosen.isValid()) return; + m_welcomeState["arrowColor"] = chosen.name(); + applyModes(true); + }); + connect(m_actBorderColor, &QAction::triggered, this, [this, applyModes]{ + const QColor cur(m_welcomeState.value("blockBorderColor").toString()); + const QColor chosen = QColorDialog::getColor(cur.isValid() ? cur : QColor("#0f766e"), this, tr("Block border color")); + if (!chosen.isValid()) return; + m_welcomeState["blockBorderColor"] = chosen.name(); + applyModes(true); + }); + connect(m_actFontColor, &QAction::triggered, this, [this, applyModes]{ + const QColor cur(m_welcomeState.value("blockFontColor").toString()); + const QColor chosen = QColorDialog::getColor(cur.isValid() ? cur : QColor("#991b1b"), this, tr("Block font color")); + if (!chosen.isValid()) return; + m_welcomeState["blockFontColor"] = chosen.name(); + applyModes(true); + }); + applyModes(false); + + connect(actSwapNums, &QAction::triggered, this, [this]{ + const auto sel = m_scene->selectedItems(); + if (sel.size() != 1) { + QMessageBox::information(this, tr("Swap numbers"), tr("Select a single block to swap its number.")); + return; + } + auto* blk = qgraphicsitem_cast(sel.first()); + if (!blk) { + QMessageBox::information(this, tr("Swap numbers"), tr("Select a block.")); + return; + } + QStringList choices; + QHash lookup; + for (QGraphicsItem* it : m_scene->items()) { + auto* other = qgraphicsitem_cast(it); + if (!other || other == blk) continue; + choices << other->number(); + lookup.insert(other->number(), other); + } + choices.removeAll(QString()); + if (choices.isEmpty()) { + QMessageBox::information(this, tr("Swap numbers"), tr("No other blocks to swap with.")); + return; + } + bool ok = false; + const QString choice = QInputDialog::getItem(this, tr("Swap numbers"), tr("Swap with:"), choices, 0, false, &ok); + if (!ok || choice.isEmpty()) return; + BlockItem* other = lookup.value(choice, nullptr); + if (!other) return; + const QString numA = blk->number(); + const QString numB = other->number(); + blk->setNumber(numB); + other->setNumber(numA); + m_scene->requestSnapshot(); + markDirty(true); + }); + + connect(actNew, &QAction::triggered, this, [this]{ newDiagram(); }); + connect(actOpen, &QAction::triggered, this, [this]{ + const QString path = QFileDialog::getOpenFileName(this, tr("Open diagram"), QString(), tr(kDiagramFileFilter)); + if (!path.isEmpty()) { + loadDiagramFromPath(path); + } + }); + + auto saveTo = [this](const QString& path) { + QString outPath = path; + if (QFileInfo(outPath).suffix().isEmpty()) { + outPath += ".idef0"; + } + const QVariantMap exported = m_scene->exportToVariant(); + QVariantMap root; + root["diagram"] = exported.value("diagram"); + root["children"] = exported.value("children"); + root["meta"] = m_welcomeState; + QJsonDocument doc = QJsonDocument::fromVariant(root); + QFile f(outPath); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + QMessageBox::warning(this, tr("Save failed"), tr("Could not open file for writing.")); + return false; + } + f.write(doc.toJson(QJsonDocument::Indented)); + f.close(); + m_currentFile = outPath; + markDirty(false); + return true; + }; + + connect(actSave, &QAction::triggered, this, [this, saveTo]{ + if (m_currentFile.isEmpty()) { + const QString path = QFileDialog::getSaveFileName(this, tr("Save diagram"), QString(), tr(kDiagramFileFilter)); + if (!path.isEmpty()) saveTo(path); + } else { + saveTo(m_currentFile); + } + }); + connect(actSaveAs, &QAction::triggered, this, [this, saveTo]{ + const QString path = QFileDialog::getSaveFileName(this, tr("Save diagram as"), QString(), tr(kDiagramFileFilter)); + if (!path.isEmpty()) saveTo(path); + }); + connect(actExportPdf, &QAction::triggered, this, [this]{ + QDialog dlg(this); + dlg.setWindowTitle(tr("Export to PDF")); + auto* layout = new QVBoxLayout(&dlg); + auto* form = new QFormLayout(); + auto* scope = new QComboBox(&dlg); + scope->addItems({tr("Current diagram"), tr("All diagrams")}); + auto* pageNumbers = new QCheckBox(tr("Number pages in footer (NUMBER field)"), &dlg); + pageNumbers->setChecked(false); + form->addRow(tr("Export scope:"), scope); + form->addRow(QString(), pageNumbers); + 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()); + }); +} + +static QString currencySymbolForLocale(const QLocale& loc) { + const QString sym = loc.currencySymbol(QLocale::CurrencySymbol); + if (!sym.isEmpty()) return sym; + const auto terr = loc.territory(); + if (terr == QLocale::Russia || terr == QLocale::RussianFederation) return QString::fromUtf8("\u20BD"); + return "$"; +} + +static QString symbolPlacementDefault(const QLocale& loc, const QString& sym) { + const QString sample = loc.toCurrencyString(123.0); + if (sample.startsWith(sym)) { + const QString rest = sample.mid(sym.size()); + return rest.startsWith(' ') ? "? 1" : "?1"; + } + if (sample.endsWith(sym)) { + const QString rest = sample.left(sample.size() - sym.size()); + return rest.endsWith(' ') ? "1 ?" : "1?"; + } + return "?1"; +} + +bool MainWindow::showWelcome() { + auto* dlg = new QDialog(this); + dlg->setWindowTitle(tr("Welcome")); + auto* tabs = new QTabWidget(dlg); + + // General tab + auto* general = new QWidget(dlg); + auto* genForm = new QFormLayout(general); + auto* author = new QLineEdit(general); + author->setPlaceholderText(tr("John Doe")); + author->setText(m_welcomeState.value("author").toString()); + auto* title = new QLineEdit(general); + title->setPlaceholderText(tr("Main process")); + title->setText(m_welcomeState.value("title").toString()); + auto* organization = new QLineEdit(general); + organization->setPlaceholderText(tr("Example.Org Inc.")); + organization->setText(m_welcomeState.value("organization").toString()); + auto* initials = new QLineEdit(general); + initials->setPlaceholderText(tr("JD")); + initials->setText(m_welcomeState.value("initials").toString()); + autoInitialsFromAuthor(author, initials); + connect(author, &QLineEdit::textChanged, this, [this, author, initials]{ + autoInitialsFromAuthor(author, initials); + }); + genForm->addRow(tr("Title:"), title); + genForm->addRow(tr("Organization:"), organization); + genForm->addRow(tr("Author:"), author); + genForm->addRow(tr("Author's initials:"), initials); + general->setLayout(genForm); + tabs->addTab(general, tr("General")); + + // Numbering tab (placeholder) + auto* numbering = new QWidget(dlg); + auto* numLayout = new QVBoxLayout(numbering); + numLayout->addStretch(); + numbering->setLayout(numLayout); + tabs->addTab(numbering, tr("Numbering")); + + // Display tab (placeholder) + auto* display = new QWidget(dlg); + auto* disLayout = new QVBoxLayout(display); + disLayout->addStretch(); + display->setLayout(disLayout); + tabs->addTab(display, tr("Display")); + + // ABC Units tab + auto* units = new QWidget(dlg); + auto* unitsForm = new QFormLayout(units); + const QLocale loc; + const auto terr = loc.territory(); + const QString curSym = (terr == QLocale::Russia || terr == QLocale::RussianFederation) + ? QString::fromUtf8("\u20BD") + : currencySymbolForLocale(loc); + auto* currency = new QComboBox(units); + currency->setEditable(false); + currency->addItem(curSym); + if (curSym != "$") currency->addItem("$"); + const QString existingCur = m_welcomeState.value("currency").toString(); + if (!existingCur.isEmpty() && currency->findText(existingCur) >= 0) { + currency->setCurrentText(existingCur); + } else { + currency->setCurrentIndex(0); + } + + auto* placement = new QComboBox(units); + placement->addItems({"1?", "?1", "1 ?", "? 1"}); + const QString placementVal = m_welcomeState.value("currencyPlacement").toString(); + if (!placementVal.isEmpty() && placement->findText(placementVal) >= 0) { + placement->setCurrentText(placementVal); + } else { + placement->setCurrentText(symbolPlacementDefault(loc, currency->currentText())); + } + + auto* timeUnit = new QComboBox(units); + const QStringList unitsList = {tr("Seconds"), tr("Minutes"), tr("Hours"), tr("Days"), tr("Weeks"), tr("Months"), tr("Years")}; + timeUnit->addItems(unitsList); + const QString timeVal = m_welcomeState.value("timeUnit").toString(); + timeUnit->setCurrentText(timeVal.isEmpty() ? tr("Days") : timeVal); + + unitsForm->addRow(tr("Currency:"), currency); + unitsForm->addRow(tr("Symbol placement:"), placement); + unitsForm->addRow(tr("Time Unit:"), timeUnit); + units->setLayout(unitsForm); + tabs->addTab(units, tr("ABC Units")); + + // Page Setup tab + auto* page = new QWidget(dlg); + auto* pageForm = new QFormLayout(page); + auto* sizeCombo = new QComboBox(page); + sizeCombo->addItems({"A4","A3","A2","A1"}); + const QString sizeVal = m_welcomeState.value("pageSize").toString(); + sizeCombo->setCurrentText(sizeVal.isEmpty() ? "A4" : sizeVal); + + auto* orientCombo = new QComboBox(page); + orientCombo->addItems({tr("Landscape"),tr("Portrait")}); + const QString orientVal = m_welcomeState.value("pageOrientation").toString(); + orientCombo->setCurrentText(orientVal.isEmpty() ? tr("Landscape") : orientVal); + + auto* headerChk = new QCheckBox(tr("With header"), page); + headerChk->setChecked(m_welcomeState.value("withHeader", true).toBool()); + auto* footerChk = new QCheckBox(tr("With footer"), page); + footerChk->setChecked(m_welcomeState.value("withFooter", true).toBool()); + + pageForm->addRow(tr("Size:"), sizeCombo); + pageForm->addRow(tr("Type:"), orientCombo); + pageForm->addRow(headerChk); + pageForm->addRow(footerChk); + page->setLayout(pageForm); + tabs->addTab(page, tr("Page Setup")); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dlg); + connect(buttons, &QDialogButtonBox::accepted, dlg, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, dlg, &QDialog::reject); + + auto* layout = new QVBoxLayout(dlg); + layout->addWidget(tabs); + layout->addWidget(buttons); + dlg->setLayout(layout); + + if (dlg->exec() != QDialog::Accepted) { + dlg->deleteLater(); + return false; + } + + m_welcomeDialog = dlg; + m_welcomeState["author"] = author->text(); + m_welcomeState["title"] = title->text(); + m_welcomeState["organization"] = organization->text(); + m_welcomeState["initials"] = initials->text(); + m_welcomeState["currency"] = currency->currentText(); + m_welcomeState["currencyPlacement"] = placement->currentText(); + m_welcomeState["timeUnit"] = timeUnit->currentText(); + m_welcomeState["pageSize"] = sizeCombo->currentText(); + m_welcomeState["pageOrientation"] = orientCombo->currentText(); + m_welcomeState["withHeader"] = headerChk->isChecked(); + m_welcomeState["withFooter"] = footerChk->isChecked(); + + dlg->deleteLater(); + return true; +} + +void MainWindow::resetScene() { + auto* old = m_scene; + 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; + markDirty(true); + }); + m_scene->applyMeta(m_welcomeState); + if (m_view) m_view->setScene(m_scene); + if (old) old->deleteLater(); +} + +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); + m_currentFile.clear(); + markDirty(false); +} + +bool MainWindow::promptStartup() { + QMessageBox box(this); + box.setWindowTitle(tr("Start")); + box.setText(tr("Start a new diagram or open an existing one?")); + QPushButton* newBtn = box.addButton(tr("New"), QMessageBox::AcceptRole); + QPushButton* openBtn = box.addButton(tr("Open…"), QMessageBox::ActionRole); + QPushButton* cancelBtn = box.addButton(QMessageBox::Cancel); + box.exec(); + if (box.clickedButton() == cancelBtn) return false; + if (box.clickedButton() == openBtn) { + const QString path = QFileDialog::getOpenFileName(this, tr("Open diagram"), QString(), tr(kDiagramFileFilter)); + if (path.isEmpty()) return false; + return loadDiagramFromPath(path); + } + if (!showWelcome()) return false; + resetScene(); + m_scene->createBlockAt(QPointF(-100, -50)); + m_currentFile.clear(); + markDirty(false); + return true; +} + +bool MainWindow::loadDiagramFromPath(const QString& path) { + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + QMessageBox::warning(this, tr("Open failed"), tr("Could not open file for reading.")); + return false; + } + const auto doc = QJsonDocument::fromJson(f.readAll()); + f.close(); + if (!doc.isObject()) { + QMessageBox::warning(this, tr("Open failed"), tr("File format is invalid.")); + return false; + } + + const QVariantMap root = doc.object().toVariantMap(); + resetScene(); + if (!m_scene->importFromVariant(root)) { + QMessageBox::warning(this, tr("Open failed"), tr("Diagram data missing or corrupted.")); + return false; + } + if (root.contains("meta")) { + m_welcomeState = root.value("meta").toMap(); + } + if (m_actColorfulMode && m_actDarkMode && m_actFollowSystemTheme) { + const QSignalBlocker b1(m_actColorfulMode); + const QSignalBlocker b2(m_actDarkMode); + const QSignalBlocker b3(m_actFollowSystemTheme); + m_actColorfulMode->setChecked(m_welcomeState.value("colorfulMode", false).toBool()); + m_actDarkMode->setChecked(m_welcomeState.value("darkMode", false).toBool()); + m_actFollowSystemTheme->setChecked(m_welcomeState.value("followSystemTheme", false).toBool()); + m_actDarkMode->setEnabled(!m_actFollowSystemTheme->isChecked()); + } + if (m_actArrowColor && m_actBorderColor && m_actFontColor) { + const bool colorful = m_welcomeState.value("colorfulMode", false).toBool(); + m_actArrowColor->setEnabled(colorful); + m_actBorderColor->setEnabled(colorful); + m_actFontColor->setEnabled(colorful); + } + m_welcomeState["resolvedDarkMode"] = effectiveDarkMode(); + applyAppPalette(m_welcomeState.value("resolvedDarkMode").toBool()); + m_scene->applyMeta(m_welcomeState); + m_currentFile = path; + markDirty(false); + return true; +} + +bool MainWindow::exportPdf(bool allDiagrams, bool numberPages) { + QString path = QFileDialog::getSaveFileName(this, tr("Export to PDF"), QString(), tr(kPdfFileFilter)); + if (path.isEmpty()) return false; + if (QFileInfo(path).suffix().isEmpty()) path += ".pdf"; + + QPdfWriter writer(path); + writer.setPageSize(QPageSize(pageSizeFromMeta(m_welcomeState))); + writer.setPageOrientation(pageOrientationFromMeta(m_welcomeState)); + writer.setResolution(96); + writer.setTitle(tr("IDEF0 Diagram Export")); + + QPainter painter(&writer); + if (!painter.isActive()) { + QMessageBox::warning(this, tr("Export failed"), tr("Could not initialize PDF painter.")); + return false; + } + const QVariantMap originalMeta = m_scene->meta(); + + int pageNo = 0; + auto renderScene = [&](QGraphicsScene* scene, bool newPage) { + if (newPage) writer.newPage(); + ++pageNo; + if (auto* ds = qobject_cast(scene)) { + QVariantMap meta = ds->meta(); + if (numberPages) { + meta["footerNumberOverride"] = QString::number(pageNo); + } else { + meta.remove("footerNumberOverride"); + } + ds->applyMeta(meta); + } + const QRect target = writer.pageLayout().paintRectPixels(writer.resolution()); + scene->render(&painter, QRectF(target), scene->sceneRect(), Qt::KeepAspectRatio); + }; + + if (!allDiagrams) { + renderScene(m_scene, false); + m_scene->applyMeta(originalMeta); + painter.end(); + return true; + } + + const QVariantMap exported = m_scene->exportToVariant(); + const QVariantMap children = exported.value("children").toMap(); + QVector keys; + QSet visited; + std::function collectKeys; + collectKeys = [&](const QString& prefix, const QVariantMap& diagramMap) { + const QJsonObject root = QJsonObject::fromVariantMap(diagramMap); + const QJsonArray blocks = root.value("blocks").toArray(); + for (const auto& vb : blocks) { + const QJsonObject bo = vb.toObject(); + if (!bo.value("hasDecomp").toBool(false)) continue; // dog-eared: no subdiagram export + const int id = bo.value("id").toInt(-1); + if (id < 0) continue; + const QString key = prefix.isEmpty() ? QString::number(id) : (prefix + "/" + QString::number(id)); + if (!children.contains(key) || visited.contains(key)) continue; + visited.insert(key); + keys.push_back(key); + collectKeys(key, children.value(key).toMap()); + } + }; + collectKeys(QString(), exported.value("diagram").toMap()); + + DiagramScene tempScene; + tempScene.applyMeta(m_welcomeState); + + QVariantMap mapRoot; + mapRoot["diagram"] = exported.value("diagram"); + mapRoot["children"] = children; + bool first = true; + if (tempScene.importFromVariant(mapRoot)) { + tempScene.applyMeta(m_welcomeState); + renderScene(&tempScene, false); + first = false; + } + + for (const QString& key : keys) { + QVariantMap mapOne; + mapOne["diagram"] = children.value(key).toMap(); + + QVariantMap relChildren; + const QString prefix = key + "/"; + for (auto it = children.cbegin(); it != children.cend(); ++it) { + if (it.key().startsWith(prefix)) { + relChildren.insert(it.key().mid(prefix.size()), it.value()); + } + } + mapOne["children"] = relChildren; + + if (!tempScene.importFromVariant(mapOne)) continue; + tempScene.applyMeta(m_welcomeState); + renderScene(&tempScene, !first); + first = false; + } + + m_scene->applyMeta(originalMeta); + painter.end(); + return true; +} + +bool MainWindow::effectiveDarkMode() const { + const bool followSystem = m_welcomeState.value("followSystemTheme", false).toBool(); + if (!followSystem) { + return m_welcomeState.value("darkMode", false).toBool(); + } + const Qt::ColorScheme scheme = qApp->styleHints()->colorScheme(); + if (scheme == Qt::ColorScheme::Dark) return true; + if (scheme == Qt::ColorScheme::Light) return false; + return m_welcomeState.value("darkMode", false).toBool(); +} + +void MainWindow::applyAppPalette(bool darkMode) { + static const QPalette defaultPalette = qApp->palette(); + if (!darkMode) { + qApp->setPalette(defaultPalette); + return; + } + QPalette p; + p.setColor(QPalette::Window, QColor(36, 36, 36)); + p.setColor(QPalette::WindowText, QColor(230, 230, 230)); + p.setColor(QPalette::Base, QColor(26, 26, 26)); + p.setColor(QPalette::AlternateBase, QColor(42, 42, 42)); + p.setColor(QPalette::ToolTipBase, QColor(46, 46, 46)); + p.setColor(QPalette::ToolTipText, QColor(230, 230, 230)); + p.setColor(QPalette::Text, QColor(230, 230, 230)); + p.setColor(QPalette::Button, QColor(46, 46, 46)); + p.setColor(QPalette::ButtonText, QColor(230, 230, 230)); + p.setColor(QPalette::BrightText, QColor(255, 90, 90)); + p.setColor(QPalette::Highlight, QColor(75, 110, 180)); + p.setColor(QPalette::HighlightedText, QColor(255, 255, 255)); + qApp->setPalette(p); +} + +bool MainWindow::openDiagramPath(const QString& path) { + return loadDiagramFromPath(path); +} + +void MainWindow::closeEvent(QCloseEvent* e) { + if (!m_dirty) { + e->accept(); + return; + } + QMessageBox box(this); + box.setWindowTitle(tr("Unsaved changes")); + box.setText(tr("You have unsaved changes. Quit anyway?")); + QPushButton* yes = box.addButton(tr("Yes"), QMessageBox::AcceptRole); + QPushButton* no = box.addButton(tr("No"), QMessageBox::RejectRole); + box.setDefaultButton(no); + box.exec(); + if (box.clickedButton() == yes) { + e->accept(); + } else { + e->ignore(); + } +} + +bool MainWindow::eventFilter(QObject* obj, QEvent* event) { + if (obj == m_view->viewport()) { + if (event->type() == QEvent::Gesture) { + auto* ge = static_cast(event); + if (QGesture* g = ge->gesture(Qt::PinchGesture)) { + auto* pinch = static_cast(g); + if (pinch->state() == Qt::GestureStarted) { + m_pinchHandled = false; + } + if (!m_pinchHandled && pinch->state() == Qt::GestureFinished) { + const qreal scale = pinch->totalScaleFactor(); + const QPointF viewPt = pinch->centerPoint(); + const QPointF scenePt = m_view->mapToScene(viewPt.toPoint()); + if (scale > 1.15) { + const auto itemsUnder = m_scene->items(scenePt); + for (QGraphicsItem* it : itemsUnder) { + if (auto* b = qgraphicsitem_cast(it)) { + if (m_scene->goDownIntoBlock(b)) { + m_pinchHandled = true; + event->accept(); + return true; + } + } + } + } else if (scale < 0.85) { + if (m_scene->goUp()) { + m_pinchHandled = true; + event->accept(); + return true; + } + } + } + } + } else if (event->type() == QEvent::NativeGesture) { + auto* ng = static_cast(event); + if (ng->gestureType() == Qt::ZoomNativeGesture && !m_pinchHandled) { + const qreal delta = ng->value(); + if (std::abs(delta) > 0.25) { + const QPointF scenePt = m_view->mapToScene(ng->position().toPoint()); + if (delta > 0) { + const auto itemsUnder = m_scene->items(scenePt); + for (QGraphicsItem* it : itemsUnder) { + if (auto* b = qgraphicsitem_cast(it)) { + if (m_scene->goDownIntoBlock(b)) { + m_pinchHandled = true; + event->accept(); + return true; + } + } + } + } else if (delta < 0) { + if (m_scene->goUp()) { + m_pinchHandled = true; + event->accept(); + return true; + } + } + } + } + } + } + return QMainWindow::eventFilter(obj, event); +} + +void MainWindow::autoInitialsFromAuthor(QLineEdit* author, QLineEdit* initials) { + if (!author || !initials) return; + const QString currentInit = initials->text(); + if (!initials->isModified() || currentInit.isEmpty()) { + const auto parts = author->text().split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + QString gen; + for (const auto& p : parts) { + gen.append(p.left(1).toUpper()); + } + if (!gen.isEmpty()) { + QSignalBlocker b(initials); + initials->setText(gen); + } + } +} + +void MainWindow::markDirty(bool dirty) { + m_dirty = dirty; + updateWindowTitle(); +} + +void MainWindow::updateWindowTitle() { + QString name = m_currentFile.isEmpty() ? tr("Untitled") : QFileInfo(m_currentFile).fileName(); + if (m_dirty) name += " *"; + setWindowTitle(name + tr(" — IDEF0 editor")); } diff --git a/src/MainWindow.h b/src/MainWindow.h index b48de09..c33f806 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -1,18 +1,50 @@ #pragma once #include +#include +class QLineEdit; +class QCloseEvent; class QGraphicsView; class DiagramScene; +class QDialog; +class QAction; class MainWindow final : public QMainWindow { Q_OBJECT public: - explicit MainWindow(QWidget* parent = nullptr); + explicit MainWindow(const QString& startupPath = QString(), QWidget* parent = nullptr); + bool openDiagramPath(const QString& path); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; private: QGraphicsView* m_view = nullptr; DiagramScene* m_scene = nullptr; + QDialog* m_welcomeDialog = nullptr; + QVariantMap m_welcomeState; + QString m_currentFile; + bool m_dirty = false; + bool m_pinchHandled = false; + QAction* m_actColorfulMode = nullptr; + QAction* m_actFollowSystemTheme = nullptr; + QAction* m_actDarkMode = nullptr; + QAction* m_actArrowColor = nullptr; + QAction* m_actBorderColor = nullptr; + QAction* m_actFontColor = nullptr; void setupUi(); void setupActions(); + bool showWelcome(); + void closeEvent(QCloseEvent* e) override; + void resetScene(); + void newDiagram(); + bool promptStartup(); + bool loadDiagramFromPath(const QString& path); + bool exportPdf(bool allDiagrams, bool numberPages); + bool effectiveDarkMode() const; + void applyAppPalette(bool darkMode); + void autoInitialsFromAuthor(QLineEdit* author, QLineEdit* initials); + void markDirty(bool dirty); + void updateWindowTitle(); }; diff --git a/src/items/ArrowItem.cpp b/src/items/ArrowItem.cpp index f33f23a..7a070aa 100644 --- a/src/items/ArrowItem.cpp +++ b/src/items/ArrowItem.cpp @@ -10,16 +10,32 @@ #include #include #include +#include +#include #include "DiagramScene.h" #include +#include #include #include #include +#include + +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 visited; + while (cur) { + if (visited.contains(cur)) break; + visited.insert(cur); + if (!cur->m_labelSource) return const_cast(cur); + cur = cur->m_labelSource; + } + return const_cast(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(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 ArrowItem::simplifyPolyline(const QVector& ptsIn) { } QVector 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 ArrowItem::hitTest(const QPointF& scenePos, qreal radius) void ArrowItem::updateLabelItem(const QVector& 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& 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 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 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 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(scene())) { + ds->propagateLabelFrom(this); + } } e->accept(); return; diff --git a/src/items/ArrowItem.h b/src/items/ArrowItem.h index 83cf9c6..fc6dd7c 100644 --- a/src/items/ArrowItem.h +++ b/src/items/ArrowItem.h @@ -1,8 +1,10 @@ #pragma once #include #include +#include #include #include +#include #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 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 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 m_lastPolyline; + bool m_isInterface = false; + bool m_interfaceStubOnly = false; + bool m_labelLocked = false; + std::optional m_interfaceEdge; + ArrowItem* m_labelSource = nullptr; + static QColor s_lineColor; + static QColor s_textColor; DragPart m_dragPart = DragPart::None; QPointF m_lastDragScenePos; diff --git a/src/items/BlockItem.cpp b/src/items/BlockItem.cpp index a1b8f5a..bf21752 100644 --- a/src/items/BlockItem.cpp +++ b/src/items/BlockItem.cpp @@ -1,12 +1,30 @@ #include "BlockItem.h" #include "ArrowItem.h" +#include "items/DiagramScene.h" #include #include #include #include #include -#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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(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 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 newPrice; + if (priceToggle->isChecked()) newPrice = priceSpin->value(); + if (m_price != newPrice) { + setPrice(newPrice); + changed = true; + } + if (changed) { + if (auto* ds = qobject_cast(scene())) { + ds->requestSnapshot(); + } + } + } + dlg->deleteLater(); QGraphicsObject::mouseDoubleClickEvent(e); } diff --git a/src/items/BlockItem.h b/src/items/BlockItem.h index b502493..5b8b4b5 100644 --- a/src/items/BlockItem.h +++ b/src/items/BlockItem.h @@ -1,6 +1,8 @@ #pragma once #include #include +#include +#include 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 price() const { return m_price; } + void setPrice(std::optional 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 currencyAffixes(); + QString m_title; + QString m_number; QRectF m_rect; // local rect int m_id; + bool m_hasDecomposition = false; QSet m_arrows; + std::optional 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; }; diff --git a/src/items/DiagramScene.cpp b/src/items/DiagramScene.cpp index 5a8c5dc..e8ad00b 100644 --- a/src/items/DiagramScene.cpp +++ b/src/items/DiagramScene.cpp @@ -2,31 +2,53 @@ #include "items/BlockItem.h" #include "items/ArrowItem.h" #include "items/JunctionItem.h" +#include "items/HeaderFooterItem.h" #include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include DiagramScene::DiagramScene(QObject* parent) : QGraphicsScene(parent) { + m_currentPrefix.clear(); // Ограничиваем рабочую область A4 (210x297 мм) в пикселях при 96 dpi (~793x1122). // По умолчанию используем альбомную ориентацию (ширина больше высоты). const qreal w = 1122; const qreal h = 793; setSceneRect(-w * 0.5, -h * 0.5, w, h); ensureFrame(); + ensureHeaderFooter(); pushSnapshot(); } BlockItem* DiagramScene::createBlockAt(const QPointF& scenePos) { auto* b = new BlockItem("Function", nullptr, m_nextBlockId++); addItem(b); - b->setPos(scenePos - QPointF(100, 50)); // центрируем + b->setNumber(assignNumber(b)); + const QRectF r = m_contentRect.isNull() ? sceneRect() : m_contentRect; + const QPointF desired = scenePos - QPointF(100, 50); // центрируем + const QRectF br = b->boundingRect().translated(desired); + QPointF clamped = desired; + if (br.left() < r.left()) clamped.setX(desired.x() + (r.left() - br.left())); + if (br.right() > r.right()) clamped.setX(clamped.x() - (br.right() - r.right())); + if (br.top() < r.top()) clamped.setY(desired.y() + (r.top() - br.top())); + if (br.bottom() > r.bottom()) clamped.setY(clamped.y() - (br.bottom() - r.bottom())); + b->setPos(clamped); pushSnapshot(); return b; } @@ -36,11 +58,123 @@ BlockItem* DiagramScene::createBlockWithId(const QPointF& scenePos, int id, cons auto* b = new BlockItem(title, nullptr, id); addItem(b); b->setPos(scenePos); + if (b->number().isEmpty()) b->setNumber(assignNumber(b)); return b; } +void DiagramScene::applyMeta(const QVariantMap& meta) { + m_meta = meta; + const bool colorfulMode = meta.value("colorfulMode", false).toBool(); + const bool darkMode = meta.value("resolvedDarkMode", + meta.value("darkMode", false)).toBool(); + + const QColor fg = darkMode ? QColor(235, 235, 235) : QColor(20, 20, 20); + const QColor bg = darkMode ? QColor(26, 26, 26) : QColor(255, 255, 255); + QColor arrowColor = fg; + QColor blockBorderColor = fg; + QColor blockFontColor = fg; + if (colorfulMode) { + const QColor a(meta.value("arrowColor").toString()); + const QColor b(meta.value("blockBorderColor").toString()); + const QColor f(meta.value("blockFontColor").toString()); + if (a.isValid()) arrowColor = a; + if (b.isValid()) blockBorderColor = b; + if (f.isValid()) blockFontColor = f; + } + + setBackgroundBrush(QBrush(bg)); + BlockItem::setVisualTheme(fg, + bg, + blockBorderColor, + blockFontColor, + darkMode ? QColor(60, 60, 80) : QColor(240, 240, 255)); + ArrowItem::setVisualTheme(arrowColor, blockFontColor); + JunctionItem::setVisualTheme(arrowColor, + darkMode ? QColor(120, 180, 255) : QColor(40, 100, 255)); + + const QString currency = meta.value("currency").toString(); + const QString placement = meta.value("currencyPlacement").toString(); + BlockItem::setCurrencyFormat(currency, placement); + for (QGraphicsItem* it : items()) { + if (auto* b = qgraphicsitem_cast(it)) { + b->update(); + } else if (auto* a = qgraphicsitem_cast(it)) { + a->updatePath(); + } else if (auto* j = qgraphicsitem_cast(it)) { + j->update(); + } + } + + const QString size = meta.value("pageSize").toString(); + const QString orient = meta.value("pageOrientation").toString(); + auto sizeToMm = [](const QString& s) { + if (s == "A1") return QSizeF(594, 841); + if (s == "A2") return QSizeF(420, 594); + if (s == "A3") return QSizeF(297, 420); + return QSizeF(210, 297); + }; + auto toPx = [](qreal mm) { + return mm * 96.0 / 25.4; + }; + QSizeF mm = sizeToMm(size.isEmpty() ? "A4" : size); + QSizeF px(toPx(mm.width()), toPx(mm.height())); + if (orient == tr("Landscape") || orient == "Landscape") { + px = QSizeF(std::max(px.width(), px.height()), std::min(px.width(), px.height())); + } else if (orient == tr("Portrait") || orient == "Portrait") { + px = QSizeF(std::min(px.width(), px.height()), std::max(px.width(), px.height())); + } + const QRectF newRect(-px.width() * 0.5, -px.height() * 0.5, px.width(), px.height()); + if (sceneRect() != newRect) { + setSceneRect(newRect); + } + + const bool withHeader = meta.value("withHeader", true).toBool(); + const bool withFooter = meta.value("withFooter", true).toBool(); + const qreal h = sceneRect().height(); + const qreal headerH = withHeader ? h * 0.12 : 0.0; + const qreal footerH = withFooter ? h * 0.08 : 0.0; + m_contentRect = sceneRect().adjusted(0, headerH, 0, -footerH); + + ensureFrame(); + ensureHeaderFooter(); + if (m_headerFooter) { + m_headerFooter->setShowHeaderFooter(withHeader, withFooter); + m_headerFooter->setMeta(m_meta); + m_headerFooter->setPageRect(sceneRect()); + m_headerFooter->update(); + } +} + +void DiagramScene::updateMeta(const QVariantMap& patch) { + QVariantMap merged = m_meta; + for (auto it = patch.cbegin(); it != patch.cend(); ++it) { + merged[it.key()] = it.value(); + } + applyMeta(merged); + emit metaChanged(m_meta); +} + +QString DiagramScene::currentNodeLabel() const { + if (m_currentBlockId < 0) return QStringLiteral("TOP"); + if (!m_currentPrefix.isEmpty()) return m_currentPrefix; + return QStringLiteral("A0"); +} + +QString DiagramScene::currentDiagramTitle() const { + if (m_currentBlockId < 0) { + return m_meta.value("project").toString(); + } + if (!m_hierarchy.isEmpty()) { + const Snapshot& parent = m_hierarchy.last().snapshot; + for (const auto& b : parent.blocks) { + if (b.id == m_currentBlockId) return b.title; + } + } + return m_meta.value("title").toString(); +} + DiagramScene::Edge DiagramScene::hitTestEdge(const QPointF& scenePos, QPointF* outScenePoint) const { - const QRectF r = sceneRect(); + const QRectF r = m_contentRect.isNull() ? sceneRect() : m_contentRect; const qreal tol = 12.0; Edge edge = Edge::None; QPointF proj = scenePos; @@ -68,8 +202,32 @@ DiagramScene::Edge DiagramScene::hitTestEdge(const QPointF& scenePos, QPointF* o return edge; } -bool DiagramScene::tryStartArrowDrag(const QPointF& scenePos) { +bool DiagramScene::tryStartArrowDrag(const QPointF& scenePos, Qt::KeyboardModifiers mods) { const auto itemsUnder = items(scenePos); + // интерфейсный круг + for (QGraphicsItem* it : itemsUnder) { + if (auto* a = qgraphicsitem_cast(it)) { + if (!a->isInterfaceStub()) continue; + auto edge = a->interfaceEdge(); + if (!edge) continue; + + // Shift+drag to move along edge + if (mods.testFlag(Qt::ShiftModifier)) { + m_dragInterfaceStub = a; + return true; + } + + a->setInterfaceIsStub(false); + a->setTempEndPoint(scenePos); + a->setLabelLocked(true); + m_dragArrow = a; + m_dragFromBlock.clear(); + m_dragFromJunction.clear(); + m_dragFromPort = edge->port; + return true; + } + } + // проверяем, не стартуем ли с существующего junction for (QGraphicsItem* it : itemsUnder) { if (auto* j = qgraphicsitem_cast(it)) { @@ -140,6 +298,35 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) { if (!m_dragArrow) return false; const auto itemsUnder = items(scenePos); + // попадание в интерфейсный круг + for (QGraphicsItem* it : itemsUnder) { + if (auto* iface = qgraphicsitem_cast(it)) { + if (!iface->isInterfaceStub()) continue; + auto edge = iface->interfaceEdge(); + if (!edge) continue; + // only allow connecting into output-side stubs (targets) + if (edge->port != BlockItem::Port::Output) continue; + ArrowItem::Endpoint stubEp = *edge; + if (!stubEp.scenePos) stubEp.scenePos = scenePos; + ArrowItem::Endpoint src = m_dragArrow->from(); + + iface->setInterfaceIsStub(false); + iface->setFrom(src); + iface->setTo(stubEp); + iface->setLabel(iface->label()); + iface->setLabelLocked(true); + iface->finalize(); + + removeItem(m_dragArrow); + delete m_dragArrow; + m_dragArrow = nullptr; + m_dragFromBlock.clear(); + m_dragFromJunction.clear(); + pushSnapshot(); + return true; + } + } + // сначала пробуем попасть в блок for (QGraphicsItem* it : itemsUnder) { auto* b = qgraphicsitem_cast(it); @@ -152,12 +339,33 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) { // запретим соединять в тот же самый порт того же блока (упрощение) if (b == m_dragFromBlock && port == m_dragFromPort) continue; - ArrowItem::Endpoint to; - to.block = b; - to.port = port; - to.localPos = localPos; - m_dragArrow->setTo(to); - m_dragArrow->finalize(); + if (m_dragArrow->isInterface()) { + auto edge = m_dragArrow->interfaceEdge(); + if (edge) { + ArrowItem::Endpoint stubEp = *edge; + if (!stubEp.scenePos) stubEp.scenePos = scenePos; + ArrowItem::Endpoint blockEp; + blockEp.block = b; + blockEp.port = port; + blockEp.localPos = localPos; + if (stubEp.port == BlockItem::Port::Output) { + m_dragArrow->setFrom(blockEp); + m_dragArrow->setTo(stubEp); + } else { + m_dragArrow->setFrom(stubEp); + m_dragArrow->setTo(blockEp); + } + m_dragArrow->setLabelLocked(true); + m_dragArrow->finalize(); + } + } else { + ArrowItem::Endpoint to; + to.block = b; + to.port = port; + to.localPos = localPos; + m_dragArrow->setTo(to); + m_dragArrow->finalize(); + } m_dragArrow = nullptr; m_dragFromBlock.clear(); @@ -173,11 +381,31 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) { if (!j->hitTest(scenePos, 8.0)) continue; if (j == m_dragFromJunction) continue; - ArrowItem::Endpoint to; - to.junction = j; - to.port = BlockItem::Port::Input; - m_dragArrow->setTo(to); - m_dragArrow->finalize(); + if (m_dragArrow->isInterface()) { + auto edge = m_dragArrow->interfaceEdge(); + if (edge) { + ArrowItem::Endpoint stubEp = *edge; + if (!stubEp.scenePos) stubEp.scenePos = scenePos; + ArrowItem::Endpoint jun; + jun.junction = j; + jun.port = BlockItem::Port::Input; + if (stubEp.port == BlockItem::Port::Output) { + m_dragArrow->setFrom(jun); + m_dragArrow->setTo(stubEp); + } else { + m_dragArrow->setFrom(stubEp); + m_dragArrow->setTo(jun); + } + m_dragArrow->setLabelLocked(true); + m_dragArrow->finalize(); + } + } else { + ArrowItem::Endpoint to; + to.junction = j; + to.port = BlockItem::Port::Input; + m_dragArrow->setTo(to); + m_dragArrow->finalize(); + } m_dragArrow = nullptr; m_dragFromBlock.clear(); @@ -213,13 +441,25 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) { targetArrow->setTo(mid); targetArrow->finalize(); + ArrowItem* sourceRoot = targetArrow->labelSourceRoot(); + const QString inheritedLabel = targetArrow->label(); + const bool hideInherited = !inheritedLabel.isEmpty(); + auto* forward = new ArrowItem(); addItem(forward); forward->setFrom(mid); forward->setTo(origTo); + forward->setLabel(inheritedLabel); + forward->setLabelHidden(hideInherited); + forward->setLabelInherited(true); + forward->setLabelSource(sourceRoot); forward->finalize(); m_dragArrow->setTo(mid); + m_dragArrow->setLabel(inheritedLabel); + m_dragArrow->setLabelHidden(hideInherited); + m_dragArrow->setLabelInherited(true); + m_dragArrow->setLabelSource(sourceRoot); m_dragArrow->finalize(); m_dragArrow = nullptr; @@ -233,6 +473,11 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) { QPointF edgePoint; Edge edge = hitTestEdge(scenePos, &edgePoint); if (edge != Edge::None) { + const auto fromEp = m_dragArrow->from(); + if (fromEp.scenePos.has_value() && !fromEp.block && !fromEp.junction) { + // запрет: нельзя начинать и заканчивать стрелку на границе + return false; + } // правая граница может быть только приемником (уже гарантировано) ArrowItem::Endpoint to; to.scenePos = edgePoint; @@ -276,24 +521,36 @@ bool DiagramScene::tryBranchAtArrow(const QPointF& scenePos) { mid.junction = junction; mid.port = BlockItem::Port::Input; - const ArrowItem::Endpoint origTo = targetArrow->to(); - targetArrow->setTo(mid); - targetArrow->finalize(); + const ArrowItem::Endpoint origTo = targetArrow->to(); + targetArrow->setTo(mid); + targetArrow->finalize(); - auto* forward = new ArrowItem(); - addItem(forward); - forward->setFrom(mid); - forward->setTo(origTo); - forward->finalize(); + ArrowItem* sourceRoot = targetArrow->labelSourceRoot(); + const QString inheritedLabel = targetArrow->label(); + const bool hideInherited = !inheritedLabel.isEmpty(); - auto* branch = new ArrowItem(); - addItem(branch); - branch->setFrom(mid); - branch->setTempEndPoint(scenePos); - m_dragArrow = branch; - m_dragFromBlock.clear(); - m_dragFromJunction = junction; - m_dragFromPort = BlockItem::Port::Output; + auto* forward = new ArrowItem(); + addItem(forward); + forward->setFrom(mid); + forward->setTo(origTo); + forward->setLabel(inheritedLabel); + forward->setLabelHidden(hideInherited); + forward->setLabelInherited(true); + forward->setLabelSource(sourceRoot); + forward->finalize(); + + auto* branch = new ArrowItem(); + addItem(branch); + branch->setFrom(mid); + branch->setLabel(inheritedLabel); + branch->setLabelHidden(hideInherited); + branch->setLabelInherited(true); + branch->setLabelSource(sourceRoot); + branch->setTempEndPoint(scenePos); + m_dragArrow = branch; + m_dragFromBlock.clear(); + m_dragFromJunction = junction; + m_dragFromPort = BlockItem::Port::Output; return true; } @@ -317,16 +574,27 @@ DiagramScene::Snapshot DiagramScene::captureSnapshot() const { } else if (ep.junction && junctionSet.contains(ep.junction) && ep.junction->scene() == this) { out.kind = Snapshot::Endpoint::Kind::Junction; out.id = ep.junction->id(); + out.port = ep.port; } else if (ep.scenePos) { out.kind = Snapshot::Endpoint::Kind::Scene; out.scenePos = *ep.scenePos; + out.port = ep.port; } return out; }; for (QGraphicsItem* it : items()) { if (auto* b = qgraphicsitem_cast(it)) { - s.blocks.push_back({b->id(), b->title(), b->pos()}); + const bool hasDecomp = childHasContent(b->id()); + b->setHasDecomposition(hasDecomp); + Snapshot::Block blk; + blk.id = b->id(); + blk.title = b->title(); + blk.pos = b->pos(); + blk.hasDecomp = hasDecomp; + blk.number = b->number(); + blk.price = b->price(); + s.blocks.push_back(blk); } } @@ -348,18 +616,27 @@ DiagramScene::Snapshot DiagramScene::captureSnapshot() const { ar.top = a->topOffset(); ar.bottom = a->bottomOffset(); ar.label = a->label(); + ar.labelHidden = a->isLabelHidden(); ar.labelOffset = a->labelOffset(); + ar.isInterface = a->isInterface(); + ar.isInterfaceStub = a->isInterfaceStub(); + ar.labelLocked = a->isLabelLocked(); + if (a->interfaceEdge()) { + ar.interfaceEdge = encodeEp(*a->interfaceEdge()); + } s.arrows.push_back(std::move(ar)); } } return s; } -void DiagramScene::restoreSnapshot(const Snapshot& snap) { +void DiagramScene::restoreSnapshot(const Snapshot& snap, bool resetHistoryState) { m_restoringSnapshot = true; cancelCurrentDrag(); clear(); + m_headerFooter = nullptr; ensureFrame(); + ensureHeaderFooter(); m_nextBlockId = 1; m_nextJunctionId = 1; @@ -369,11 +646,20 @@ void DiagramScene::restoreSnapshot(const Snapshot& snap) { for (const auto& b : snap.blocks) { auto* blk = createBlockWithId(b.pos, b.id, b.title); + blk->setHasDecomposition(b.hasDecomp || childHasContent(b.id)); + if (!b.number.isEmpty()) { + blk->setNumber(b.number); + } else { + blk->setNumber(assignNumber(blk)); + } + if (b.price.has_value()) { + blk->setPrice(b.price); + } blockMap.insert(b.id, blk); m_nextBlockId = std::max(m_nextBlockId, b.id + 1); } - for (const auto& j : snap.junctions) { - m_nextJunctionId = std::max(m_nextJunctionId, j.id + 1); +for (const auto& j : snap.junctions) { + m_nextJunctionId = std::max(m_nextJunctionId, j.id + 1); auto* jun = new JunctionItem(nullptr, j.id); addItem(jun); jun->setPos(j.pos); @@ -390,10 +676,11 @@ void DiagramScene::restoreSnapshot(const Snapshot& snap) { break; case Snapshot::Endpoint::Kind::Junction: out.junction = junctionMap.value(ep.id, nullptr); + out.port = ep.port; break; case Snapshot::Endpoint::Kind::Scene: out.scenePos = ep.scenePos; - out.port = BlockItem::Port::Input; + out.port = ep.port; break; case Snapshot::Endpoint::Kind::None: break; @@ -404,16 +691,32 @@ void DiagramScene::restoreSnapshot(const Snapshot& snap) { for (const auto& a : snap.arrows) { auto* ar = new ArrowItem(); addItem(ar); - ar->setFrom(decodeEp(a.from)); - ar->setTo(decodeEp(a.to)); + const auto fromEp = decodeEp(a.from); + const auto toEp = decodeEp(a.to); + if (a.isInterface && a.interfaceEdge.kind != Snapshot::Endpoint::Kind::None) { + ar->setInterfaceStub(decodeEp(a.interfaceEdge), a.label); + ar->setInterfaceIsStub(a.isInterfaceStub); + if (!a.isInterfaceStub) { + ar->setFrom(fromEp); + ar->setTo(toEp); + } + } else { + ar->setFrom(fromEp); + ar->setTo(toEp); + } ar->setOffsets(a.bend, a.top, a.bottom); ar->setLabel(a.label); ar->setLabelOffset(a.labelOffset); + ar->setLabelHidden(a.labelHidden); + ar->setLabelInherited(a.labelInherited); + ar->setLabelLocked(a.labelLocked); ar->finalize(); } m_restoringSnapshot = false; - resetHistory(captureSnapshot()); + if (resetHistoryState) { + resetHistory(captureSnapshot()); + } } void DiagramScene::pushSnapshot() { @@ -424,6 +727,13 @@ void DiagramScene::pushSnapshot() { } m_history.push_back(std::move(s)); m_historyIndex = m_history.size() - 1; + // refresh dog-ears based on known children + for (QGraphicsItem* it : items()) { + if (auto* blk = qgraphicsitem_cast(it)) { + blk->setHasDecomposition(childHasContent(blk->id())); + } + } + emit changed(); } void DiagramScene::scheduleSnapshot() { @@ -438,7 +748,7 @@ void DiagramScene::scheduleSnapshot() { void DiagramScene::undo() { if (m_historyIndex <= 0) return; m_historyIndex -= 1; - restoreSnapshot(m_history[m_historyIndex]); + restoreSnapshot(m_history[m_historyIndex], false); } bool DiagramScene::goDownIntoSelected() { @@ -453,19 +763,152 @@ bool DiagramScene::goDownIntoSelected() { bool DiagramScene::goUp() { if (m_hierarchy.isEmpty()) return false; Snapshot child = captureSnapshot(); - m_children[m_currentBlockId] = child; + if (m_currentBlockId >= 0) { + m_children[currentPathKey()] = child; + } const HierNode parent = m_hierarchy.takeLast(); + if (m_currentBlockId >= 0) { + for (QGraphicsItem* it : items()) { + if (auto* blk = qgraphicsitem_cast(it)) { + if (blk->id() == m_currentBlockId) { + blk->setHasDecomposition(true); + break; + } + } + } + } m_currentBlockId = parent.blockId; + m_currentPrefix = parent.prefix; restoreSnapshot(parent.snapshot); return true; } bool DiagramScene::goDownIntoBlock(BlockItem* b) { if (!b) return false; + const QString childKey = childKeyFor(b->id()); Snapshot current = captureSnapshot(); - m_hierarchy.push_back({m_currentBlockId, current}); + m_hierarchy.push_back({m_currentBlockId, current, m_currentPrefix}); m_currentBlockId = b->id(); - Snapshot child = m_children.value(m_currentBlockId, Snapshot{}); + m_currentPrefix = b->number().isEmpty() ? m_currentPrefix : b->number(); + Snapshot child; + if (m_children.contains(childKey)) { + child = m_children.value(childKey); + QVector preserved; + preserved.reserve(child.arrows.size()); + for (const auto& ar : child.arrows) { + if (!(ar.isInterface && ar.isInterfaceStub)) preserved.push_back(ar); + } + child.arrows = std::move(preserved); + } + + // build interface stubs from current parent connections + 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, bool inward) { + const qreal inset = 80.0; + switch (port) { + case BlockItem::Port::Input: { + QPointF edge(frame.left(), frame.top() + t * frame.height()); + return inward ? edge + QPointF(inset, 0) : edge; + } + case BlockItem::Port::Output: { + QPointF edge(frame.right(), frame.top() + t * frame.height()); + return inward ? edge - QPointF(inset, 0) : edge; + } + case BlockItem::Port::Control: { + QPointF edge(frame.left() + t * frame.width(), frame.top()); + return inward ? edge + QPointF(0, inset) : edge; + } + case BlockItem::Port::Mechanism: { + QPointF edge(frame.left() + t * frame.width(), frame.bottom()); + return inward ? edge - QPointF(0, inset) : edge; + } + } + return QPointF(); + }; + + 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(); + }; + + auto makeSceneEndpoint = [](const QPointF& pos, BlockItem::Port port) { + Snapshot::Endpoint ep; + ep.kind = Snapshot::Endpoint::Kind::Scene; + ep.scenePos = pos; + ep.port = port; + return ep; + }; + + auto edgeKey = [](const Snapshot::Endpoint& ep) { + QPointF p = ep.scenePos.value_or(QPointF()); + // coarse rounding to avoid mismatch on re-entry + return QStringLiteral("%1:%2:%3").arg(int(ep.port)).arg(p.x(), 0, 'f', 1).arg(p.y(), 0, 'f', 1); + }; + auto labelKey = [](const Snapshot::Arrow& ar) { + return QStringLiteral("%1:%2").arg(int(ar.interfaceEdge.port)).arg(ar.label); + }; + + QSet usedInterfaces; + for (const auto& ar : child.arrows) { + if (!ar.isInterface) continue; + if (!ar.label.isEmpty()) { + usedInterfaces.insert(labelKey(ar)); + } else { + usedInterfaces.insert(edgeKey(ar.interfaceEdge)); + } + } + + const QRectF br = b->mapRectToScene(b->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()); }; + + for (QGraphicsItem* it : items()) { + auto* a = qgraphicsitem_cast(it); + if (!a) continue; + const auto f = a->from(); + const auto t = a->to(); + if (t.block == b) { + 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, false); + Snapshot::Arrow ar; + ar.from = makeSceneEndpoint(edgeScene, t.port); + ar.to = ar.from; + ar.label = a->label(); + ar.isInterface = true; + ar.isInterfaceStub = true; + ar.labelLocked = true; + ar.interfaceEdge = ar.from; + const QString key = ar.label.isEmpty() ? edgeKey(ar.interfaceEdge) : labelKey(ar); + if (!usedInterfaces.contains(key)) { + usedInterfaces.insert(key); + child.arrows.push_back(ar); + } + } else if (f.block == b) { + 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, false); + Snapshot::Arrow ar; + ar.from = makeSceneEndpoint(edgeScene, f.port); + ar.to = ar.from; + ar.label = a->label(); + ar.isInterface = true; + ar.isInterfaceStub = true; + ar.labelLocked = true; + ar.interfaceEdge = ar.to; + const QString key = ar.label.isEmpty() ? edgeKey(ar.interfaceEdge) : labelKey(ar); + if (!usedInterfaces.contains(key)) { + usedInterfaces.insert(key); + child.arrows.push_back(ar); + } + } + } restoreSnapshot(child); return true; } @@ -495,7 +938,7 @@ void DiagramScene::mousePressEvent(QGraphicsSceneMouseEvent* e) { } } // если кликнули на порт — начинаем протягивание стрелки - if (tryStartArrowDrag(e->scenePos())) { + if (tryStartArrowDrag(e->scenePos(), e->modifiers())) { e->accept(); return; } @@ -512,15 +955,47 @@ void DiagramScene::mousePressEvent(QGraphicsSceneMouseEvent* e) { if (e->button() == Qt::RightButton) { // правой кнопкой — добавить блок - createBlockAt(e->scenePos()); - e->accept(); - return; + const QRectF r = m_contentRect.isNull() ? sceneRect() : m_contentRect; + if (r.contains(e->scenePos())) { + createBlockAt(e->scenePos()); + e->accept(); + return; + } } QGraphicsScene::mousePressEvent(e); } void DiagramScene::mouseMoveEvent(QGraphicsSceneMouseEvent* e) { + if (m_dragInterfaceStub && (e->buttons() & Qt::LeftButton)) { + if (auto edge = m_dragInterfaceStub->interfaceEdge()) { + const QRectF r = m_contentRect.isNull() ? sceneRect() : m_contentRect; + QPointF p = e->scenePos(); + switch (edge->port) { + case BlockItem::Port::Input: + p.setX(r.left()); + p.setY(std::clamp(p.y(), r.top(), r.bottom())); + break; + case BlockItem::Port::Output: + p.setX(r.right()); + p.setY(std::clamp(p.y(), r.top(), r.bottom())); + break; + case BlockItem::Port::Control: + p.setY(r.top()); + p.setX(std::clamp(p.x(), r.left(), r.right())); + break; + case BlockItem::Port::Mechanism: + p.setY(r.bottom()); + p.setX(std::clamp(p.x(), r.left(), r.right())); + break; + } + ArrowItem::Endpoint ep = *edge; + ep.scenePos = p; + m_dragInterfaceStub->setInterfaceStub(ep, m_dragInterfaceStub->label()); + } + e->accept(); + return; + } if (m_dragArrow) { m_dragArrow->setTempEndPoint(e->scenePos()); e->accept(); @@ -530,6 +1005,12 @@ void DiagramScene::mouseMoveEvent(QGraphicsSceneMouseEvent* e) { } void DiagramScene::mouseReleaseEvent(QGraphicsSceneMouseEvent* e) { + if (m_dragInterfaceStub && e->button() == Qt::LeftButton) { + m_dragInterfaceStub = nullptr; + pushSnapshot(); + e->accept(); + return; + } if (m_dragArrow && e->button() == Qt::LeftButton) { if (!tryFinishArrowDrag(e->scenePos())) { // не получилось — удаляем временную стрелку @@ -577,12 +1058,17 @@ void DiagramScene::keyPressEvent(QKeyEvent* e) { void DiagramScene::cancelCurrentDrag() { if (m_dragArrow) { - removeItem(m_dragArrow); - delete m_dragArrow; + if (m_dragArrow->isInterface()) { + m_dragArrow->resetInterfaceStub(); + } else { + removeItem(m_dragArrow); + delete m_dragArrow; + } m_dragArrow = nullptr; m_dragFromBlock.clear(); m_dragFromJunction.clear(); } + m_dragInterfaceStub = nullptr; } void DiagramScene::deleteSelection() { @@ -623,7 +1109,11 @@ void DiagramScene::deleteSelection() { } } for (ArrowItem* a : arrowsToDelete) { - delete a; + if (a->isInterface()) { + a->resetInterfaceStub(); + } else { + delete a; + } } for (QGraphicsItem* it : othersToDelete) { delete it; @@ -651,25 +1141,303 @@ void DiagramScene::maybeSnapshotMovedItems() { if (moved) pushSnapshot(); } +bool DiagramScene::childHasContent(int blockId) const { + const QString key = childKeyFor(blockId); + if (!m_children.contains(key)) return false; + const auto& snap = m_children.value(key); + if (!snap.blocks.isEmpty()) return true; + if (!snap.junctions.isEmpty()) return true; + for (const auto& ar : snap.arrows) { + if (!(ar.isInterface && ar.isInterfaceStub)) return true; + } + return false; +} + +QString DiagramScene::currentPathKey() const { + QStringList parts; + for (const auto& node : m_hierarchy) { + if (node.blockId >= 0) parts.push_back(QString::number(node.blockId)); + } + if (m_currentBlockId >= 0) parts.push_back(QString::number(m_currentBlockId)); + return parts.join('/'); +} + +QString DiagramScene::childKeyFor(int blockId) const { + const QString base = currentPathKey(); + if (base.isEmpty()) return QString::number(blockId); + return base + "/" + QString::number(blockId); +} + +QString DiagramScene::assignNumber(BlockItem* b) { + const QString prefix = m_currentPrefix; + const auto all = items(); + + if (prefix.isEmpty()) { + int rootCount = 0; + for (QGraphicsItem* it : all) { + auto* blk = qgraphicsitem_cast(it); + if (!blk || blk == b) continue; + const QString num = blk->number(); + if (num.isEmpty() || num.contains('.')) continue; + rootCount++; + } + return "A" + QString("0").repeated(rootCount + 1); + } + + if (prefix == "A0") { + int maxIdx = 0; + for (QGraphicsItem* it : all) { + auto* blk = qgraphicsitem_cast(it); + if (!blk || blk == b) continue; + const QString num = blk->number(); + if (num.startsWith("A") && !num.contains('.')) { + bool ok = false; + int idx = num.mid(1).toInt(&ok); + if (ok) maxIdx = std::max(maxIdx, idx); + } + } + int next = std::max(1, maxIdx + 1); + return "A" + QString::number(next); + } + + int maxIdx = 0; + const QString want = prefix + "."; + for (QGraphicsItem* it : all) { + auto* blk = qgraphicsitem_cast(it); + if (!blk || blk == b) continue; + const QString num = blk->number(); + if (num.startsWith(want)) { + bool ok = false; + int idx = num.mid(want.size()).toInt(&ok); + if (ok) maxIdx = std::max(maxIdx, idx); + } + } + return prefix + "." + QString::number(maxIdx + 1); +} + +void DiagramScene::propagateLabelFrom(ArrowItem* root) { + if (!root || !root->scene()) return; + ArrowItem* source = root->labelSourceRoot(); + const QString lbl = source ? source->label() : QString(); + for (QGraphicsItem* it : root->scene()->items()) { + auto* a = qgraphicsitem_cast(it); + if (!a || a == source) continue; + if (!a->isLabelInherited()) continue; + if (a->labelSourceRoot() == source) { + a->setLabel(lbl); + a->setLabelHidden(!lbl.isEmpty()); + } + } +} + void DiagramScene::resetHistory(const Snapshot& base) { m_history.clear(); m_history.push_back(base); m_historyIndex = 0; } +QJsonObject endpointToJson(const DiagramScene::Snapshot::Endpoint& ep) { + QJsonObject o; + o["kind"] = int(ep.kind); + o["id"] = ep.id; + o["port"] = int(ep.port); + if (ep.localPos) { + o["localPos"] = QJsonArray{ep.localPos->x(), ep.localPos->y()}; + } + if (ep.scenePos) { + o["scenePos"] = QJsonArray{ep.scenePos->x(), ep.scenePos->y()}; + } + return o; +} + +DiagramScene::Snapshot::Endpoint endpointFromJson(const QJsonObject& o) { + DiagramScene::Snapshot::Endpoint ep; + 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))); + if (o.contains("localPos")) { + const auto arr = o.value("localPos").toArray(); + if (arr.size() == 2) ep.localPos = QPointF(arr[0].toDouble(), arr[1].toDouble()); + } + if (o.contains("scenePos")) { + const auto arr = o.value("scenePos").toArray(); + if (arr.size() == 2) ep.scenePos = QPointF(arr[0].toDouble(), arr[1].toDouble()); + } + return ep; +} + +QVariantMap DiagramScene::exportToVariant() { + auto snapshotToVariant = [](const Snapshot& snap) -> QVariantMap { + QJsonObject root; + QJsonArray blocks; + for (const auto& b : snap.blocks) { + QJsonObject o; + o["id"] = b.id; + o["title"] = b.title; + o["pos"] = QJsonArray{b.pos.x(), b.pos.y()}; + o["hasDecomp"] = b.hasDecomp; + o["number"] = b.number; + if (b.price.has_value()) o["price"] = *b.price; + blocks.append(o); + } + root["blocks"] = blocks; + + QJsonArray junctions; + for (const auto& j : snap.junctions) { + QJsonObject o; + o["id"] = j.id; + o["pos"] = QJsonArray{j.pos.x(), j.pos.y()}; + junctions.append(o); + } + root["junctions"] = junctions; + + QJsonArray arrows; + for (const auto& a : snap.arrows) { + QJsonObject o; + o["from"] = endpointToJson(a.from); + o["to"] = endpointToJson(a.to); + o["bend"] = a.bend; + o["top"] = a.top; + o["bottom"] = a.bottom; + o["label"] = a.label; + o["labelOffset"] = QJsonArray{a.labelOffset.x(), a.labelOffset.y()}; + o["labelHidden"] = a.labelHidden; + o["labelInherited"] = a.labelInherited; + o["isInterface"] = a.isInterface; + o["isInterfaceStub"] = a.isInterfaceStub; + o["labelLocked"] = a.labelLocked; + o["interfaceEdge"] = endpointToJson(a.interfaceEdge); + arrows.append(o); + } + root["arrows"] = arrows; + return root.toVariantMap(); + }; + + if (m_currentBlockId >= 0) { + m_children[currentPathKey()] = captureSnapshot(); + } + + Snapshot rootSnap = captureSnapshot(); + if (!m_hierarchy.isEmpty()) { + rootSnap = m_hierarchy.first().snapshot; + } + + QVariantMap out; + out["diagram"] = snapshotToVariant(rootSnap); + + QJsonObject children; + for (auto it = m_children.cbegin(); it != m_children.cend(); ++it) { + children[it.key()] = QJsonObject::fromVariantMap(snapshotToVariant(it.value())); + } + out["children"] = children.toVariantMap(); + return out; +} + +bool DiagramScene::importFromVariant(const QVariantMap& map) { + auto toSnap = [](const QVariantMap& vm, DiagramScene* self) -> std::optional { + QJsonObject root = QJsonObject::fromVariantMap(vm); + if (!root.contains("blocks") || !root.contains("arrows")) return std::nullopt; + Snapshot snap; + for (const auto& vb : root.value("blocks").toArray()) { + const auto o = vb.toObject(); + const auto posArr = o.value("pos").toArray(); + if (posArr.size() != 2) continue; + DiagramScene::Snapshot::Block blk; + blk.id = o.value("id").toInt(); + blk.title = o.value("title").toString(); + blk.pos = QPointF(posArr[0].toDouble(), posArr[1].toDouble()); + blk.hasDecomp = o.value("hasDecomp").toBool(false); + blk.number = o.value("number").toString(); + if (o.contains("price") && !o.value("price").isNull()) { + blk.price = o.value("price").toDouble(); + } + snap.blocks.push_back(blk); + } + for (const auto& vj : root.value("junctions").toArray()) { + const auto o = vj.toObject(); + const auto posArr = o.value("pos").toArray(); + if (posArr.size() != 2) continue; + snap.junctions.push_back({o.value("id").toInt(), QPointF(posArr[0].toDouble(), posArr[1].toDouble())}); + } + for (const auto& va : root.value("arrows").toArray()) { + const auto o = va.toObject(); + Snapshot::Arrow ar; + ar.from = endpointFromJson(o.value("from").toObject()); + ar.to = endpointFromJson(o.value("to").toObject()); + ar.bend = o.value("bend").toDouble(); + ar.top = o.value("top").toDouble(); + ar.bottom = o.value("bottom").toDouble(); + ar.label = o.value("label").toString(); + const auto lo = o.value("labelOffset").toArray(); + if (lo.size() == 2) ar.labelOffset = QPointF(lo[0].toDouble(), lo[1].toDouble()); + ar.labelHidden = o.value("labelHidden").toBool(false); + ar.labelInherited = o.value("labelInherited").toBool(false); + ar.isInterface = o.value("isInterface").toBool(false); + ar.isInterfaceStub = o.value("isInterfaceStub").toBool(false); + ar.labelLocked = o.value("labelLocked").toBool(false); + ar.interfaceEdge = endpointFromJson(o.value("interfaceEdge").toObject()); + snap.arrows.push_back(ar); + } + return snap; + }; + + auto diagIt = map.find("diagram"); + if (diagIt == map.end()) return false; + auto maybeRoot = toSnap(diagIt.value().toMap(), this); + if (!maybeRoot) return false; + m_children.clear(); + auto childrenIt = map.find("children"); + if (childrenIt != map.end()) { + const QVariantMap childrenMap = childrenIt.value().toMap(); + for (auto it = childrenMap.cbegin(); it != childrenMap.cend(); ++it) { + auto maybeChild = toSnap(it.value().toMap(), this); + if (maybeChild) m_children[it.key()] = *maybeChild; + } + } + + m_hierarchy.clear(); + m_currentPrefix.clear(); + m_currentBlockId = -1; + restoreSnapshot(*maybeRoot); + const int currentId = QJsonObject::fromVariantMap(diagIt.value().toMap()).value("currentBlockId").toInt(-1); + m_currentBlockId = currentId; + return true; +} + void DiagramScene::ensureFrame() { bool exists = false; for (QGraphicsItem* it : items()) { if (it->data(0).toString() == QStringLiteral("static-frame")) { exists = true; + if (auto* rect = qgraphicsitem_cast(it)) { + rect->setRect(m_contentRect.isNull() ? sceneRect() : m_contentRect); + const bool darkMode = m_meta.value("resolvedDarkMode", + m_meta.value("darkMode", false)).toBool(); + const QColor frameColor = darkMode ? QColor(220, 220, 220) : QColor(80, 80, 80); + rect->setPen(QPen(frameColor, 1.5, Qt::DashLine)); + } break; } } if (!exists) { - auto* frame = addRect(sceneRect(), QPen(QColor(80, 80, 80), 1.5, Qt::DashLine), Qt::NoBrush); + const bool darkMode = m_meta.value("resolvedDarkMode", + m_meta.value("darkMode", false)).toBool(); + const QColor frameColor = darkMode ? QColor(220, 220, 220) : QColor(80, 80, 80); + auto* frame = addRect(m_contentRect.isNull() ? sceneRect() : m_contentRect, + QPen(frameColor, 1.5, Qt::DashLine), + Qt::NoBrush); frame->setZValue(-5); frame->setEnabled(false); frame->setAcceptedMouseButtons(Qt::NoButton); frame->setData(0, QVariant(QStringLiteral("static-frame"))); } } + +void DiagramScene::ensureHeaderFooter() { + if (!m_headerFooter) { + m_headerFooter = new HeaderFooterItem(this); + addItem(m_headerFooter); + } + m_headerFooter->setPageRect(sceneRect()); + m_headerFooter->setMeta(m_meta); +} diff --git a/src/items/DiagramScene.h b/src/items/DiagramScene.h index b466a80..bfb684f 100644 --- a/src/items/DiagramScene.h +++ b/src/items/DiagramScene.h @@ -1,10 +1,13 @@ #pragma once #include #include +#include +#include #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 localPos; + std::optional scenePos; }; - struct Block { int id; QString title; QPointF pos; }; + struct Block { int id; QString title; QPointF pos; bool hasDecomp = false; QString number; std::optional 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 blocks; QVector junctions; QVector arrows; }; - struct HierNode { int blockId; Snapshot snapshot; }; + struct HierNode { int blockId; Snapshot snapshot; QString prefix; }; ArrowItem* m_dragArrow = nullptr; QPointer 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 m_pressPositions; int m_currentBlockId = -1; QVector m_hierarchy; - QHash m_children; // blockId -> saved child diagram + QHash 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); }; diff --git a/src/items/HeaderFooterItem.cpp b/src/items/HeaderFooterItem.cpp new file mode 100644 index 0000000..8f16880 --- /dev/null +++ b/src/items/HeaderFooterItem.cpp @@ -0,0 +1,320 @@ +#include "HeaderFooterItem.h" +#include "items/DiagramScene.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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); +} diff --git a/src/items/HeaderFooterItem.h b/src/items/HeaderFooterItem.h new file mode 100644 index 0000000..8735514 --- /dev/null +++ b/src/items/HeaderFooterItem.h @@ -0,0 +1,32 @@ +#pragma once +#include +#include + +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; +}; diff --git a/src/items/JunctionItem.cpp b/src/items/JunctionItem.cpp index a0b4d0a..368c711 100644 --- a/src/items/JunctionItem.cpp +++ b/src/items/JunctionItem.cpp @@ -1,10 +1,14 @@ #include "JunctionItem.h" #include "ArrowItem.h" +#include "items/DiagramScene.h" #include #include #include +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(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(); diff --git a/src/items/JunctionItem.h b/src/items/JunctionItem.h index 21fef6d..713d206 100644 --- a/src/items/JunctionItem.h +++ b/src/items/JunctionItem.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include 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 m_arrows; + static QColor s_normalColor; + static QColor s_selectedColor; }; diff --git a/src/main.cpp b/src/main.cpp index d433e8d..aa7ee5a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,9 +1,67 @@ #include +#include +#include +#include +#include +#include +#include +#include #include "MainWindow.h" +class IdefApplication final : public QApplication { +public: + using QApplication::QApplication; + + QString pendingFileOpen; + std::function fileOpenHandler; + + bool event(QEvent* ev) override { + if (ev->type() == QEvent::FileOpen) { + auto* foe = static_cast(ev); + if (foe && !foe->file().isEmpty()) { + if (fileOpenHandler) { + fileOpenHandler(foe->file()); + } else { + pendingFileOpen = foe->file(); + } + return true; + } + } + return QApplication::event(ev); + } +}; + int main(int argc, char** argv) { - QApplication app(argc, argv); - MainWindow w; + IdefApplication app(argc, argv); + + QTranslator translator; + const QLocale locale; + const QString baseName = QStringLiteral("idef0_%1").arg(locale.name()); + 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; + } + } + + QString startupPath; + const QStringList args = QCoreApplication::arguments(); + if (args.size() > 1) { + startupPath = args.at(1); + } + if (!app.pendingFileOpen.isEmpty()) { + startupPath = app.pendingFileOpen; + } + + MainWindow w(startupPath); + app.fileOpenHandler = [&w](const QString& path) { + w.openDiagramPath(path); + }; w.resize(1200, 800); w.show(); return app.exec();