From 5eb23fc77e71a3b87f80bd939f65675eedb692c0 Mon Sep 17 00:00:00 2001 From: raspopov Date: Sat, 4 Jul 2026 19:26:52 +0300 Subject: [PATCH] Add the feature to merge projects. Fixes #121. --- src/appproject.cpp | 46 +++++---- src/appproject.h | 1 + src/mainwindow.cpp | 205 +++++++++++++++++++++++++++----------- src/mainwindow.h | 7 +- src/modbusmultiserver.cpp | 23 +++-- src/modbusmultiserver.h | 4 +- 6 files changed, 195 insertions(+), 91 deletions(-) diff --git a/src/appproject.cpp b/src/appproject.cpp index c6af5120..f7b2b295 100644 --- a/src/appproject.cpp +++ b/src/appproject.cpp @@ -640,6 +640,12 @@ void AppProject::closeProject() // Close any remaining windows (for example split auto-clones). _mdiArea->closeAllSubWindows(); + // Force immediate destruction of MDI subwindows (WA_DeleteOnClose uses + // deleteLater, so without this their destructors run after new forms are + // already created, causing deviceIdAdded/unitMapAdded signals to be + // suppressed for the newly opened project). + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + const auto deleteClosedForms = [this](auto&& shouldDelete) { const auto snapshot = _closedForms; for (auto* frm : snapshot) { @@ -662,6 +668,8 @@ void AppProject::closeProject() _closedForms.clear(); _mbServer.clearAddressSpace(); + _mbServer.clearDescriptions(); + _mbServer.clearTimestamps(); _dataCounter = 0; _trafficCounter = 0; _scriptCounter = 0; @@ -1721,11 +1729,12 @@ void AppProject::loadProject(const QString& filename) if(!file.open(QFile::ReadOnly)) return; - _projectFilename = QFileInfo(filename).absoluteFilePath(); - emit projectOpened(_projectFilename); - - _mbServer.clearDescriptions(); - _mbServer.clearTimestamps(); + const auto replace = _projectFilename.isEmpty(); + if (replace) { + setSavePath(QFileInfo(filename).absoluteDir().absolutePath()); + _projectFilename = QFileInfo(filename).absoluteFilePath(); + emit projectOpened(_projectFilename); + } ModbusDefinitions defs; QList conns; @@ -1738,7 +1747,7 @@ void AppProject::loadProject(const QString& filename) bool projectGlobalZeroBasedAddress = false; bool hasProjectGlobalHexView = false; bool projectGlobalHexView = false; - bool viewPreparedForForms = false; + bool viewPreparedForForms = !replace; QStringList primaryTabOrder; QStringList secondaryTabOrder; @@ -1807,20 +1816,6 @@ void AppProject::loadProject(const QString& filename) _mdiArea->setSplitViewEnabled(splitView); viewPreparedForForms = true; } - - _mdiArea->closeAllSubWindows(); - // Force immediate destruction of MDI subwindows (WA_DeleteOnClose uses - // deleteLater, so without this their destructors run after new forms are - // already created, causing deviceIdAdded/unitMapAdded signals to be - // suppressed for the newly opened project). - QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); - // Clean up forms that were already closed (hidden) - const auto closed = _closedForms; - for (auto&& frm : closed) { - _projectTree->removeForm(frm); - delete frm; - } - _closedForms.clear(); while (xml.readNextStartElement()) { ProjectFormKind kind; bool isForm = true; @@ -2011,9 +2006,14 @@ void AppProject::loadProject(const QString& filename) // Prefer global AddressSpace metadata when present; otherwise keep legacy per-form values. if (hasGlobalDescriptionMap) - _mbServer.setDescriptionMap(globalDescriptionMap, WriteSource::ProjectLoad); + _mbServer.setDescriptionMap(globalDescriptionMap, WriteSource::ProjectLoad, replace); if (hasGlobalTimestampMap) - _mbServer.setTimestampMap(globalTimestampMap); + _mbServer.setTimestampMap(globalTimestampMap, replace); + + if (!replace) { + // Ignore global settings part of merging project + return; + } _mainWindow->applyConnections(defs, conns); syncAutoRequestMap(_mbServer.getModbusDefinitions()); @@ -2136,6 +2136,7 @@ void AppProject::restoreActiveWindows() /// /// \brief AppProject::saveProject /// \param filename +/// \return /// bool AppProject::saveProject(const QString& filename) { @@ -2147,6 +2148,7 @@ bool AppProject::saveProject(const QString& filename) return false; } + setSavePath(QFileInfo(filename).absoluteDir().absolutePath()); _projectFilename = absoluteFilename; QXmlStreamWriter w(&file); diff --git a/src/appproject.h b/src/appproject.h index c5fb6083..b9e7f157 100644 --- a/src/appproject.h +++ b/src/appproject.h @@ -97,6 +97,7 @@ class AppProject : public QObject void loadProject(const QString& filename); bool saveProject(const QString& filename); void restoreActiveWindows(); + const QString & filePath() const noexcept { return _projectFilename; } // Called from MainWindow::~MainWindow() before delete ui. // Closes MDI windows and deletes forms/scripts without touching the project tree UI diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index b35cbacc..39aad610 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -293,6 +293,7 @@ MainWindow::MainWindow(const QString& profile, bool useSession, const QString& s setWindowTitle(APP_PRODUCT_NAME); setUnifiedTitleAndToolBarOnMac(true); setStatusBar(new MainStatusBar(_mbMultiServer, this)); + setAcceptDrops(true); if (auto* newButton = qobject_cast(ui->toolBarMain->widgetForAction(ui->actionNew))) { newButton->setMenu(ui->menuNew); @@ -522,9 +523,6 @@ void MainWindow::on_outputDockVisibilityChanged(bool visible) /// void MainWindow::on_mdiSubWindowActivated(QMdiSubWindow* wnd) { - if(wnd) - markModified(); - QMdiSubWindow* stableWnd = ui->mdiArea->activeSubWindow(); if(!stableWnd) stableWnd = ui->mdiArea->currentSubWindow(); @@ -693,12 +691,10 @@ void MainWindow::changeEvent(QEvent* event) /// void MainWindow::closeEvent(QCloseEvent *event) { - const bool shouldAskToSave = hasProjectContext() && (_isModified || _projectFilePath.isEmpty()); - if(shouldAskToSave) { - if(!confirmSaveOnClose()) { - event->ignore(); - return; - } + if (!confirmSaveOnClose()) { + // User canceled + event->ignore(); + return; } saveAppSettings(); @@ -748,6 +744,89 @@ bool MainWindow::eventFilter(QObject* obj, QEvent* e) return QObject::eventFilter(obj, e); } +/// +/// \brief MainWindow::acceptedProjects +/// Looks through the list of files and directories dropped onto the window to identify suitable projects. +/// \param event The QDropEvent or QDragEnterEvent window event. +/// \return The list of accepted project files. +/// +QStringList MainWindow::acceptedProjects(QDropEvent* event) +{ + QStringList projects; + static const QStringList filters {"*.omsim", "*.xml"}; + + for (const auto& url : event->mimeData()->urls()) { + const auto info = QFileInfo(url.toLocalFile()); + if (info.isFile()) { + for (const auto& filter : filters) { + if (!info.suffix().compare(filter.mid(2), Qt::CaseInsensitive)) { + projects.append(info.absoluteFilePath()); + } + } + } + else if (info.isDir()) { + QDirIterator it(info.absoluteFilePath(), filters, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext() && projects.size() < 100) { + it.next(); + projects.append(it.fileInfo().absoluteFilePath()); + } + } + } + + return projects; +} + +/// +/// \brief MainWindow::dragEnterEvent +/// Allows to start a dragging the project list into the application window. +/// \param event The drag enter event. +/// +void MainWindow::dragEnterEvent(QDragEnterEvent* event) +{ + if (!acceptedProjects(event).empty()) { + event->acceptProposedAction(); + } +} + +/// +/// \brief MainWindow::dropEvent +/// Accepts the list of projects dragged into the application window and offers to merge them with the existing one. +/// \param event The drop event. +/// +void MainWindow::dropEvent(QDropEvent* event) +{ + const auto projects = acceptedProjects(event); + if (!projects.empty()) { + auto replace = true; + if (hasProjectContext()) { + switch (QMessageBox::question(this, APP_PRODUCT_NAME, + tr("Would you like to combine the file(s) with the project?\n" + "\n" + "The global settings part of the merging file(s) will be ignored.\n" + "Please verify the merge result carefully."), + QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel, QMessageBox::No)) { + case QMessageBox::Yes: + replace = false; + break; + case QMessageBox::No: + replace = true; + break; + default: + // User canceled + return; + } + } + for (const auto& project : projects) { + if (!loadProject(project, replace)) { + // User canceled + return; + } + replace = false; + } + event->acceptProposedAction(); + } +} + /// /// \brief MainWindow::on_awake /// @@ -932,11 +1011,14 @@ void MainWindow::on_actionOpenProject_triggered() filters << tr("Project 1.x files (*.xml)"); filters << tr("All files (*)"); - const auto filename = QFileDialog::getOpenFileName(this, QString(), _project->savePath(), filters.join(";;")); - if(filename.isEmpty()) return; - - _project->setSavePath(QFileInfo(filename).absoluteDir().absolutePath()); - loadProject(filename); + const auto filenames = QFileDialog::getOpenFileNames(this, QString(), _project->savePath(), filters.join(";;")); + auto replace = true; + for (const auto& filename : filenames) { + if (!loadProject(filename, replace)) { + return; + } + replace = false; + } } /// @@ -944,13 +1026,12 @@ void MainWindow::on_actionOpenProject_triggered() /// void MainWindow::on_actionSaveProject_triggered() { - if(_projectFilePath.isEmpty()) { + if (_project->filePath().isEmpty()) { on_actionSaveProjectAs_triggered(); return; } - if(saveProject(_projectFilePath)) { - addRecentProject(_projectFilePath); + if (saveProject(_project->filePath())) { return; } @@ -970,18 +1051,7 @@ void MainWindow::on_actionSaveProjectAs_triggered() /// void MainWindow::on_actionCloseProject_triggered() { - const bool shouldAskToSave = hasProjectContext() && (_isModified || _projectFilePath.isEmpty()); - if(shouldAskToSave) { - if(!confirmSaveOnClose()) - return; - } - - _project->closeProject(); - _projectFilePath.clear(); - _lastProjectPath.clear(); - _isModified = false; - updateProjectWindowTitle(); - updateMainToolbarState(); + closeProject(); } /// @@ -1525,29 +1595,29 @@ void MainWindow::presetRegs(QModbusDataUnit::RegisterType type) /// /// \brief MainWindow::loadProject /// \param filename +/// \param replace +/// \return /// -void MainWindow::loadProject(const QString& filename) +bool MainWindow::loadProject(const QString& filename, bool replace) { - if (hasProjectContext()) { - _project->closeProject(); - _projectFilePath.clear(); - _isModified = false; - updateProjectWindowTitle(); + if (replace) { + if (!closeProject()) { + // User canceled + return false; + } + AppLogger::clear(); } - AppLogger::clear(); - _project->loadProject(filename); applyGlobalAddressBase(AppPreferences::instance().globalAddressBase(), false); applyGlobalHexView(AppPreferences::instance().globalHexView(), false); syncGlobalViewControls(); - _projectFilePath = QFileInfo(filename).absoluteFilePath(); - _lastProjectPath = _projectFilePath; - _project->setSavePath(QFileInfo(filename).absoluteDir().absolutePath()); - _isModified = false; + _isModified = !replace; updateProjectWindowTitle(); updateMainToolbarState(); - addRecentProject(_projectFilePath); + addRecentProject(_project->filePath()); + + return true; } /// @@ -1559,11 +1629,28 @@ bool MainWindow::saveProject(const QString& filename) if (!_project->saveProject(filename)) return false; - _projectFilePath = QFileInfo(filename).absoluteFilePath(); - _lastProjectPath = _projectFilePath; - _project->setSavePath(QFileInfo(filename).absoluteDir().absolutePath()); _isModified = false; updateProjectWindowTitle(); + addRecentProject(_project->filePath()); + return true; +} + +/// +/// \brief MainWindow::closeProject +/// \return +/// +bool MainWindow::closeProject() +{ + if (!confirmSaveOnClose()) { + // User canceled + return false; + } + + _project->closeProject(); + _isModified = false; + updateProjectWindowTitle(); + updateMainToolbarState(); + return true; } @@ -1573,8 +1660,8 @@ bool MainWindow::saveProject(const QString& filename) /// QString MainWindow::projectName() const { - if(!_projectFilePath.isEmpty()) - return QFileInfo(_projectFilePath).completeBaseName(); + if (!_project->filePath().isEmpty()) + return QFileInfo(_project->filePath()).completeBaseName(); if(hasProjectContext()) return tr("Untitled"); @@ -1593,7 +1680,7 @@ void MainWindow::updateProjectWindowTitle() if(name.isEmpty()) setWindowTitle(modifiedMark + APP_PRODUCT_NAME); else - setWindowTitle(QString("%1%2 - %3").arg(modifiedMark, APP_PRODUCT_NAME, name)); + setWindowTitle(QString("%1%2 - %3").arg(modifiedMark, name, APP_PRODUCT_NAME)); } /// @@ -1751,7 +1838,7 @@ void MainWindow::saveAppSettings() m.setValue("SavePath", _project->savePath()); m.setValue(kNewFormKindKey, newFormKindToSetting(_newFormKind)); m.setValue(kRecentProjectsKey, _recentProjects); - m.setValue(kLastProjectPathKey, _lastProjectPath); + m.setValue(kLastProjectPathKey, _project->filePath()); } /// @@ -1919,9 +2006,15 @@ void MainWindow::applyGlobalHexView(bool enabled, bool persist) /// /// \brief MainWindow::confirmSaveOnClose +/// \return /// bool MainWindow::confirmSaveOnClose() { + const auto shouldAskToSave = hasProjectContext() && (_isModified || _project->filePath().isEmpty()); + if (!shouldAskToSave) { + return true; + } + const auto button = QMessageBox::question(this, tr("Save Project"), tr("Save project before closing?"), @@ -1933,9 +2026,8 @@ bool MainWindow::confirmSaveOnClose() if(button != QMessageBox::Save) return true; - if(!_projectFilePath.isEmpty()) { - if(saveProject(_projectFilePath)) { - addRecentProject(_projectFilePath); + if (!_project->filePath().isEmpty()) { + if (saveProject(_project->filePath())) { return true; } @@ -1955,9 +2047,9 @@ bool MainWindow::promptSaveProjectAs(const QString& initialPath) QStringList filters; filters << tr("Project files (*.omsim)"); - const QString defaultPath = _projectFilePath.isEmpty() + const QString defaultPath = _project->filePath().isEmpty() ? _project->savePath() + "/" + projectName() + ".omsim" - : _projectFilePath; + : _project->filePath(); const QString dialogPath = initialPath.isEmpty() ? defaultPath : initialPath; auto filename = QFileDialog::getSaveFileName(this, QString(), dialogPath, filters.join(";;")); @@ -1967,11 +2059,9 @@ bool MainWindow::promptSaveProjectAs(const QString& initialPath) if(!filename.endsWith(".omsim", Qt::CaseInsensitive)) filename.append(".omsim"); - _project->setSavePath(QFileInfo(filename).absoluteDir().absolutePath()); if(!saveProject(filename)) return false; - addRecentProject(QFileInfo(filename).absoluteFilePath()); return true; } @@ -1995,10 +2085,11 @@ QString MainWindow::projectSavePathInProfileDir() const /// /// \brief MainWindow::hasProjectContext +/// \return /// bool MainWindow::hasProjectContext() const { - return !_projectFilePath.isEmpty() + return !_project->filePath().isEmpty() || _project->firstMdiChild() != nullptr || !_project->closedForms().isEmpty(); } diff --git a/src/mainwindow.h b/src/mainwindow.h index 38177f3c..9fb21003 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -58,8 +58,9 @@ class MainWindow : public QMainWindow void applyGlobalAddressBase(AddressBase base, bool persist = true); void applyGlobalHexView(bool enabled, bool persist = true); - void loadProject(const QString& filename); + bool loadProject(const QString& filename, bool replace = true); bool saveProject(const QString& filename); + bool closeProject(); void appendConsoleMessage(const QString& source, const QString& text, ConsoleOutput::MessageType type); void showOutputConsole(); @@ -79,6 +80,8 @@ class MainWindow : public QMainWindow void changeEvent(QEvent* event) override; void closeEvent(QCloseEvent *event) override; bool eventFilter(QObject * obj, QEvent * e) override; + void dragEnterEvent(QDragEnterEvent* event) override; + void dropEvent(QDropEvent* event) override; public slots: void windowActivate(QMdiSubWindow* wnd); @@ -188,6 +191,7 @@ private slots: void syncGlobalViewControls(); void applyGlobalViewStateToForm(QWidget* frm); void updateMainToolbarState(); + static QStringList acceptedProjects(QDropEvent* event); private: Ui::MainWindow *ui; @@ -206,7 +210,6 @@ private slots: QSharedPointer _selectedPrinter; DataSimulator* _dataSimulator = nullptr; QString _profile; - QString _projectFilePath; ProjectFormKind _newFormKind = ProjectFormKind::Data; QStringList _recentProjects; QString _lastProjectPath; diff --git a/src/modbusmultiserver.cpp b/src/modbusmultiserver.cpp index 5177beb1..e2521031 100644 --- a/src/modbusmultiserver.cpp +++ b/src/modbusmultiserver.cpp @@ -754,18 +754,21 @@ void ModbusMultiServer::setTimestamp(quint8 deviceId, QModbusDataUnit::RegisterT /// /// \brief ModbusMultiServer::setTimestampMap /// \param timestamps +/// \param replace /// -void ModbusMultiServer::setTimestampMap(const AddressTimestampMap& timestamps) +void ModbusMultiServer::setTimestampMap(const AddressTimestampMap& timestamps, bool replace) { if(QThread::currentThread() != _workerThread) { - QMetaObject::invokeMethod(this, [this, timestamps]() { - setTimestampMap(timestamps); + QMetaObject::invokeMethod(this, [this, timestamps, replace]() { + setTimestampMap(timestamps, replace); }, Qt::BlockingQueuedConnection); return; } - clearTimestamps(); + if (replace) { + clearTimestamps(); + } for(auto it = timestamps.constBegin(); it != timestamps.constEnd(); ++it) { @@ -927,18 +930,22 @@ void ModbusMultiServer::setDescription(quint8 deviceId, QModbusDataUnit::Registe /// /// \brief ModbusMultiServer::setDescriptionMap /// \param descriptions +/// \param source +/// \param replace /// -void ModbusMultiServer::setDescriptionMap(const AddressDescriptionMap& descriptions, WriteSource source) +void ModbusMultiServer::setDescriptionMap(const AddressDescriptionMap& descriptions, WriteSource source, bool replace) { if(QThread::currentThread() != _workerThread) { - QMetaObject::invokeMethod(this, [this, descriptions, source]() { - setDescriptionMap(descriptions, source); + QMetaObject::invokeMethod(this, [this, descriptions, source, replace]() { + setDescriptionMap(descriptions, source, replace); }, Qt::BlockingQueuedConnection); return; } - clearDescriptions(); + if (replace) { + clearDescriptions(); + } for(auto it = descriptions.constBegin(); it != descriptions.constEnd(); ++it) { diff --git a/src/modbusmultiserver.h b/src/modbusmultiserver.h index ee21f9c1..a4861d36 100644 --- a/src/modbusmultiserver.h +++ b/src/modbusmultiserver.h @@ -73,7 +73,7 @@ class ModbusMultiServer final : public QObject AddressTimestampMap timestampMap(quint8 deviceId, QModbusDataUnit::RegisterType pointType, quint16 pointAddress, quint16 length) const; AddressTimestampMap timestampMap() const; void setTimestamp(quint8 deviceId, QModbusDataUnit::RegisterType pointType, quint16 pointAddress, const QDateTime& timestamp); - void setTimestampMap(const AddressTimestampMap& timestamps); + void setTimestampMap(const AddressTimestampMap& timestamps, bool replace = true); void clearTimestamps(); QString description(quint8 deviceId, QModbusDataUnit::RegisterType type, quint16 address) const; @@ -81,7 +81,7 @@ class ModbusMultiServer final : public QObject AddressDescriptionMap descriptionMap() const; void setDescription(quint8 deviceId, QModbusDataUnit::RegisterType pointType, quint16 pointAddress, const QString& description, WriteSource source = WriteSource::User); - void setDescriptionMap(const AddressDescriptionMap& descriptions, WriteSource source = WriteSource::Internal); + void setDescriptionMap(const AddressDescriptionMap& descriptions, WriteSource source, bool replace = true); void clearDescriptions(); void writeValue(quint8 deviceId, QModbusDataUnit::RegisterType pointType, quint16 pointAddress, quint16 value, ByteOrder order);