diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index f7c086756..829e3a74b 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -30,6 +30,7 @@ #include "qetpalette.h" #include "qetstyle.h" #include "utils/qetutils.h" +#include "utils/configprofile.h" #include "qetmessagebox.h" #include "qetproject.h" #include "qtextorientationspinboxwidget.h" @@ -55,9 +56,11 @@ #include #define QUOTE(x) STRINGIFY(x) #define STRINGIFY(x) #x +#include #include #include #include +#include #include #include #ifdef BUILD_WITHOUT_KF @@ -2242,6 +2245,109 @@ void QETApp::configureQET() #endif } +/** + @brief QETApp::exportConfiguration + Save the settings of QElectroTech to a file the user chooses, to keep + them as a named profile or copy them to another computer (discussion + #610). The file is always written in the ini format, whatever the + platform stores its live settings in, so a profile saved on Windows + loads on Linux and macOS. See ConfigProfile for the keys left out. +*/ +void QETApp::exportConfiguration() +{ + QWidget *parent_widget = qApp->activeWindow(); + + QString path = QFileDialog::getSaveFileName( + parent_widget, + tr("Enregistrer la configuration sous...", "dialog title"), + QString(), + tr("Configurations QElectroTech (*.conf)", "file dialog filter")); + if (path.isEmpty()) { + return; + } + if (!path.endsWith(QLatin1String(".conf"), Qt::CaseInsensitive)) { + path += QLatin1String(".conf"); + } + + QSettings live_settings; + QSettings file_settings(path, QSettings::IniFormat); + ConfigProfile::exportTo(live_settings, file_settings); + + if (file_settings.status() != QSettings::NoError) { + QET::QetMessageBox::critical( + parent_widget, + tr("Erreur", "message box title"), + tr("Impossible d'enregistrer la configuration dans « %1 ».").arg(path)); + } +} + +/** + @brief QETApp::importConfiguration + Replace the settings of QElectroTech with a file saved by + exportConfiguration(), then close QElectroTech. + + The settings are read by each part of QElectroTech when it starts, and + there is no signal telling all of them that a setting changed, so the + new settings are applied by starting QElectroTech again. That restart + is left to the user: a second copy started from here would find this + one still running, hand its arguments over to it and exit (main.cpp). +*/ +void QETApp::importConfiguration() +{ + QWidget *parent_widget = qApp->activeWindow(); + + const QString path = QFileDialog::getOpenFileName( + parent_widget, + tr("Charger une configuration...", "dialog title"), + QString(), + tr("Configurations QElectroTech (*.conf)", "file dialog filter")); + if (path.isEmpty()) { + return; + } + + QSettings file_settings(path, QSettings::IniFormat); + if (file_settings.status() != QSettings::NoError + || !ConfigProfile::isProfile(file_settings)) + { + QET::QetMessageBox::critical( + parent_widget, + tr("Erreur", "message box title"), + tr("« %1 » n'est pas une configuration enregistrée par QElectroTech.").arg(path)); + return; + } + + //Said before anything closes: once the last window is closed, + //QElectroTech quits by itself (checkRemainingWindows()). + const auto answer = QET::QetMessageBox::question( + parent_widget, + tr("Charger une configuration", "message box title"), + tr("Cette configuration va remplacer vos réglages actuels, sauf la " + "disposition des fenêtres et la liste des fichiers récents.\n\n" + "QElectroTech va ensuite se fermer. Relancez-le pour utiliser " + "la nouvelle configuration.\n\n" + "Voulez-vous continuer ?"), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No); + if (answer != QMessageBox::Yes) { + return; + } + + //Asks to save any modified project, as quitQET() does. The settings + //are only replaced once every editor is closed, so that the editors + //saving their own settings on close cannot overwrite them, and a + //cancelled close leaves them untouched. + if (!closeEveryEditor()) { + return; + } + + QSettings live_settings; + ConfigProfile::importFrom(file_settings, live_settings); + //~QETApp() saves the colour dialog's custom colours on exit: load + //the profile's into it first, or the old ones overwrite them + QET::loadCustomColors(); + qApp->quit(); +} + /** @brief QETApp::aboutQET Open the dialog about qet. diff --git a/sources/qetapp.h b/sources/qetapp.h index 5c0b15106..2c2fa1d3e 100644 --- a/sources/qetapp.h +++ b/sources/qetapp.h @@ -291,6 +291,10 @@ class QETApp : public QObject void openTitleBlockTemplate(const QString &); void openTitleBlockTemplateFiles(const QStringList &); void configureQET(); + /// Save the settings to a file (discussion #610) + void exportConfiguration(); + /// Replace the settings with a saved file, then close QElectroTech + void importConfiguration(); void aboutQET(); void showDiagnosticsReport(); void receiveMessage(int instanceId, QByteArray message); diff --git a/sources/qetmainwindow.cpp b/sources/qetmainwindow.cpp index 1436f2668..36fccebf8 100644 --- a/sources/qetmainwindow.cpp +++ b/sources/qetmainwindow.cpp @@ -92,6 +92,16 @@ void QETMainWindow::initCommonActions() } }); + export_config_action_ = new QAction(QET::Icons::DocumentExport, tr("Enregistrer la configuration sous..."), this); + export_config_action_ -> setStatusTip(tr("Enregistre les réglages de QElectroTech dans un fichier", "status bar tip")); + connect(export_config_action_, &QAction::triggered, qet_app, &QETApp::exportConfiguration); + ShortcutManager::instance().registerAction(export_config_action_, "mainwindow.export_configuration", tr("Général"), QKeySequence()); + + import_config_action_ = new QAction(QET::Icons::DocumentImport, tr("Charger une configuration..."), this); + import_config_action_ -> setStatusTip(tr("Remplace les réglages de QElectroTech par ceux d'un fichier, puis ferme QElectroTech", "status bar tip")); + connect(import_config_action_, &QAction::triggered, qet_app, &QETApp::importConfiguration); + ShortcutManager::instance().registerAction(import_config_action_, "mainwindow.import_configuration", tr("Général"), QKeySequence()); + fullscreen_action_ = new QAction(this); updateFullScreenAction(); connect(fullscreen_action_, &QAction::triggered, this, &QETMainWindow::toggleFullScreen); @@ -170,6 +180,9 @@ void QETMainWindow::initCommonMenus() settings_menu_ = new QMenu(tr("&Configuration", "window menu"), this); settings_menu_ -> addAction(fullscreen_action_); settings_menu_ -> addAction(configure_action_); + settings_menu_ -> addSeparator(); + settings_menu_ -> addAction(export_config_action_); + settings_menu_ -> addAction(import_config_action_); connect(settings_menu_, &QMenu::aboutToShow, this, &QETMainWindow::checkToolbarsmenu); help_menu_ = new QMenu(tr("&Aide", "window menu"), this); diff --git a/sources/qetmainwindow.h b/sources/qetmainwindow.h index c3820c4bb..fe76fb7e0 100644 --- a/sources/qetmainwindow.h +++ b/sources/qetmainwindow.h @@ -56,6 +56,8 @@ class QETMainWindow : public QMainWindow { // attributes protected: QAction *configure_action_; ///< Launch the QElectroTech configuration dialog + QAction *export_config_action_; ///< Save the settings to a file + QAction *import_config_action_; ///< Replace the settings with a saved file QAction *fullscreen_action_; ///< Toggle full screen QAction *whatsthis_action_; ///< Toggle "What's this" mode QAction *about_qet_; ///< Launch the "About QElectroTech" dialog diff --git a/sources/utils/configprofile.h b/sources/utils/configprofile.h new file mode 100644 index 000000000..b235bc507 --- /dev/null +++ b/sources/utils/configprofile.h @@ -0,0 +1,98 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef CONFIGPROFILE_H +#define CONFIGPROFILE_H + +#include +#include +#include + +/** + Configuration profiles (discussion #610): the settings of QElectroTech + saved to a file, and loaded back from one. + + Two kinds of keys stay out of a profile, because they describe this + computer rather than how the user wants QElectroTech to behave: window + sizes, positions and dock layouts (".../geometry", ".../state"), and + the recent-files lists ("...-recentfiles/..."). They are not written to + the file, and loading a profile keeps the current ones. +*/ +namespace ConfigProfile +{ + /// Key written to every exported file, so that loading refuses a + /// file that was not exported by QElectroTech. Never copied into + /// the live settings. + inline const QString marker_key{QStringLiteral("qelectrotech-configuration/format")}; + + /// True for a key that stays out of a profile (see above). + inline bool isLocalKey(const QString &key) + { + return key.endsWith(QLatin1String("/geometry")) + || key.endsWith(QLatin1String("/state")) + || key.contains(QLatin1String("-recentfiles/")) + || key == marker_key; + } + + /// True if @a file was exported by exportTo(). + inline bool isProfile(const QSettings &file) + { + return file.value(marker_key).toInt() == 1; + } + + /// Replace the contents of @a file with every key of @a live, + /// except the local ones. Returns the number of keys written. + inline int exportTo(const QSettings &live, QSettings &file) + { + file.clear(); + int written = 0; + const QStringList keys = live.allKeys(); + for (const QString &key : keys) { + if (isLocalKey(key)) { + continue; + } + file.setValue(key, live.value(key)); + ++written; + } + file.setValue(marker_key, 1); + file.sync(); + return written; + } + + /// Replace every key of @a live, except the local ones, with the + /// keys of @a file. A key that is not in @a file is removed rather + /// than kept: loading a profile must not leave behind a setting + /// the previous profile made. Local keys in @a file are ignored. + inline void importFrom(const QSettings &file, QSettings &live) + { + const QStringList live_keys = live.allKeys(); + for (const QString &key : live_keys) { + if (!isLocalKey(key)) { + live.remove(key); + } + } + const QStringList file_keys = file.allKeys(); + for (const QString &key : file_keys) { + if (!isLocalKey(key)) { + live.setValue(key, file.value(key)); + } + } + live.sync(); + } +} + +#endif // CONFIGPROFILE_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index c8c159b15..29afaa992 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -111,6 +111,13 @@ add_test(NAME tst_alignment COMMAND tst_alignment) target_include_directories(tst_alignment PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_alignment PRIVATE Qt::Test) +# configprofile.h is header-only: which settings a saved configuration +# profile carries, and how loading one replaces the live settings. +add_executable(tst_configprofile tst_configprofile.cpp) +add_test(NAME tst_configprofile COMMAND tst_configprofile) +target_include_directories(tst_configprofile PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_configprofile PRIVATE Qt::Test Qt::Gui) + # itemgroups.cpp keeps group membership and the selection rule apart from # Diagram, so it is tested here on a plain scene of rectangles. add_executable(tst_itemgroups tst_itemgroups.cpp ${QET_DIR}/sources/itemgroups.cpp) diff --git a/tests/qttest/tst_configprofile.cpp b/tests/qttest/tst_configprofile.cpp new file mode 100644 index 000000000..e005e6f8d --- /dev/null +++ b/tests/qttest/tst_configprofile.cpp @@ -0,0 +1,100 @@ +#include +#include + +#include "utils/configprofile.h" + +class tst_configprofile : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + QString path(const QString &name) const { return m_dir.filePath(name); } + +private slots: + void localKeys() + { + QVERIFY(ConfigProfile::isLocalKey("diagrameditor/geometry")); + QVERIFY(ConfigProfile::isLocalKey("elementeditor/state")); + QVERIFY(ConfigProfile::isLocalKey("projects-recentfiles/file1")); + QVERIFY(ConfigProfile::isLocalKey(ConfigProfile::marker_key)); + QVERIFY(!ConfigProfile::isLocalKey("diagrameditor/Xgrid")); + QVERIFY(!ConfigProfile::isLocalKey("shortcuts/mainwindow.fullscreen")); + QVERIFY(!ConfigProfile::isLocalKey("masterpropertieswidget/plc-table-header-state")); + } + + // Export leaves out window layout and recent files, and marks the file. + void exportLeavesOutLocalKeys() + { + QSettings live(path("live1.conf"), QSettings::IniFormat); + live.setValue("diagrameditor/Xgrid", 7); + live.setValue("lang", "de"); + live.setValue("diagrameditor/geometry", QByteArray("xyz")); + live.setValue("projects-recentfiles/file1", "/home/a/b.qet"); + + QSettings file(path("profile1.conf"), QSettings::IniFormat); + QCOMPARE(ConfigProfile::exportTo(live, file), 2); + QVERIFY(ConfigProfile::isProfile(file)); + QCOMPARE(file.value("diagrameditor/Xgrid").toInt(), 7); + QVERIFY(!file.contains("diagrameditor/geometry")); + QVERIFY(!file.contains("projects-recentfiles/file1")); + } + + // Import replaces every setting, removes the ones the profile does not + // have, keeps window layout and recent files, and never copies the marker. + void importReplacesButKeepsLocal() + { + QSettings file(path("profile2.conf"), QSettings::IniFormat); + file.setValue("diagrameditor/Xgrid", 5); + file.setValue("diagrameditor/geometry", QByteArray("from-file")); + file.setValue(ConfigProfile::marker_key, 1); + + QSettings live(path("live2.conf"), QSettings::IniFormat); + live.setValue("diagrameditor/Xgrid", 10); + live.setValue("lang", "fr"); + live.setValue("diagrameditor/geometry", QByteArray("mine")); + live.setValue("projects-recentfiles/file1", "/home/a/b.qet"); + + ConfigProfile::importFrom(file, live); + QCOMPARE(live.value("diagrameditor/Xgrid").toInt(), 5); + QVERIFY(!live.contains("lang")); + QCOMPARE(live.value("diagrameditor/geometry").toByteArray(), QByteArray("mine")); + QCOMPARE(live.value("projects-recentfiles/file1").toString(), QString("/home/a/b.qet")); + QVERIFY(!live.contains(ConfigProfile::marker_key)); + } + + // Values survive the round trip with their types, including arrays. + void roundTrip() + { + QSettings a(path("a.conf"), QSettings::IniFormat); + a.setValue("print/default/fitinpage", true); + a.setValue("diagrameditor/sheet_background_color", QColor(12, 34, 56)); + a.beginWriteArray("diagrameditor/defaultguides", 2); + a.setArrayIndex(0); a.setValue("pos", 100); + a.setArrayIndex(1); a.setValue("pos", 250); + a.endArray(); + + QSettings file(path("profile3.conf"), QSettings::IniFormat); + ConfigProfile::exportTo(a, file); + QSettings b(path("b.conf"), QSettings::IniFormat); + b.setValue("stray", 1); + ConfigProfile::importFrom(file, b); + + QStringList ka = a.allKeys(), kb = b.allKeys(); + ka.sort(); kb.sort(); + QCOMPARE(kb, ka); + for (const QString &k : std::as_const(ka)) + QCOMPARE(b.value(k), a.value(k)); + } + + void plainFileIsNotAProfile() + { + QSettings other(path("other.conf"), QSettings::IniFormat); + other.setValue("some/key", 1); + QVERIFY(!ConfigProfile::isProfile(other)); + } +}; + +QTEST_GUILESS_MAIN(tst_configprofile) + +#include "tst_configprofile.moc"