Merge pull request #1166 from ispyisail/revive/640-config-profiles

Add saving and loading the configuration to a file
This commit is contained in:
ispyisail
2026-09-30 11:35:54 +13:00
committed by GitHub
7 changed files with 330 additions and 0 deletions
+106
View File
@@ -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 <iostream>
#define QUOTE(x) STRINGIFY(x)
#define STRINGIFY(x) #x
#include <QFileDialog>
#include <QFontDatabase>
#include <QProcessEnvironment>
#include <QRegularExpression>
#include <QSettings>
#include <QStyleFactory>
#include <QStyleHints>
#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.
+4
View File
@@ -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);
+13
View File
@@ -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);
+2
View File
@@ -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
+98
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef CONFIGPROFILE_H
#define CONFIGPROFILE_H
#include <QSettings>
#include <QString>
#include <QStringList>
/**
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
+7
View File
@@ -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)
+100
View File
@@ -0,0 +1,100 @@
#include <QtTest>
#include <QTemporaryDir>
#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"