diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index c0b172539..9fddcead5 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -61,14 +61,19 @@ #include #define QUOTE(x) STRINGIFY(x) #define STRINGIFY(x) #x +#include +#include +#include #include #include #include +#include #include #include #include #include #include +#include #ifdef BUILD_WITHOUT_KF # include "ui/nokde/kautosavefile.h" #else @@ -2291,6 +2296,88 @@ void QETApp::customizeQET(int tab) dialog.setParent(nullptr, dialog.windowFlags()); } +namespace { +/** + @brief partLabel + @return the name of @a part shown to the user +*/ +QString partLabel(ConfigProfile::Part part) +{ + switch (part) { + case ConfigProfile::Part::Controls: + return QETApp::tr("Toolbars, keyboard shortcuts, mouse and trackpad gestures"); + case ConfigProfile::Part::NewProject: + return QETApp::tr("Defaults for new projects: folio, title block, " + "wires, numbering"); + case ConfigProfile::Part::Other: + return QETApp::tr("All other settings: appearance, grid, language..."); + case ConfigProfile::Part::Folders: + return QETApp::tr("Folders of the collections"); + } + return QString(); +} + +/** + @brief choosePartsToSave + Ask which parts of the settings to save, all of them ticked to begin + with. + @return the parts chosen, or an empty list if the user cancelled +*/ +QList choosePartsToSave(QWidget *parent) +{ + QDialog dialog(parent); + dialog.setWindowTitle(QETApp::tr("Save settings as...", "dialog title")); + + auto *layout = new QVBoxLayout(&dialog); + layout->addWidget(new QLabel(QETApp::tr("Settings to save:"), &dialog)); + + QMap boxes; + for (ConfigProfile::Part part : ConfigProfile::choosableParts()) { + auto *box = new QCheckBox(partLabel(part), &dialog); + box->setChecked(true); + layout->addWidget(box); + boxes.insert(part, box); + } + + auto *note = new QLabel(QETApp::tr("The folders of your collections are " + "saved only when every part is ticked."), + &dialog); + note->setWordWrap(true); + layout->addWidget(note); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok + | QDialogButtonBox::Cancel, + &dialog); + layout->addWidget(buttons); + QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + + //Nothing ticked, nothing to save + auto update_ok = [&boxes, buttons]() { + bool any = false; + for (QCheckBox *box : std::as_const(boxes)) { + any = any || box->isChecked(); + } + buttons->button(QDialogButtonBox::Ok)->setEnabled(any); + }; + for (QCheckBox *box : std::as_const(boxes)) { + QObject::connect(box, &QCheckBox::toggled, &dialog, update_ok); + } + + if (dialog.exec() != QDialog::Accepted) { + return {}; + } + + QList parts; + for (ConfigProfile::Part part : ConfigProfile::choosableParts()) { + if (boxes.value(part)->isChecked()) { + parts << part; + } + } + return parts; +} +} + /** @brief QETApp::exportConfiguration Save the settings of QElectroTech to a file the user chooses, to keep @@ -2298,11 +2385,19 @@ void QETApp::customizeQET(int tab) #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. + + The user first chooses which parts of the settings to save, to share + the defaults for new projects alone for example (discussion #1405). */ void QETApp::exportConfiguration() { QWidget *parent_widget = qApp->activeWindow(); + const QList parts = choosePartsToSave(parent_widget); + if (parts.isEmpty()) { + return; + } + QString path = QFileDialog::getSaveFileName( parent_widget, tr("Save settings as...", "dialog title"), @@ -2317,7 +2412,7 @@ void QETApp::exportConfiguration() QSettings live_settings; QSettings file_settings(path, QSettings::IniFormat); - ConfigProfile::exportTo(live_settings, file_settings); + ConfigProfile::exportTo(live_settings, file_settings, parts); if (file_settings.status() != QSettings::NoError) { QET::QetMessageBox::critical( @@ -2330,7 +2425,10 @@ void QETApp::exportConfiguration() /** @brief QETApp::importConfiguration Replace the settings of QElectroTech with a file saved by - exportConfiguration(), then close QElectroTech. + exportConfiguration(), then close QElectroTech. A file holding only + some parts of the settings replaces only those parts. A file holding + only the defaults for new projects needs no restart: they are read + again for every new project. 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 @@ -2352,8 +2450,10 @@ void QETApp::importConfiguration() } QSettings file_settings(path, QSettings::IniFormat); + const QList parts = ConfigProfile::partsOf(file_settings); if (file_settings.status() != QSettings::NoError - || !ConfigProfile::isProfile(file_settings)) + || !ConfigProfile::isProfile(file_settings) + || parts.isEmpty()) { QET::QetMessageBox::critical( parent_widget, @@ -2362,24 +2462,57 @@ void QETApp::importConfiguration() return; } + const bool complete = parts.contains(ConfigProfile::Part::Folders); + const bool restart = ConfigProfile::needsRestart(parts); + QString question; + if (complete) { + question = tr("These settings will replace your current ones, except the " + "window layout and the list of recent files.\n" + "\n" + "QElectroTech will then close. Start it again to use the new " + "settings.\n" + "\n" + "Do you want to continue?"); + } else { + QStringList labels; + for (ConfigProfile::Part part : parts) { + labels << QStringLiteral("• ") + partLabel(part); + } + question = tr("These settings will replace your current ones for:\n" + "%1\n" + "All your other settings are kept.") + .arg(labels.join(QLatin1Char('\n'))); + question += QStringLiteral("\n\n"); + question += restart + ? tr("QElectroTech will then close. Start it again to use the new " + "settings.") + : tr("Projects already open keep their own defaults."); + question += QStringLiteral("\n\n") + tr("Do you want to continue?"); + } + //Said before anything closes: once the last window is closed, //QElectroTech quits by itself (checkRemainingWindows()). const auto answer = QET::QetMessageBox::question( parent_widget, tr("Load settings", "message box title"), - tr("These settings will replace your current ones, except the " - "window layout and the list of recent files.\n" - "\n" - "QElectroTech will then close. Start it again to use the new " - "settings.\n" - "\n" - "Do you want to continue?"), + question, QMessageBox::Yes | QMessageBox::No, QMessageBox::No); if (answer != QMessageBox::Yes) { return; } + if (!restart) { + QSettings live_settings; + ConfigProfile::importFrom(file_settings, live_settings); + QET::QetMessageBox::information( + parent_widget, + tr("Load settings", "message box title"), + tr("The settings are loaded. They are used for the next " + "new project.")); + 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 diff --git a/sources/utils/configprofile.h b/sources/utils/configprofile.h index b235bc507..93d08b4b7 100644 --- a/sources/utils/configprofile.h +++ b/sources/utils/configprofile.h @@ -18,6 +18,7 @@ #ifndef CONFIGPROFILE_H #define CONFIGPROFILE_H +#include #include #include #include @@ -28,71 +29,201 @@ 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 + sizes, positions and dock layouts (".../geometry", ".../state", + "dialoggeometry/..."), and the recent-files lists ("...-recentfiles/..."). They are not written to the file, and loading a profile keeps the current ones. + + A file can hold every other setting (a complete profile), or only some + parts of them (discussion #1405), for example the defaults for new + projects alone, to hand to someone else. Loading a file replaces only + the parts it holds and keeps every other setting. */ 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. + /// the live settings. 1 = a complete profile, 2 = some parts only. + /// A QElectroTech that only knows 1 refuses a file of some parts: + /// it would load it as a complete one and lose every other setting. inline const QString marker_key{QStringLiteral("qelectrotech-configuration/format")}; + /// Key listing the parts a file of format 2 holds, see partName(). + inline const QString parts_key{QStringLiteral("qelectrotech-configuration/parts")}; + + /// The parts the settings are split into. + enum class Part { + Controls, ///< toolbars, keyboard shortcuts, shortcut bar, mouse and trackpad gestures + NewProject, ///< defaults for new projects: folio, title block, wires, numbering... + Other, ///< everything else: appearance, grid, language... + Folders ///< folders of the collections, title blocks and macros + }; + + /// The parts a user can choose to save. Folders are not one of + /// them: they are only saved in a complete profile, because they + /// rarely exist on another computer. + inline QList choosableParts() + { + return {Part::Controls, Part::NewProject, Part::Other}; + } + /// 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.startsWith(QLatin1String("dialoggeometry/")) || key.contains(QLatin1String("-recentfiles/")) - || key == marker_key; + || key == marker_key + || key == parts_key; + } + + /// The part @a key belongs to. Every key belongs to exactly one + /// part: a key not listed here is Other. + inline Part partOf(const QString &key) + { + static const QStringList controls{ + QStringLiteral("shortcuts/"), + QStringLiteral("toolbars/"), + QStringLiteral("diagrameditor/toolbars/"), + QStringLiteral("diagrameditor/custom_toolbars/"), + QStringLiteral("diagrameditor/shortcut_bar/"), + QStringLiteral("diagrameditor/gestures/"), + QStringLiteral("diagrameditor/mouse_gestures"), + QStringLiteral("diagrameditor/context_toolbar"), + QStringLiteral("diagramview/gestures")}; + static const QStringList new_project{ + //BorderProperties, TitleBlockProperties, ConductorProperties, + //report, cross-reference and guide defaults all start so + QStringLiteral("diagrameditor/default"), + QStringLiteral("autonum/")}; + + for (const QString &prefix : controls) { + if (key.startsWith(prefix)) return Part::Controls; + } + for (const QString &prefix : new_project) { + if (key.startsWith(prefix)) return Part::NewProject; + } + if (key.startsWith(QLatin1String("elements-collections/")) + && key.endsWith(QLatin1String("-path"))) { + return Part::Folders; + } + return Part::Other; + } + + /// Name of @a part in the parts_key of a file. + inline QString partName(Part part) + { + switch (part) { + case Part::Controls: return QStringLiteral("controls"); + case Part::NewProject: return QStringLiteral("newproject"); + case Part::Other: return QStringLiteral("other"); + case Part::Folders: return QStringLiteral("folders"); + } + return QString(); } /// True if @a file was exported by exportTo(). inline bool isProfile(const QSettings &file) { - return file.value(marker_key).toInt() == 1; + const int format = file.value(marker_key).toInt(); + return format == 1 || format == 2; } - /// 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) + /// The parts @a file holds: every part for a complete profile. + inline QList partsOf(const QSettings &file) { + const QList all{Part::Controls, Part::NewProject, + Part::Other, Part::Folders}; + if (file.value(marker_key).toInt() != 2) { + return all; + } + const QStringList names = file.value(parts_key).toString() + .split(QLatin1Char(','), Qt::SkipEmptyParts); + QList parts; + for (Part part : all) { + if (names.contains(partName(part))) { + parts << part; + } + } + return parts; + } + + /// Replace the contents of @a file with the keys of @a live that + /// belong to @a parts, except the local ones. With every choosable + /// part, the file is a complete profile, folders included. Returns + /// the number of keys written. + inline int exportTo(const QSettings &live, QSettings &file, + QList parts = choosableParts()) + { + const bool complete = parts.contains(Part::Controls) + && parts.contains(Part::NewProject) + && parts.contains(Part::Other); + if (complete) { + parts << Part::Folders; + } else { + parts.removeAll(Part::Folders); + } + file.clear(); int written = 0; const QStringList keys = live.allKeys(); for (const QString &key : keys) { - if (isLocalKey(key)) { + if (isLocalKey(key) || !parts.contains(partOf(key))) { continue; } file.setValue(key, live.value(key)); ++written; } - file.setValue(marker_key, 1); + if (complete) { + file.setValue(marker_key, 1); + } else { + QStringList names; + for (Part part : std::as_const(parts)) { + names << partName(part); + } + file.setValue(marker_key, 2); + file.setValue(parts_key, names.join(QLatin1Char(','))); + } 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. + /// Replace every key of @a live that belongs to a part @a file + /// holds, except the local ones, with the keys of @a file. A key + /// of such a part that is not in @a file is removed rather than + /// kept: loading a profile must not leave behind a setting the + /// previous profile made. Keys of the other parts are kept. Local + /// keys in @a file are ignored. inline void importFrom(const QSettings &file, QSettings &live) { + const QList parts = partsOf(file); const QStringList live_keys = live.allKeys(); for (const QString &key : live_keys) { - if (!isLocalKey(key)) { + if (!isLocalKey(key) && parts.contains(partOf(key))) { live.remove(key); } } const QStringList file_keys = file.allKeys(); for (const QString &key : file_keys) { - if (!isLocalKey(key)) { + if (!isLocalKey(key) && parts.contains(partOf(key))) { live.setValue(key, file.value(key)); } } live.sync(); } + + /// True if loading @a parts needs QElectroTech to start again. + /// The defaults for new projects are read again for every new + /// project; every other part is read once, at start. + inline bool needsRestart(const QList &parts) + { + for (Part part : parts) { + if (part != Part::NewProject) { + return true; + } + } + return false; + } } #endif // CONFIGPROFILE_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 29c339e6f..c7101c9f9 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -187,10 +187,13 @@ target_link_libraries(tst_symbolscale 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) +# borderproperties.cpp: the real writer of the folio size defaults, to check +# that what it writes falls in the part for new projects. +add_executable(tst_configprofile tst_configprofile.cpp + ${QET_DIR}/sources/borderproperties.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) +target_link_libraries(tst_configprofile PRIVATE Qt::Test Qt::Gui Qt::Xml) # itemgroups.cpp keeps group membership and the selection rule apart from # Diagram, so it is tested here on a plain scene of rectangles. diff --git a/tests/qttest/tst_configprofile.cpp b/tests/qttest/tst_configprofile.cpp index e005e6f8d..73d871348 100644 --- a/tests/qttest/tst_configprofile.cpp +++ b/tests/qttest/tst_configprofile.cpp @@ -2,6 +2,7 @@ #include #include "utils/configprofile.h" +#include "borderproperties.h" class tst_configprofile : public QObject { @@ -17,6 +18,7 @@ private slots: QVERIFY(ConfigProfile::isLocalKey("diagrameditor/geometry")); QVERIFY(ConfigProfile::isLocalKey("elementeditor/state")); QVERIFY(ConfigProfile::isLocalKey("projects-recentfiles/file1")); + QVERIFY(ConfigProfile::isLocalKey("dialoggeometry/ConfigDialog")); QVERIFY(ConfigProfile::isLocalKey(ConfigProfile::marker_key)); QVERIFY(!ConfigProfile::isLocalKey("diagrameditor/Xgrid")); QVERIFY(!ConfigProfile::isLocalKey("shortcuts/mainwindow.fullscreen")); @@ -31,6 +33,7 @@ private slots: live.setValue("lang", "de"); live.setValue("diagrameditor/geometry", QByteArray("xyz")); live.setValue("projects-recentfiles/file1", "/home/a/b.qet"); + live.setValue("dialoggeometry/ConfigDialog", QByteArray("xyz")); QSettings file(path("profile1.conf"), QSettings::IniFormat); QCOMPARE(ConfigProfile::exportTo(live, file), 2); @@ -38,6 +41,7 @@ private slots: QCOMPARE(file.value("diagrameditor/Xgrid").toInt(), 7); QVERIFY(!file.contains("diagrameditor/geometry")); QVERIFY(!file.contains("projects-recentfiles/file1")); + QVERIFY(!file.contains("dialoggeometry/ConfigDialog")); } // Import replaces every setting, removes the ones the profile does not @@ -87,6 +91,134 @@ private slots: QCOMPARE(b.value(k), a.value(k)); } + // Every key belongs to one part; keys nobody listed are Other. + void partOfKeys() + { + using P = ConfigProfile::Part; + QCOMPARE(ConfigProfile::partOf("shortcuts/mainwindow.fullscreen"), P::Controls); + QCOMPARE(ConfigProfile::partOf("toolbars/icon_size"), P::Controls); + QCOMPARE(ConfigProfile::partOf("diagrameditor/toolbars/diagram"), P::Controls); + QCOMPARE(ConfigProfile::partOf("diagrameditor/custom_toolbars/names"), P::Controls); + QCOMPARE(ConfigProfile::partOf("diagrameditor/shortcut_bar/canvas"), P::Controls); + QCOMPARE(ConfigProfile::partOf("diagrameditor/gestures/directions"), P::Controls); + QCOMPARE(ConfigProfile::partOf("diagrameditor/mouse_gestures"), P::Controls); + QCOMPARE(ConfigProfile::partOf("diagrameditor/context_toolbar"), P::Controls); + QCOMPARE(ConfigProfile::partOf("diagramview/gestures"), P::Controls); + + QCOMPARE(ConfigProfile::partOf("diagrameditor/defaultconductortype"), P::NewProject); + QCOMPARE(ConfigProfile::partOf("diagrameditor/defaultreportlabel"), P::NewProject); + QCOMPARE(ConfigProfile::partOf("diagrameditor/defaultxrefcoil"), P::NewProject); + QCOMPARE(ConfigProfile::partOf("diagrameditor/defaultguides/1/position"), P::NewProject); + QCOMPARE(ConfigProfile::partOf("autonum/conductor/current"), P::NewProject); + + QCOMPARE(ConfigProfile::partOf("elements-collections/custom-collection-path"), P::Folders); + QCOMPARE(ConfigProfile::partOf("elements-collections/macros-path"), P::Folders); + + QCOMPARE(ConfigProfile::partOf("diagrameditor/Xgrid"), P::Other); + QCOMPARE(ConfigProfile::partOf("diagrameditor/auto_break_conductor"), P::Other); + QCOMPARE(ConfigProfile::partOf("lang"), P::Other); + QCOMPARE(ConfigProfile::partOf("customColors"), P::Other); + } + + // What the New project page writes for the folio size is the + // NewProject part, whatever key names BorderProperties uses. + void folioDefaultsAreNewProject() + { + QSettings live(path("live_border.conf"), QSettings::IniFormat); + BorderProperties().toSettings(live, "diagrameditor/default"); + const QStringList keys = live.allKeys(); + QVERIFY(!keys.isEmpty()); + for (const QString &key : keys) + QCOMPARE(ConfigProfile::partOf(key), ConfigProfile::Part::NewProject); + } + + // A file of some parts holds only their keys, never the folders, and + // is format 2 so that an older QElectroTech refuses it. + void exportSomeParts() + { + QSettings live(path("live4.conf"), QSettings::IniFormat); + live.setValue("shortcuts/mainwindow.fullscreen", "F11"); + live.setValue("diagrameditor/defaultconductortype", "Multi"); + live.setValue("diagrameditor/Xgrid", 7); + live.setValue("elements-collections/custom-collection-path", "/home/a/sym"); + + QSettings file(path("profile4.conf"), QSettings::IniFormat); + QCOMPARE(ConfigProfile::exportTo(live, file, {ConfigProfile::Part::NewProject}), 1); + QCOMPARE(file.value(ConfigProfile::marker_key).toInt(), 2); + QVERIFY(ConfigProfile::isProfile(file)); + QCOMPARE(ConfigProfile::partsOf(file), QList{ConfigProfile::Part::NewProject}); + QVERIFY(file.contains("diagrameditor/defaultconductortype")); + QVERIFY(!file.contains("shortcuts/mainwindow.fullscreen")); + QVERIFY(!file.contains("diagrameditor/Xgrid")); + QVERIFY(!file.contains("elements-collections/custom-collection-path")); + + //Other without the two others: still no folders + QSettings file2(path("profile4b.conf"), QSettings::IniFormat); + ConfigProfile::exportTo(live, file2, {ConfigProfile::Part::Other}); + QVERIFY(file2.contains("diagrameditor/Xgrid")); + QVERIFY(!file2.contains("elements-collections/custom-collection-path")); + } + + // Every choosable part is a complete profile: format 1, folders + // included, readable by the QElectroTech that only knows format 1. + void exportEveryPartIsComplete() + { + QSettings live(path("live5.conf"), QSettings::IniFormat); + live.setValue("diagrameditor/Xgrid", 7); + live.setValue("elements-collections/custom-collection-path", "/home/a/sym"); + + QSettings file(path("profile5.conf"), QSettings::IniFormat); + ConfigProfile::exportTo(live, file, ConfigProfile::choosableParts()); + QCOMPARE(file.value(ConfigProfile::marker_key).toInt(), 1); + QVERIFY(!file.contains(ConfigProfile::parts_key)); + QVERIFY(file.contains("elements-collections/custom-collection-path")); + QCOMPARE(ConfigProfile::partsOf(file).size(), 4); + } + + // Loading some parts replaces those parts only, removes their keys the + // file lacks, and keeps every other setting, folders included. + void importSomePartsKeepsTheRest() + { + QSettings file(path("profile6.conf"), QSettings::IniFormat); + file.setValue("diagrameditor/defaultconductortype", "Single"); + file.setValue("diagrameditor/Xgrid", 99); // not in its parts: ignored + file.setValue(ConfigProfile::marker_key, 2); + file.setValue(ConfigProfile::parts_key, "newproject"); + + QSettings live(path("live6.conf"), QSettings::IniFormat); + live.setValue("diagrameditor/defaultconductortype", "Multi"); + live.setValue("autonum/folio/current", "mine"); + live.setValue("diagrameditor/Xgrid", 10); + live.setValue("shortcuts/mainwindow.fullscreen", "F11"); + live.setValue("elements-collections/custom-collection-path", "/home/me/sym"); + + ConfigProfile::importFrom(file, live); + QCOMPARE(live.value("diagrameditor/defaultconductortype").toString(), QString("Single")); + QVERIFY(!live.contains("autonum/folio/current")); + QCOMPARE(live.value("diagrameditor/Xgrid").toInt(), 10); + QCOMPARE(live.value("shortcuts/mainwindow.fullscreen").toString(), QString("F11")); + QCOMPARE(live.value("elements-collections/custom-collection-path").toString(), + QString("/home/me/sym")); + QVERIFY(!live.contains(ConfigProfile::parts_key)); + } + + void restartOnlyWhenNeeded() + { + using P = ConfigProfile::Part; + QVERIFY(!ConfigProfile::needsRestart({P::NewProject})); + QVERIFY(ConfigProfile::needsRestart({P::NewProject, P::Controls})); + QVERIFY(ConfigProfile::needsRestart({P::Other})); + } + + // A file of format 2 naming no known part is refused by the caller. + void unknownPartsGiveNothing() + { + QSettings file(path("profile7.conf"), QSettings::IniFormat); + file.setValue(ConfigProfile::marker_key, 2); + file.setValue(ConfigProfile::parts_key, "colours"); + QVERIFY(ConfigProfile::partsOf(file).isEmpty()); + } + void plainFileIsNotAProfile() { QSettings other(path("other.conf"), QSettings::IniFormat);