diff --git a/CMakeLists.txt b/CMakeLists.txt
index 6af925c12..1694a98c0 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -332,6 +332,7 @@ target_include_directories(
${QET_DIR}/sources/factory/ui
${QET_DIR}/sources/print
${QET_DIR}/sources/svg
+ ${QET_DIR}/sources/materiallist
)
if(NOT BUILD_WITH_KF)
diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake
index 9f92fcc3e..f46101bfa 100644
--- a/cmake/qet_compilation_vars.cmake
+++ b/cmake/qet_compilation_vars.cmake
@@ -73,6 +73,7 @@ set(QET_RES_FILES
${QET_DIR}/sources/editor/ui/terminaleditor.ui
${QET_DIR}/sources/ElementsCollection/ui/renamedialog.ui
${QET_DIR}/sources/factory/ui/addtabledialog.ui
+ ${QET_DIR}/sources/materiallist/materialselectiondialog.ui
${QET_DIR}/sources/NameList/ui/namelistdialog.ui
${QET_DIR}/sources/NameList/ui/namelistwidget.ui
${QET_DIR}/sources/print/projectprintwindow.ui
@@ -863,6 +864,13 @@ set(QET_SRC_FILES
${QET_DIR}/sources/utils/qetutils.cpp
${QET_DIR}/sources/utils/qetutils.h
+ ${QET_DIR}/sources/materiallist/materialentrydialog.cpp
+ ${QET_DIR}/sources/materiallist/materialentrydialog.h
+ ${QET_DIR}/sources/materiallist/materiallist.cpp
+ ${QET_DIR}/sources/materiallist/materiallist.h
+ ${QET_DIR}/sources/materiallist/materialselectiondialog.cpp
+ ${QET_DIR}/sources/materiallist/materialselectiondialog.h
+
${QET_DIR}/sources/xml/terminalstripitemxml.cpp
${QET_DIR}/sources/xml/terminalstripitemxml.h
${QET_DIR}/sources/xml/terminalstriplayoutpatternxml.cpp
diff --git a/sources/materiallist/materialentrydialog.cpp b/sources/materiallist/materialentrydialog.cpp
new file mode 100644
index 000000000..2795df12f
--- /dev/null
+++ b/sources/materiallist/materialentrydialog.cpp
@@ -0,0 +1,144 @@
+/*
+ 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 .
+*/
+#include "materialentrydialog.h"
+
+#include "../qetmessagebox.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+/**
+ @brief MaterialEntryDialog::MaterialEntryDialog
+ Build one field per column of the material file.
+ @param columns the columns of the file, in file order
+ @param parent parent widget
+*/
+MaterialEntryDialog::MaterialEntryDialog(const QStringList &columns, QWidget *parent) :
+ QDialog(parent),
+ m_columns(columns)
+{
+ setWindowTitle(tr("Nouvelle entrée"));
+
+ //Two fields per row : the form shows the whole article at once
+ //instead of stacking twenty lines in a narrow column.
+ setMinimumSize(780, 560);
+ resize(1120, 800);
+
+ auto *main_layout = new QVBoxLayout(this);
+
+ auto *intro = new QLabel(
+ tr("Renseignez l'article à ajouter. Les champs laissés vides "
+ "restent vides dans le fichier."), this);
+ intro->setWordWrap(true);
+ main_layout->addWidget(intro);
+
+ auto *scroll_area = new QScrollArea(this);
+ scroll_area->setWidgetResizable(true);
+ scroll_area->setFrameShape(QFrame::NoFrame);
+
+ auto *container = new QWidget(scroll_area);
+ auto *grid = new QGridLayout(container);
+
+ for (int i = 0; i < m_columns.size(); ++i)
+ {
+ const QString column = m_columns.at(i);
+
+ auto *label = new QLabel(MaterialList::translatedColumn(column) + QLatin1Char(' '), container);
+ label->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
+
+ auto *edit = new QLineEdit(container);
+ edit->setClearButtonEnabled(true);
+
+ label->setBuddy(edit);
+ const int row = i / 2;
+ const int pair = i % 2;
+ grid->addWidget(label, row, pair * 2);
+ grid->addWidget(edit, row, pair * 2 + 1);
+ m_edits.append(edit);
+ }
+ grid->setColumnStretch(1, 1);
+ grid->setColumnStretch(3, 1);
+
+ scroll_area->setWidget(container);
+ main_layout->addWidget(scroll_area, 1);
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
+ buttons->button(QDialogButtonBox::Ok)->setText(tr("Enregistrer"));
+ buttons->button(QDialogButtonBox::Cancel)->setText(tr("Annuler"));
+ connect(buttons, &QDialogButtonBox::accepted, this, &MaterialEntryDialog::accept);
+ connect(buttons, &QDialogButtonBox::rejected, this, &MaterialEntryDialog::reject);
+ main_layout->addWidget(buttons);
+
+ if (!m_edits.isEmpty()) {
+ m_edits.first()->setFocus();
+ }
+}
+
+/**
+ @brief MaterialEntryDialog::~MaterialEntryDialog
+ Destructor
+*/
+MaterialEntryDialog::~MaterialEntryDialog() = default;
+
+/**
+ @brief MaterialEntryDialog::record
+ @return the article described by the form. Empty fields are left out :
+ an empty column never means anything for an article which is being
+ created.
+*/
+MaterialRecord MaterialEntryDialog::record() const
+{
+ MaterialRecord record;
+
+ for (int i = 0; i < m_columns.size() && i < m_edits.size(); ++i)
+ {
+ QString value = m_edits.at(i)->text();
+ value.remove(QLatin1Char('\r'));
+ value.remove(QLatin1Char('\n'));
+ value = value.trimmed();
+ if (!value.isEmpty()) {
+ record.setValue(m_columns.at(i), value);
+ }
+ }
+
+ return record;
+}
+
+/**
+ @brief MaterialEntryDialog::accept
+ Refuse an empty form: an article without any information is not worth
+ a line in the file.
+*/
+void MaterialEntryDialog::accept()
+{
+ if (record().values.isEmpty())
+ {
+ QET::QetMessageBox::warning(this,
+ tr("Aucun renseignement"),
+ tr("Saisissez au moins un renseignement "
+ "pour créer une entrée."));
+ return;
+ }
+
+ QDialog::accept();
+}
diff --git a/sources/materiallist/materialentrydialog.h b/sources/materiallist/materialentrydialog.h
new file mode 100644
index 000000000..0ab51f6ac
--- /dev/null
+++ b/sources/materiallist/materialentrydialog.h
@@ -0,0 +1,54 @@
+/*
+ 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 MATERIALENTRYDIALOG_H
+#define MATERIALENTRYDIALOG_H
+
+#include "../materiallist/materiallist.h"
+
+#include
+#include
+
+class QLineEdit;
+
+/**
+ @brief The form used to add an article to the material file.
+
+ One field per column of the file, including the columns QElectroTech
+ does not know : whatever the user typed there is written back to the
+ file untouched when a later row is appended.
+*/
+class MaterialEntryDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit MaterialEntryDialog(const QStringList &columns, QWidget *parent = nullptr);
+ ~MaterialEntryDialog() override;
+
+ /** @brief the article described by the form */
+ MaterialRecord record() const;
+
+ void accept() override;
+
+ //ATTRIBUTES
+ private:
+ QStringList m_columns;
+ QList m_edits;
+};
+
+#endif // MATERIALENTRYDIALOG_H
diff --git a/sources/materiallist/materiallist.cpp b/sources/materiallist/materiallist.cpp
new file mode 100644
index 000000000..e84b1ec1c
--- /dev/null
+++ b/sources/materiallist/materiallist.cpp
@@ -0,0 +1,778 @@
+/*
+ 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 .
+*/
+#include "materiallist.h"
+
+#include "../qetinformation.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+namespace {
+
+/**
+ @brief Decode a csv file content.
+ The material file is written in utf-8 with a BOM, the same way the bill
+ of material export does, so spreadsheets open it with the right
+ encoding. A file written by hand in the local encoding is still
+ decodable: fromUtf8() is only used when the bytes are valid utf-8.
+ @param raw
+ @return
+*/
+QString decode(const QByteArray &raw)
+{
+ if (raw.startsWith("\xEF\xBB\xBF")) {
+ return QString::fromUtf8(raw.constData() + 3, raw.size() - 3);
+ }
+
+ const QByteArray utf8 = QString::fromUtf8(raw).toUtf8();
+ if (utf8 == raw) {
+ return QString::fromUtf8(raw);
+ }
+ return QString::fromLocal8Bit(raw);
+}
+
+/**
+ @brief Tell whether a file exists and holds something.
+ An existing but empty file is the same as no file at all: it can be
+ filled with the template without losing anything.
+ @param path
+ @return
+*/
+bool hasContent(const QString &path)
+{
+ const QFileInfo info(path);
+ return info.exists() && info.isFile() && info.size() > 0;
+}
+
+} // namespace
+
+/**
+ @brief MaterialList::settingsKey
+ @return the QSettings key holding the path of the material file
+*/
+QString MaterialList::settingsKey()
+{
+ return QStringLiteral("elements-collections/material-list-path");
+}
+
+/**
+ @brief MaterialList::configuredPath
+ @return the path of the material file as chosen in the preferences,
+ or a null QString when the user never set one.
+*/
+QString MaterialList::configuredPath()
+{
+ QSettings settings;
+ const QString path = settings.value(settingsKey(), QStringLiteral("default")).toString();
+ if (path.isEmpty() || path == QLatin1String("default")) {
+ return QString();
+ }
+ return path;
+}
+
+/**
+ @brief MaterialList::setConfiguredPath
+ Store the path of the material file in the preferences.
+ @param path
+*/
+void MaterialList::setConfiguredPath(const QString &path)
+{
+ QSettings settings;
+ settings.setValue(settingsKey(), path.isEmpty() ? QStringLiteral("default") : path);
+}
+
+/**
+ @brief MaterialList::defaultFileName
+ @return the file name used when the material file is created without an
+ explicit location.
+*/
+QString MaterialList::defaultFileName()
+{
+ return QStringLiteral("qet_material_list.csv");
+}
+
+/**
+ @brief MaterialList::defaultPath
+ @return where the material file is created when no path is configured
+ yet : the standard documents folder.
+*/
+QString MaterialList::defaultPath()
+{
+ QString dir = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
+ while (dir.endsWith(QLatin1Char('/'))) {
+ dir.chop(1);
+ }
+ if (dir.isEmpty()) {
+ dir = QDir::currentPath();
+ }
+ return dir + QLatin1Char('/') + defaultFileName();
+}
+
+/**
+ @brief MaterialList::savedHeaderState
+ @return the saved column layout of the selection window, empty when the
+ window was never opened or the layout could not be read back.
+*/
+QByteArray MaterialList::savedHeaderState()
+{
+ QSettings settings;
+ return settings.value(QStringLiteral("elements-collections/material-list-header")).toByteArray();
+}
+
+/**
+ @brief MaterialList::saveHeaderState
+ Remember the column layout of the selection window.
+ @param state the header state as saved by QHeaderView::saveState()
+*/
+void MaterialList::saveHeaderState(const QByteArray &state)
+{
+ QSettings settings;
+ settings.setValue(QStringLiteral("elements-collections/material-list-header"), state);
+}
+
+/**
+ @brief MaterialList::savedDialogSize
+ @return the size the selection window had the last time it was closed,
+ an invalid size when it was never opened.
+*/
+QSize MaterialList::savedDialogSize()
+{
+ QSettings settings;
+ const QSize size(settings.value(QStringLiteral("elements-collections/material-list-width")).toInt(),
+ settings.value(QStringLiteral("elements-collections/material-list-height")).toInt());
+ //A size which was never written reads back as 0x0, which Qt still
+ //counts as valid : it must not be handed to resize().
+ return size.isEmpty() ? QSize() : size;
+}
+
+/**
+ @brief MaterialList::saveDialogSize
+ Remember the size of the selection window.
+ @param size
+*/
+void MaterialList::saveDialogSize(const QSize &size)
+{
+ if (size.isEmpty()) {
+ return;
+ }
+ QSettings settings;
+ settings.setValue(QStringLiteral("elements-collections/material-list-width"), size.width());
+ settings.setValue(QStringLiteral("elements-collections/material-list-height"), size.height());
+}
+
+/**
+ @brief MaterialList::defaultColumns
+ @return the columns of a brand new material file, in writing order.
+ It is every information of the main article (everything but label and
+ formula, which QElectroTech computes itself) plus the one column that
+ describes an auxiliary block.
+*/
+QStringList MaterialList::defaultColumns()
+{
+ QStringList columns = columnsForBlock(0);
+ columns << QStringLiteral("auxiliary");
+ return columns;
+}
+
+/**
+ @brief MaterialList::columnsForBlock
+ Columns of the material file that can be transferred to a block of the
+ element informations.
+ @param block 0 for the main article, 1 to 4 for an auxiliary article
+ @return
+*/
+QStringList MaterialList::columnsForBlock(int block)
+{
+ static const QStringList main_columns = {
+ QStringLiteral("description"),
+ QStringLiteral("designation"),
+ QStringLiteral("manufacturer"),
+ QStringLiteral("manufacturer_reference"),
+ QStringLiteral("machine_manufacturer_reference"),
+ QStringLiteral("supplier"),
+ QStringLiteral("quantity"),
+ QStringLiteral("unity"),
+ QStringLiteral("function"),
+ QStringLiteral("comment"),
+ QStringLiteral("notes"),
+ QStringLiteral("model"),
+ QStringLiteral("category"),
+ QStringLiteral("voltage_rating"),
+ QStringLiteral("current_rating"),
+ QStringLiteral("plant"),
+ QStringLiteral("location"),
+ QStringLiteral("width"),
+ QStringLiteral("height"),
+ QStringLiteral("depth")
+ };
+
+ static const QStringList auxiliary_columns = {
+ QStringLiteral("description"),
+ QStringLiteral("designation"),
+ QStringLiteral("manufacturer"),
+ QStringLiteral("manufacturer_reference"),
+ QStringLiteral("machine_manufacturer_reference"),
+ QStringLiteral("supplier"),
+ QStringLiteral("quantity"),
+ QStringLiteral("unity"),
+ QStringLiteral("auxiliary")
+ };
+
+ if (block <= 0) {
+ return main_columns;
+ }
+ if (block > 4) {
+ return QStringList();
+ }
+ return auxiliary_columns;
+}
+
+/**
+ @brief MaterialList::elementInfoKey
+ Tell which element information a column of the material file feeds
+ when it is applied to a block.
+ @param column a column of the material file
+ @param block 0 for the main article, 1 to 4 for an auxiliary article
+ @return the element information key, or a null QString when that column
+ does not belong to that block
+*/
+QString MaterialList::elementInfoKey(const QString &column, int block)
+{
+ if (!columnsForBlock(block).contains(column)) {
+ return QString();
+ }
+
+ if (block <= 0) {
+ return column;
+ }
+
+ //Every auxiliary column carries the same name as its main counterpart,
+ //only the suffix tells the block apart. The block description column
+ //is the exception: it is named auxiliary in the file and auxiliary1 to
+ //auxiliary4 in the element informations.
+ if (column == QLatin1String("auxiliary")) {
+ return QStringLiteral("auxiliary%1").arg(block);
+ }
+ return column + QStringLiteral("_auxiliary%1").arg(block);
+}
+
+/**
+ @brief MaterialList::translatedColumn
+ @param column a column of the material file
+ @return that column named the way it is shown to the user, in the
+ current language. A column QElectroTech doesn't know is returned as it
+ is, since it can only have been named by the user.
+*/
+QString MaterialList::translatedColumn(const QString &column)
+{
+ if (column == QLatin1String("auxiliary")) {
+ return QCoreApplication::translate("MaterialList", "Bloc auxiliaire");
+ }
+
+ const QString translated = QETInformation::translatedInfoKey(column);
+ return translated.isEmpty() ? column : translated;
+}
+
+/**
+ @brief MaterialList::translatedHeader
+ The label line of a material file : the columns the way the user reads
+ them, in the current language.
+ @param columns
+ @return
+*/
+QStringList MaterialList::translatedHeader(const QStringList &columns)
+{
+ QStringList header;
+ header.reserve(columns.size());
+ for (const QString &column : columns) {
+ header.append(translatedColumn(column));
+ }
+ return header;
+}
+
+/**
+ @brief MaterialList::headerAliasMap
+ @return every accepted header cell of a material file, lowercased,
+ mapped to the canonical column it means.
+ Beside the key itself, a header may use the translated name of the
+ information ("Artikelbeschreibung" for description), so a file written
+ by hand in the user's language is understood as well as the canonical
+ one.
+*/
+QMap MaterialList::headerAliasMap()
+{
+ QMap map;
+ const QStringList keys = QETInformation::elementInfoKeys();
+
+ for (const QString &key : keys)
+ {
+ map.insert(key.toLower(), key);
+ const QString translated = QETInformation::translatedInfoKey(key);
+ if (!translated.isEmpty()) {
+ map.insert(translated.toLower(), key);
+ }
+ }
+
+ map.insert(QStringLiteral("auxiliary"), QStringLiteral("auxiliary"));
+ const QString auxiliary_label = QCoreApplication::translate("MaterialList", "Bloc auxiliaire").toLower();
+ map.insert(auxiliary_label, QStringLiteral("auxiliary"));
+
+ return map;
+}
+
+/**
+ @brief MaterialList::canonicalColumn
+ Turn one header cell into a canonical column name.
+ @param header_cell
+ @param already_used the names chosen for the previous cells, to keep
+ the header free of duplicates
+ @param alias_map lowercased alias -> canonical name
+ @return
+*/
+QString MaterialList::canonicalColumn(const QString &header_cell,
+ const QStringList &already_used,
+ const QMap &alias_map)
+{
+ const QString raw = header_cell.trimmed();
+
+ if (raw.isEmpty()) {
+ //A nameless column keeps its data and its place, it just can't be
+ //matched to an element information.
+ QString name = QStringLiteral("column_%1").arg(already_used.size() + 1);
+ int i = already_used.size() + 1;
+ while (already_used.contains(name)) {
+ name = QStringLiteral("column_%1").arg(++i);
+ }
+ return name;
+ }
+
+ const auto it = alias_map.constFind(raw.toLower());
+ if (it != alias_map.constEnd() && !already_used.contains(it.value())) {
+ return it.value();
+ }
+
+ //Either an unknown name, or a second cell meaning something which is
+ //already used : the cell is kept as it is written, its data is never
+ //thrown away.
+ QString name = raw;
+ int i = 2;
+ while (already_used.contains(name)) {
+ name = raw + QStringLiteral("_%1").arg(i++);
+ }
+ return name;
+}
+
+/**
+ @brief MaterialList::isMachineHeaderLine
+ Tell whether the second line of a material file is the machine header
+ QElectroTech writes under the label line : the canonical column names,
+ kept so that a file written in one language is still understood in
+ another one.
+
+ The check is deliberately strict about what it recognises and lenient
+ about the rest : every cell is either a known column name, or the very
+ same text as the label above it (which is how an unknown column shows
+ up in both lines). A data row holding free text never passes.
+ @param label_line the first line of the file, the one the user reads
+ @param candidate the line which may be the machine header
+ @param alias_map lowercased alias -> canonical name
+ @return true when candidate must not be read as a record
+*/
+bool MaterialList::isMachineHeaderLine(const QStringList &label_line,
+ const QStringList &candidate,
+ const QMap &alias_map)
+{
+ if (candidate.isEmpty() || candidate.size() != label_line.size()) {
+ return false;
+ }
+
+ bool has_known_column = false;
+ for (int i = 0; i < candidate.size(); ++i)
+ {
+ const QString cell = candidate.at(i).trimmed();
+ if (cell.isEmpty()) {
+ continue;
+ }
+ if (alias_map.contains(cell.toLower()))
+ {
+ has_known_column = true;
+ continue;
+ }
+ if (cell == label_line.at(i).trimmed()) {
+ continue;
+ }
+ return false;
+ }
+
+ return has_known_column;
+}
+
+/**
+ @brief MaterialList::detectSeparator
+ Tell which separator a csv text uses. Spreadsheets of the german
+ region write a semicolon, the english ones a comma, some tools a tab.
+ @param first_line the header line
+ @return
+*/
+QChar MaterialList::detectSeparator(const QString &first_line)
+{
+ const int semicolons = first_line.count(QLatin1Char(';'));
+ const int tabs = first_line.count(QLatin1Char('\t'));
+ const int commas = first_line.count(QLatin1Char(','));
+
+ if (semicolons == 0 && tabs == 0 && commas == 0) {
+ return QLatin1Char(';');
+ }
+ if (tabs >= semicolons && tabs >= commas && tabs > 0) {
+ return QLatin1Char('\t');
+ }
+ if (commas > semicolons) {
+ return QLatin1Char(',');
+ }
+ return QLatin1Char(';');
+}
+
+/**
+ @brief MaterialList::parseCsv
+ Split a csv text into rows. Quoted fields may contain the separator
+ and line breaks, doubled quotes mean one quote.
+ @param content
+ @param separator
+ @return
+*/
+QList MaterialList::parseCsv(const QString &content, QChar separator)
+{
+ QList rows;
+ QStringList row;
+ QString field;
+ bool quoted = false;
+
+ for (int i = 0; i < content.size(); ++i)
+ {
+ const QChar c = content.at(i);
+
+ if (quoted)
+ {
+ if (c == QLatin1Char('"'))
+ {
+ if (i + 1 < content.size() && content.at(i + 1) == QLatin1Char('"')) {
+ field += QLatin1Char('"');
+ ++i;
+ } else {
+ quoted = false;
+ }
+ } else {
+ field += c;
+ }
+ }
+ else if (c == QLatin1Char('"') && field.isEmpty())
+ {
+ quoted = true;
+ }
+ else if (c == separator)
+ {
+ row.append(field);
+ field.clear();
+ }
+ else if (c == QLatin1Char('\n'))
+ {
+ row.append(field);
+ field.clear();
+ rows.append(row);
+ row.clear();
+ }
+ else if (c != QLatin1Char('\r'))
+ {
+ field += c;
+ }
+ }
+
+ if (!field.isEmpty() || !row.isEmpty()) {
+ row.append(field);
+ rows.append(row);
+ }
+
+ //A trailing line break produces a last, empty row : without this it
+ //would show up as a blank line in the selection dialog.
+ while (!rows.isEmpty() && rows.last().size() == 1 && rows.last().first().isEmpty()) {
+ rows.removeLast();
+ }
+
+ return rows;
+}
+
+/**
+ @brief MaterialList::serializeCsv
+ Build the content of a csv file : utf-8 BOM, quoted fields, one line
+ per record. Same dialect as the bill of material export.
+ The file opens with two header lines : the columns the way the user
+ reads them, then the canonical names QElectroTech matches against the
+ element informations.
+ @param header_lines the header lines, in writing order
+ @param rows
+ @param separator
+ @return
+*/
+QByteArray MaterialList::serializeCsv(const QList &header_lines, const QList &rows, QChar separator)
+{
+ //Helpers duplicated from the bill of material export : every field is
+ //quoted, so a semicolon inside a value is never read as a separator.
+ auto escape = [](QString value) {
+ value.replace(QLatin1Char('"'), QStringLiteral("\"\""));
+ return QLatin1Char('"') + value + QLatin1Char('"');
+ };
+
+ auto line = [&](const QStringList &values) {
+ QStringList escaped;
+ escaped.reserve(values.size());
+ for (const QString &value : values) {
+ escaped.append(escape(value));
+ }
+ return escaped.join(separator) + QLatin1Char('\n');
+ };
+
+ QByteArray out("\xEF\xBB\xBF");
+ for (const QStringList &header_line : header_lines) {
+ out += line(header_line).toUtf8();
+ }
+ for (const QStringList &row : rows) {
+ out += line(row).toUtf8();
+ }
+ return out;
+}
+
+/**
+ @brief MaterialList::load
+ Read a material file.
+ @param path
+ @param data receives the columns and the records
+ @param error receives a message when the file can't be read
+ @return true on success
+*/
+bool MaterialList::load(const QString &path, MaterialListData *data, QString *error)
+{
+ if (error) {
+ error->clear();
+ }
+ if (!data) {
+ if (error) {
+ *error = QCoreApplication::translate("MaterialList", "Aucun récepteur pour le répertoire de matériaux.");
+ }
+ return false;
+ }
+
+ data->columns.clear();
+ data->records.clear();
+ data->separator = QLatin1Char(';');
+
+ QFile file(path);
+ if (!file.open(QIODevice::ReadOnly))
+ {
+ if (error) {
+ *error = file.errorString();
+ }
+ return false;
+ }
+ const QByteArray raw = file.readAll();
+ file.close();
+
+ const QString content = decode(raw);
+ if (content.trimmed().isEmpty()) {
+ return true;
+ }
+
+ const int eol = content.indexOf(QLatin1Char('\n'));
+ data->separator = detectSeparator(eol < 0 ? content : content.left(eol));
+
+ QList rows = parseCsv(content, data->separator);
+ if (rows.isEmpty()) {
+ return true;
+ }
+
+ const QStringList header = rows.takeFirst();
+ const QMap alias_map = headerAliasMap();
+
+ //The label line is followed by the machine header QElectroTech
+ //writes itself : the canonical names, so that the file is read the
+ //same way whatever the language it was written in.
+ if (!rows.isEmpty() && isMachineHeaderLine(header, rows.first(), alias_map)) {
+ rows.takeFirst();
+ }
+
+ QStringList used;
+ for (const QString &cell : header)
+ {
+ const QString column = canonicalColumn(cell, used, alias_map);
+ used.append(column);
+ data->columns.append(column);
+ }
+
+ for (const QStringList &row : std::as_const(rows))
+ {
+ MaterialRecord record;
+ bool empty = true;
+ for (int i = 0; i < data->columns.size(); ++i)
+ {
+ const QString value = i < row.size() ? row.at(i) : QString();
+ if (!value.isEmpty()) {
+ empty = false;
+ }
+ record.setValue(data->columns.at(i), value);
+ }
+ if (!empty) {
+ data->records.append(record);
+ }
+ }
+
+ return true;
+}
+
+/**
+ @brief MaterialList::writeFile
+ Write a whole material file atomically : a crash or a full disk never
+ leaves a half written catalogue behind.
+ @param path
+ @param data
+ @param error receives a message when the file can't be written
+ @return true on success
+*/
+bool MaterialList::writeFile(const QString &path, const MaterialListData &data, QString *error)
+{
+ if (error) {
+ error->clear();
+ }
+
+ QList rows;
+ rows.reserve(data.records.size());
+ for (const MaterialRecord &record : data.records)
+ {
+ QStringList row;
+ row.reserve(data.columns.size());
+ for (const QString &column : data.columns) {
+ row.append(record.value(column));
+ }
+ rows.append(row);
+ }
+
+ QList header_lines;
+ header_lines.append(translatedHeader(data.columns));
+ header_lines.append(data.columns);
+ const QByteArray content = serializeCsv(header_lines, rows, data.separator);
+
+ QSaveFile file(path);
+ if (!file.open(QIODevice::WriteOnly) || file.write(content) != content.size())
+ {
+ if (error) {
+ *error = file.errorString();
+ }
+ file.cancelWriting();
+ return false;
+ }
+ if (!file.commit())
+ {
+ if (error) {
+ *error = file.errorString();
+ }
+ return false;
+ }
+ return true;
+}
+
+/**
+ @brief MaterialList::appendRecord
+ Add one article at the end of a material file.
+ The file is read again first: changes the user just saved from his
+ spreadsheet are part of what gets rewritten, never dropped.
+ @param path
+ @param record
+ @param error receives a message when the file can't be written
+ @return true on success
+*/
+bool MaterialList::appendRecord(const QString &path, const MaterialRecord &record, QString *error)
+{
+ if (error) {
+ error->clear();
+ }
+
+ MaterialListData data;
+ if (hasContent(path))
+ {
+ if (!load(path, &data, error)) {
+ return false;
+ }
+ if (data.columns.isEmpty())
+ {
+ if (error) {
+ *error = QCoreApplication::translate("MaterialList", "Le fichier ne contient pas d'en-tête : colonnes manquantes.");
+ }
+ return false;
+ }
+ }
+ else
+ {
+ data.columns = defaultColumns();
+ data.separator = QLatin1Char(';');
+ }
+
+ data.records.append(record);
+ return writeFile(path, data, error);
+}
+
+/**
+ @brief MaterialList::createFile
+ Create an empty material file holding only the header line.
+ @param path
+ @param error receives a message when the file can't be written
+ @return true on success
+*/
+bool MaterialList::createFile(const QString &path, QString *error)
+{
+ if (error) {
+ error->clear();
+ }
+
+ if (hasContent(path))
+ {
+ if (error) {
+ *error = QCoreApplication::translate("MaterialList", "Le fichier existe déjà et n'est pas vide.");
+ }
+ return false;
+ }
+
+ MaterialListData data;
+ data.columns = defaultColumns();
+ data.separator = QLatin1Char(';');
+ return writeFile(path, data, error);
+}
+
+/**
+ @brief MaterialList::isEmptyFile
+ @param path
+ @return true when no file, or an empty one, exists at path
+*/
+bool MaterialList::isEmptyFile(const QString &path)
+{
+ return !hasContent(path);
+}
diff --git a/sources/materiallist/materiallist.h b/sources/materiallist/materiallist.h
new file mode 100644
index 000000000..66b5cda77
--- /dev/null
+++ b/sources/materiallist/materiallist.h
@@ -0,0 +1,121 @@
+/*
+ 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 MATERIALLIST_H
+#define MATERIALLIST_H
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+/**
+ @brief One line of the material file.
+
+ Keys are column names : either a canonical element information key
+ (description, manufacturer...) or the raw name of a column added by
+ the user, which QElectroTech does not know about and therefore never
+ Transfers to an element, but keeps intact when a row is appended.
+*/
+struct MaterialRecord
+{
+ QMap values;
+
+ QString value(const QString &column) const {return values.value(column);}
+ void setValue(const QString &column, const QString &value) {values.insert(column, value);}
+ bool operator==(const MaterialRecord &other) const {return values == other.values;}
+};
+
+/**
+ @brief The whole content of a material file.
+
+ Both the column names and the separator are read from the file rather
+ than imposed, so a file edited in a spreadsheet keeps working : the
+ separator is detected (;, tab or ,), unknown columns are preserved, and
+ only the header cells that can be matched to a known element
+ information are canonicalized.
+*/
+struct MaterialListData
+{
+ QStringList columns;
+ QList records;
+ QChar separator = QLatin1Char(';');
+
+ bool isEmpty() const {return columns.isEmpty() && records.isEmpty();}
+};
+
+/**
+ @brief The MaterialList class reads and writes the material file (a CSV
+ file listing purchasable articles) and maps its columns onto element
+ informations.
+
+ This is a pure data class : it does not know anything about widgets,
+ so it stays usable from the tests and from the command line.
+*/
+class MaterialList
+{
+ public:
+ //Settings
+ static QString settingsKey();
+ static QString configuredPath();
+ static void setConfiguredPath(const QString &path);
+ static QString defaultPath();
+ static QString defaultFileName();
+
+ //Geometry of the selection window, remembered from one opening
+ //to the next : column widths are part of how the user reads his
+ //own catalogue and must not be lost when the window closes.
+ static QByteArray savedHeaderState();
+ static void saveHeaderState(const QByteArray &state);
+ static QSize savedDialogSize();
+ static void saveDialogSize(const QSize &size);
+
+ //Columns
+ static QStringList defaultColumns();
+ static QStringList columnsForBlock(int block);
+ static QString elementInfoKey(const QString &column, int block);
+ static QString translatedColumn(const QString &column);
+ static QStringList translatedHeader(const QStringList &columns);
+
+ //File io
+ static bool load(const QString &path, MaterialListData *data, QString *error = nullptr);
+ static bool writeFile(const QString &path, const MaterialListData &data, QString *error = nullptr);
+ static bool appendRecord(const QString &path, const MaterialRecord &record, QString *error = nullptr);
+ static bool createFile(const QString &path, QString *error = nullptr);
+ static bool isEmptyFile(const QString &path);
+
+ //Csv helpers, public only to keep them testable
+ static QList parseCsv(const QString &content, QChar separator);
+ static QByteArray serializeCsv(const QList &header_lines,
+ const QList &rows,
+ QChar separator);
+ static QChar detectSeparator(const QString &first_line);
+
+ private:
+ static QString canonicalColumn(const QString &header_cell,
+ const QStringList &already_used,
+ const QMap &alias_map);
+ static QMap headerAliasMap();
+ static bool isMachineHeaderLine(const QStringList &label_line,
+ const QStringList &candidate,
+ const QMap &alias_map);
+};
+
+#endif // MATERIALLIST_H
diff --git a/sources/materiallist/materialselectiondialog.cpp b/sources/materiallist/materialselectiondialog.cpp
new file mode 100644
index 000000000..d7265de7e
--- /dev/null
+++ b/sources/materiallist/materialselectiondialog.cpp
@@ -0,0 +1,610 @@
+/*
+ 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 .
+*/
+#include "materialselectiondialog.h"
+
+#include "materialentrydialog.h"
+#include "../qetmessagebox.h"
+#include "ui_materialselectiondialog.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+/**
+ @brief MaterialTableModel::MaterialTableModel
+ Constructor
+ @param parent parent object
+*/
+MaterialTableModel::MaterialTableModel(QObject *parent) :
+ QAbstractTableModel(parent)
+{
+}
+
+/**
+ @brief MaterialTableModel::setMaterial
+ Replace the displayed file content
+ @param columns
+ @param records
+*/
+void MaterialTableModel::setMaterial(const QStringList &columns, const QList &records)
+{
+ beginResetModel();
+ m_columns = columns;
+ m_records = records;
+ endResetModel();
+}
+
+/**
+ @brief MaterialTableModel::record
+ @param row
+ @return the record of that row, a null one when out of range
+*/
+MaterialRecord MaterialTableModel::record(int row) const
+{
+ return m_records.value(row);
+}
+
+/**
+ @brief MaterialTableModel::rowCount
+ @param parent
+ @return
+*/
+int MaterialTableModel::rowCount(const QModelIndex &parent) const
+{
+ return parent.isValid() ? 0 : m_records.size();
+}
+
+/**
+ @brief MaterialTableModel::columnCount
+ @param parent
+ @return
+*/
+int MaterialTableModel::columnCount(const QModelIndex &parent) const
+{
+ return parent.isValid() ? 0 : m_columns.size();
+}
+
+/**
+ @brief MaterialTableModel::data
+ @param index
+ @param role
+ @return
+*/
+QVariant MaterialTableModel::data(const QModelIndex &index, int role) const
+{
+ if (!index.isValid()
+ || index.row() < 0 || index.row() >= m_records.size()
+ || index.column() < 0 || index.column() >= m_columns.size())
+ {
+ return QVariant();
+ }
+
+ const QString value = m_records.at(index.row()).value(m_columns.at(index.column()));
+
+ switch (role)
+ {
+ case Qt::DisplayRole:
+ case Qt::ToolTipRole:
+ //The tooltip shows the whole cell once the column is too narrow
+ return value;
+ default:
+ return QVariant();
+ }
+}
+
+/**
+ @brief MaterialTableModel::headerData
+ @param section
+ @param orientation
+ @param role
+ @return
+*/
+QVariant MaterialTableModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+ if (role != Qt::DisplayRole) {
+ return QVariant();
+ }
+
+ if (orientation == Qt::Horizontal)
+ {
+ if (section >= 0 && section < m_columns.size()) {
+ return MaterialList::translatedColumn(m_columns.at(section));
+ }
+ return QVariant();
+ }
+
+ return section + 1;
+}
+
+/**
+ @brief MaterialFilterProxy::MaterialFilterProxy
+ Constructor
+ @param model the material table model, also used as source model
+ @param parent parent object
+*/
+MaterialFilterProxy::MaterialFilterProxy(MaterialTableModel *model, QObject *parent) :
+ QSortFilterProxyModel(parent),
+ m_model(model)
+{
+ setSourceModel(model);
+ setFilterCaseSensitivity(Qt::CaseInsensitive);
+ setDynamicSortFilter(true);
+}
+
+/**
+ @brief MaterialFilterProxy::setTokens
+ Set the words every row must hold to be shown.
+ @param tokens
+*/
+void MaterialFilterProxy::setTokens(const QStringList &tokens)
+{
+ if (m_tokens == tokens) {
+ return;
+ }
+ m_tokens = tokens;
+ //The plain string filter of the base class is not what keeps the
+ //rows (filterAcceptsRow() below is), setting it is only how the
+ //base class is told to run the filter again.
+ setFilterFixedString(tokens.join(QLatin1Char(' ')));
+}
+
+/**
+ @brief MaterialFilterProxy::filterAcceptsRow
+ A row is kept when each token is found in at least one of its cells :
+ the words may be spread over different columns, as a search for
+ "Hilfsschalter Schneider" expects.
+ @param source_row
+ @param source_parent
+ @return
+*/
+bool MaterialFilterProxy::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const
+{
+ Q_UNUSED(source_parent)
+
+ if (m_tokens.isEmpty() || !m_model) {
+ return true;
+ }
+
+ const QStringList columns = m_model->columns();
+ for (const QString &token : m_tokens)
+ {
+ bool found = false;
+ for (int column = 0; column < columns.size(); ++column)
+ {
+ const QModelIndex index = m_model->index(source_row, column);
+ const QString value = m_model->data(index, Qt::DisplayRole).toString();
+ if (value.contains(token, Qt::CaseInsensitive))
+ {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ @brief MaterialSelectionDialog::MaterialSelectionDialog
+ Constructor
+ @param path path of the material file
+ @param parent parent widget
+*/
+MaterialSelectionDialog::MaterialSelectionDialog(const QString &path,
+ QWidget *parent) :
+ QDialog(parent),
+ ui(new Ui::MaterialSelectionDialog),
+ m_path(path)
+{
+ ui->setupUi(this);
+
+ m_model = new MaterialTableModel(this);
+ m_proxy = new MaterialFilterProxy(m_model, this);
+
+ ui->m_table_view->setModel(m_proxy);
+ ui->m_table_view->setSortingEnabled(true);
+ ui->m_table_view->horizontalHeader()->setStretchLastSection(true);
+
+ //The wheel is taken in hand here : it moves the rows, and the
+ //columns when the movement is horizontal or Shift is held.
+ ui->m_table_view->viewport()->installEventFilter(this);
+
+ //The row numbers are the order of the file : clicking them, or the
+ //corner just above them, sorts the table by them, which means the
+ //table is no longer sorted at all.
+ for (QAbstractButton *button : ui->m_table_view->findChildren())
+ {
+ if (button->inherits("QTableCornerButton")) {
+ connect(button, &QAbstractButton::clicked,
+ this, &MaterialSelectionDialog::resetSorting);
+ }
+ }
+ ui->m_table_view->verticalHeader()->setSectionsClickable(true);
+ connect(ui->m_table_view->verticalHeader(), &QHeaderView::sectionClicked,
+ this, &MaterialSelectionDialog::resetSorting);
+
+ ui->m_button_box->button(QDialogButtonBox::Ok)->setText(tr("Appliquer"));
+ ui->m_button_box->button(QDialogButtonBox::Cancel)->setText(tr("Annuler"));
+
+ connect(ui->m_search_edit, &QLineEdit::textChanged,
+ this, &MaterialSelectionDialog::searchChanged);
+ if (QItemSelectionModel *selection_model = ui->m_table_view->selectionModel())
+ {
+ connect(selection_model, &QItemSelectionModel::selectionChanged, this,
+ [this](const QItemSelection &, const QItemSelection &) { updateApplyButton(); });
+ }
+
+ reload();
+
+ if (const QSize saved_size = MaterialList::savedDialogSize(); saved_size.isValid()) {
+ resize(saved_size);
+ }
+
+ const int description_column = m_data.columns.indexOf(QStringLiteral("description"));
+ if (description_column >= 0) {
+ ui->m_table_view->sortByColumn(description_column, Qt::AscendingOrder);
+ }
+
+ //Last, so that the columns the user widened, moved or sorted last
+ //time are the ones he finds again.
+ restoreViewState();
+
+ updateApplyButton();
+}
+
+/**
+ @brief MaterialSelectionDialog::~MaterialSelectionDialog
+ Destructor
+*/
+MaterialSelectionDialog::~MaterialSelectionDialog()
+{
+ delete ui;
+}
+
+/**
+ @brief MaterialSelectionDialog::restoreViewState
+ Give the columns back the width, the place and the order they had the
+ last time the window was closed.
+*/
+void MaterialSelectionDialog::restoreViewState()
+{
+ const QByteArray state = MaterialList::savedHeaderState();
+ if (state.isEmpty()) {
+ return;
+ }
+ ui->m_table_view->horizontalHeader()->restoreState(state);
+
+ //Every column of the file is shown, even one an older version of
+ //the window had hidden because no record used it yet.
+ for (int column = 0; column < ui->m_table_view->model()->columnCount(); ++column) {
+ ui->m_table_view->setColumnHidden(column, false);
+ }
+}
+
+/**
+ @brief MaterialSelectionDialog::saveViewState
+ Remember how the columns were laid out, and how big the window was.
+*/
+void MaterialSelectionDialog::saveViewState()
+{
+ MaterialList::saveHeaderState(ui->m_table_view->horizontalHeader()->saveState());
+ MaterialList::saveDialogSize(size());
+}
+
+/**
+ @brief MaterialSelectionDialog::resetSorting
+ Give the table back the order of the file: no column sorted and no
+ arrow in the header, the rows counting from one down again.
+*/
+void MaterialSelectionDialog::resetSorting()
+{
+ //Sorting by the row numbers is sorting by nothing : without a
+ //column to sort by, the proxy writes the rows the way the file
+ //does.
+ m_proxy->sort(-1, Qt::AscendingOrder);
+ ui->m_table_view->horizontalHeader()->setSortIndicator(-1, Qt::AscendingOrder);
+}
+
+/**
+ @brief MaterialSelectionDialog::done
+ Every way out of the window goes through here, so a column resized by
+ the user is never lost, be it "Apply", "Cancel" or the window manager
+ close button.
+ @param result
+*/
+void MaterialSelectionDialog::done(int result)
+{
+ saveViewState();
+ QDialog::done(result);
+}
+
+/**
+ @brief MaterialSelectionDialog::eventFilter
+ The table takes the wheel in hand: the rows must move up and down and
+ the columns left and right, whether the platform does anything with
+ wheel events or not.
+ @param watched
+ @param event
+ @return true when the event was handled here
+*/
+bool MaterialSelectionDialog::eventFilter(QObject *watched, QEvent *event)
+{
+ if (watched == ui->m_table_view->viewport() && event->type() == QEvent::Wheel)
+ {
+ scrollTable(static_cast(event));
+ return true;
+ }
+ return QDialog::eventFilter(watched, event);
+}
+
+/**
+ @brief wheelAmount
+ Turn one wheel movement into an amount to scroll.
+
+ @param delta the movement, in eighths of a degree (120 per notch) or
+ in pixels, depending on the device
+ @param counting_items true when the scroll bar counts rows and columns
+ instead of pixels, which is how the scroll bars of a table work
+ @return how far the scroll bar moves, in its own unit
+*/
+static int wheelAmount(int delta, bool counting_items)
+{
+ if (counting_items) {
+ return 3 * qRound(double(delta) / QWheelEvent::DefaultDeltasPerStep);
+ }
+ return delta;
+}
+
+/**
+ @brief MaterialSelectionDialog::scrollTable
+ Scroll the table with the wheel, in both directions.
+
+ A wheel which only turns moves the rows. A movement reported on the
+ horizontal axis (a wheel which tilts, a touchpad swiped sideways), or
+ Shift held down on a plain wheel, moves the columns instead, which is
+ how the rest of a wide catalogue is reached.
+
+ Three rows or columns travel with every notch, the same amount Qt
+ itself scrolls when it gets the wheel.
+ @param event
+*/
+void MaterialSelectionDialog::scrollTable(QWheelEvent *event)
+{
+ QPoint delta = event->angleDelta();
+ if (delta.isNull()) {
+ delta = event->pixelDelta();
+ }
+
+ //Shift turns the wheel movement sideways.
+ if ((event->modifiers() & Qt::ShiftModifier) && delta.x() == 0 && delta.y() != 0) {
+ delta.setX(delta.y());
+ delta.setY(0);
+ }
+ if (delta.isNull()) {
+ return;
+ }
+
+ if (delta.y() != 0 && ui->m_table_view->verticalScrollBar())
+ {
+ const bool counting_rows =
+ ui->m_table_view->verticalScrollMode() == QAbstractItemView::ScrollPerItem;
+ QScrollBar *bar = ui->m_table_view->verticalScrollBar();
+ bar->setValue(bar->value() - wheelAmount(delta.y(), counting_rows));
+ }
+
+ if (delta.x() != 0 && ui->m_table_view->horizontalScrollBar())
+ {
+ const bool counting_columns =
+ ui->m_table_view->horizontalScrollMode() == QAbstractItemView::ScrollPerItem;
+ QScrollBar *bar = ui->m_table_view->horizontalScrollBar();
+ bar->setValue(bar->value() - wheelAmount(delta.x(), counting_columns));
+ }
+}
+
+/**
+ @brief MaterialSelectionDialog::reload
+ Read the material file again, so that anything the user saved from his
+ spreadsheet since the dialog was built shows up.
+*/
+void MaterialSelectionDialog::reload()
+{
+ QString error;
+ MaterialListData data;
+
+ if (!MaterialList::load(m_path, &data, &error))
+ {
+ QET::QetMessageBox::warning(this,
+ tr("Lecture impossible"),
+ tr("Impossible de lire le fichier :\n%1\n%2")
+ .arg(m_path, error));
+ data = MaterialListData();
+ }
+
+ m_data = data;
+ m_model->setMaterial(m_data.columns, m_data.records);
+ updateCount();
+}
+
+/**
+ @brief MaterialSelectionDialog::updateCount
+ Tell how many entries are shown.
+*/
+void MaterialSelectionDialog::updateCount()
+{
+ const int total = m_model->rowCount();
+ const int shown = m_proxy->rowCount();
+
+ if (m_proxy->tokens().isEmpty()) {
+ ui->m_count_label->setText(tr("Entrées : %1").arg(total));
+ } else {
+ ui->m_count_label->setText(tr("Entrées : %1 sur %2").arg(shown).arg(total));
+ }
+}
+
+/**
+ @brief MaterialSelectionDialog::updateApplyButton
+ Apply is only meaningful with an entry selected.
+*/
+void MaterialSelectionDialog::updateApplyButton()
+{
+ QPushButton *button = ui->m_button_box->button(QDialogButtonBox::Ok);
+ if (button) {
+ button->setEnabled(!selectedRecord().values.isEmpty());
+ }
+}
+
+/**
+ @brief MaterialSelectionDialog::searchChanged
+ Refilter the table each time the search field changes.
+ @param text
+*/
+void MaterialSelectionDialog::searchChanged(const QString &text)
+{
+ m_proxy->setTokens(text.simplified().split(QLatin1Char(' '), Qt::SkipEmptyParts));
+ updateCount();
+ updateApplyButton();
+}
+
+/**
+ @brief MaterialSelectionDialog::selectedRecord
+ @return the selected entry, a null one when nothing is selected
+*/
+MaterialRecord MaterialSelectionDialog::selectedRecord() const
+{
+ if (!m_proxy || !ui->m_table_view->selectionModel()) {
+ return MaterialRecord();
+ }
+
+ const QModelIndexList rows = ui->m_table_view->selectionModel()->selectedRows();
+ if (rows.isEmpty()) {
+ return MaterialRecord();
+ }
+
+ const QModelIndex source = m_proxy->mapToSource(rows.first());
+ if (!source.isValid()) {
+ return MaterialRecord();
+ }
+
+ return m_model->record(source.row());
+}
+
+/**
+ @brief MaterialSelectionDialog::selectRecord
+ Select the row holding that record, scrolling to it.
+ @param record
+ @return true when the record was found and is visible
+*/
+bool MaterialSelectionDialog::selectRecord(const MaterialRecord &record)
+{
+ const QList records = m_model->records();
+ int row = -1;
+ //The newest match wins : appending an entry twice in a row selects
+ //the line which was just written.
+ for (int i = records.size() - 1; i >= 0; --i)
+ {
+ if (records.at(i) == record)
+ {
+ row = i;
+ break;
+ }
+ }
+ if (row < 0) {
+ return false;
+ }
+
+ const QModelIndex proxy = m_proxy->mapFromSource(m_model->index(row, 0));
+ if (!proxy.isValid()) {
+ return false;
+ }
+
+ ui->m_table_view->setCurrentIndex(proxy);
+ ui->m_table_view->selectionModel()->select(
+ proxy, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
+ ui->m_table_view->scrollTo(proxy);
+ updateApplyButton();
+
+ return true;
+}
+
+/**
+ @brief MaterialSelectionDialog::accept
+ Refuse to close the dialog without a selection: "Apply" with nothing
+ chosen would silently do nothing.
+*/
+void MaterialSelectionDialog::accept()
+{
+ if (selectedRecord().values.isEmpty())
+ {
+ QET::QetMessageBox::information(this,
+ tr("Aucune sélection"),
+ tr("Sélectionnez d'abord un article dans la liste."));
+ return;
+ }
+
+ QDialog::accept();
+}
+
+/**
+ @brief MaterialSelectionDialog::on_m_new_entry_btn_clicked
+ Open the entry form and, once it is filled, append the article to the
+ material file, then select it in the table.
+*/
+void MaterialSelectionDialog::on_m_new_entry_btn_clicked()
+{
+ QStringList columns = m_data.columns;
+ if (columns.isEmpty()) {
+ columns = MaterialList::defaultColumns();
+ }
+
+ MaterialEntryDialog entry_dialog(columns, this);
+ if (entry_dialog.exec() != QDialog::Accepted) {
+ return;
+ }
+
+ const MaterialRecord record = entry_dialog.record();
+
+ QString error;
+ if (!MaterialList::appendRecord(m_path, record, &error))
+ {
+ QET::QetMessageBox::critical(this,
+ tr("Écriture impossible"),
+ tr("Impossible d'écrire dans le fichier :\n%1\n%2")
+ .arg(m_path, error));
+ return;
+ }
+
+ //The file is read again so the new line is found where it really
+ //is, and not guessed from where it was before the write.
+ reload();
+
+ //A search hiding the new article would leave the user with a form
+ //he just filled and no line to see.
+ if (!selectRecord(record))
+ {
+ ui->m_search_edit->clear();
+ selectRecord(record);
+ }
+}
diff --git a/sources/materiallist/materialselectiondialog.h b/sources/materiallist/materialselectiondialog.h
new file mode 100644
index 000000000..9b3012b7b
--- /dev/null
+++ b/sources/materiallist/materialselectiondialog.h
@@ -0,0 +1,134 @@
+/*
+ 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 MATERIALSELECTIONDIALOG_H
+#define MATERIALSELECTIONDIALOG_H
+
+#include "../materiallist/materiallist.h"
+
+#include
+#include
+#include
+
+class QModelIndex;
+class QItemSelectionModel;
+class QWheelEvent;
+
+namespace Ui {
+ class MaterialSelectionDialog;
+}
+
+/**
+ @brief Table model showing a material file.
+
+ Every column of the file is a column of the model, named the way the
+ user reads it (translated), so the dialog stays a plain read only
+ table whatever the file contains.
+*/
+class MaterialTableModel : public QAbstractTableModel
+{
+ Q_OBJECT
+
+ public:
+ explicit MaterialTableModel(QObject *parent = nullptr);
+
+ void setMaterial(const QStringList &columns, const QList &records);
+ QStringList columns() const {return m_columns;}
+ QList records() const {return m_records;}
+ MaterialRecord record(int row) const;
+
+ int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+ int columnCount(const QModelIndex &parent = QModelIndex()) const override;
+ QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
+ QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
+
+ private:
+ QStringList m_columns;
+ QList m_records;
+};
+
+/**
+ @brief Filter keeping only the rows matching every word typed by the
+ user, whatever the column each word is found in.
+
+ "Hilfsschalter Schneider" keeps the rows holding both words, so adding
+ the manufacturer to the search narrows the result down, which is the
+ behaviour of a bill of material lookup.
+*/
+class MaterialFilterProxy : public QSortFilterProxyModel
+{
+ Q_OBJECT
+
+ public:
+ explicit MaterialFilterProxy(MaterialTableModel *model, QObject *parent = nullptr);
+
+ void setTokens(const QStringList &tokens);
+ QStringList tokens() const {return m_tokens;}
+
+ protected:
+ bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override;
+
+ private:
+ MaterialTableModel *m_model = nullptr;
+ QStringList m_tokens;
+};
+
+/**
+ @brief The dialog listing every article of the material file, letting
+ the user search one and hand it back to the element properties.
+
+ Three buttons: apply the selected row, do nothing, or add a new row to
+ the file.
+*/
+class MaterialSelectionDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit MaterialSelectionDialog(const QString &path,
+ QWidget *parent = nullptr);
+ ~MaterialSelectionDialog() override;
+
+ MaterialRecord selectedRecord() const;
+ void accept() override;
+ void done(int result) override;
+ bool eventFilter(QObject *watched, QEvent *event) override;
+
+ private slots:
+ void on_m_new_entry_btn_clicked();
+ void searchChanged(const QString &text);
+
+ private:
+ void reload();
+ void restoreViewState();
+ void saveViewState();
+ void resetSorting();
+ void scrollTable(QWheelEvent *event);
+ void updateCount();
+ void updateApplyButton();
+ bool selectRecord(const MaterialRecord &record);
+
+ //ATTRIBUTES
+ private:
+ Ui::MaterialSelectionDialog *ui;
+ MaterialTableModel *m_model = nullptr;
+ MaterialFilterProxy *m_proxy = nullptr;
+ QString m_path;
+ MaterialListData m_data;
+};
+
+#endif // MATERIALSELECTIONDIALOG_H
diff --git a/sources/materiallist/materialselectiondialog.ui b/sources/materiallist/materialselectiondialog.ui
new file mode 100644
index 000000000..a5cbd7601
--- /dev/null
+++ b/sources/materiallist/materialselectiondialog.ui
@@ -0,0 +1,150 @@
+
+
+ MaterialSelectionDialog
+
+
+
+ 0
+ 0
+ 1120
+ 800
+
+
+
+
+ 780
+ 560
+
+
+
+ Liste de matériaux
+
+
+ true
+
+
+ -
+
+
-
+
+
+ Rechercher :
+
+
+
+ -
+
+
+ Tous les mots saisis doivent apparaître quelque part dans la ligne
+
+
+ true
+
+
+ Rechercher dans toutes les colonnes
+
+
+
+
+
+ -
+
+
+ QAbstractItemView::NoEditTriggers
+
+
+ true
+
+
+ QAbstractItemView::SelectRows
+
+
+ QAbstractItemView::SingleSelection
+
+
+ true
+
+
+ false
+
+
+
+ -
+
+
+ Entrées : 0
+
+
+
+ -
+
+
-
+
+
+ Ajouter un nouvel article à la liste
+
+
+ Nouvelle entrée
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+
+
+
+
+
+
+
+
+ m_button_box
+ accepted()
+ MaterialSelectionDialog
+ accept()
+
+
+ 800
+ 540
+
+
+ 100
+ 100
+
+
+
+
+ m_button_box
+ rejected()
+ MaterialSelectionDialog
+ reject()
+
+
+ 880
+ 540
+
+
+ 100
+ 100
+
+
+
+
+
diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp
index 517548ad3..63bea4a11 100644
--- a/sources/ui/configpage/generalconfigurationpage.cpp
+++ b/sources/ui/configpage/generalconfigurationpage.cpp
@@ -20,12 +20,14 @@
#include "../../qetapp.h"
#include "../../qeticons.h"
#include "ui_generalconfigurationpage.h"
+#include "../../materiallist/materiallist.h"
#include "../../utils/qetsettings.h"
#include "../../utils/qetutils.h"
#include "../../qetmessagebox.h"
#include "../../textgrid.h"
#include "../nokde/kcolorbutton.h"
#include
+#include
#include
#include
@@ -228,6 +230,17 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
ui->m_user_macros_path_cb->blockSignals(false);
}
+ //MATERIAL FILE
+ path = MaterialList::configuredPath();
+ ui->m_material_list_path_le->setText(path);
+ if (path.isEmpty())
+ {
+ ui->m_material_list_path_le->setPlaceholderText(
+ tr("Non configuré (par défaut : %1)",
+ "hint shown in the material file field when no file is configured yet")
+ .arg(MaterialList::defaultPath()));
+ }
+
fillLang();
}
@@ -412,6 +425,12 @@ void GeneralConfigurationPage::applyConf()
if (path != settings.value("elements-collections/macros-path").toString()) {
QETApp::resetCollectionsPath();
}
+
+ //MATERIAL FILE
+ //Unlike the collections, the material file is a plain file, it is
+ //kept as chosen even when it doesn't exist yet : the user may well
+ //point QElectroTech at a file he intends to write later.
+ MaterialList::setConfiguredPath(ui->m_material_list_path_le->text().trimmed());
}
/**
@@ -617,6 +636,70 @@ void GeneralConfigurationPage::on_m_user_macros_path_cb_currentIndexChanged(int
}
}
+/**
+ @brief GeneralConfigurationPage::on_m_material_list_browse_pb_clicked
+ Let the user pick an existing material file.
+*/
+void GeneralConfigurationPage::on_m_material_list_browse_pb_clicked()
+{
+ QString start_dir = ui->m_material_list_path_le->text();
+ start_dir = start_dir.isEmpty()
+ ? QETApp::documentDir()
+ : QFileInfo(start_dir).absolutePath();
+
+ const QString path = QFileDialog::getOpenFileName(
+ this,
+ tr("Sélectionner le fichier de la liste de matériaux"),
+ start_dir,
+ tr("Fichiers csv (*.csv)"));
+
+ if (!path.isEmpty()) {
+ ui->m_material_list_path_le->setText(path);
+ }
+}
+
+/**
+ @brief GeneralConfigurationPage::on_m_material_list_create_pb_clicked
+ Create the material file with its header line, so the columns are
+ known before the user fills them from his spreadsheet.
+*/
+void GeneralConfigurationPage::on_m_material_list_create_pb_clicked()
+{
+ QString path = ui->m_material_list_path_le->text();
+ path = path.isEmpty()
+ ? MaterialList::defaultPath()
+ : QFileInfo(path).absolutePath() + QLatin1Char('/') + MaterialList::defaultFileName();
+
+ path = QFileDialog::getSaveFileName(
+ this,
+ tr("Créer le fichier de la liste de matériaux"),
+ path,
+ tr("Fichiers csv (*.csv)"));
+ if (path.isEmpty()) {
+ return;
+ }
+ if (QFileInfo(path).suffix().isEmpty()) {
+ path += QStringLiteral(".csv");
+ }
+
+ //An existing file is kept as it is : this button creates the
+ //header, it never overwrites a catalogue.
+ if (MaterialList::isEmptyFile(path))
+ {
+ QString error;
+ if (!MaterialList::createFile(path, &error))
+ {
+ QET::QetMessageBox::critical(this,
+ tr("Création impossible"),
+ tr("Impossible de créer le fichier :\n%1\n%2")
+ .arg(path, error));
+ return;
+ }
+ }
+
+ ui->m_material_list_path_le->setText(path);
+}
+
void GeneralConfigurationPage::on_m_indi_text_font_pb_clicked()
{
bool ok;
diff --git a/sources/ui/configpage/generalconfigurationpage.h b/sources/ui/configpage/generalconfigurationpage.h
index 015ec8cc1..9a3702805 100644
--- a/sources/ui/configpage/generalconfigurationpage.h
+++ b/sources/ui/configpage/generalconfigurationpage.h
@@ -47,6 +47,8 @@ class GeneralConfigurationPage : public ConfigPage
void on_m_company_tbt_path_cb_currentIndexChanged(int index);
void on_m_custom_tbt_path_cb_currentIndexChanged(int index);
void on_m_user_macros_path_cb_currentIndexChanged(int index);
+ void on_m_material_list_browse_pb_clicked();
+ void on_m_material_list_create_pb_clicked();
void on_m_indi_text_font_pb_clicked();
void on_MaxPartsElementEditorList_sb_valueChanged(int value);
void on_DiagramEditor_Grid_PointSize_min_sb_valueChanged(int value);
diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui
index 027b73704..e0251641e 100644
--- a/sources/ui/configpage/generalconfigurationpage.ui
+++ b/sources/ui/configpage/generalconfigurationpage.ui
@@ -459,6 +459,47 @@
+ -
+
+
+ Fichier de la liste de matériaux
+
+
+
+ -
+
+
-
+
+
+ Répertoire de matériaux utilisé pour renseigner les articles d'un élément
+
+
+ true
+
+
+ Non configuré
+
+
+
+ -
+
+
+ Parcourir...
+
+
+
+ -
+
+
+ Créer le fichier de la liste avec ses en-têtes
+
+
+ Créer...
+
+
+
+
+
diff --git a/sources/ui/elementinfopartwidget.cpp b/sources/ui/elementinfopartwidget.cpp
index 0f57c6ec6..70df10998 100644
--- a/sources/ui/elementinfopartwidget.cpp
+++ b/sources/ui/elementinfopartwidget.cpp
@@ -23,6 +23,7 @@
#include
#include
#include
+#include
#include
@@ -46,6 +47,14 @@ ElementInfoPartWidget::ElementInfoPartWidget(
ui->setupUi(this);
ui->label_->setText(translated_key);
ui->m_erase_text->setVisible(false);
+ ui->m_material_btn->setVisible(false);
+
+ //The line edit must swallow the whole width of the row: without
+ //this the material button column would steal room from it.
+ ui->gridLayout_2->setColumnStretch(0, 1);
+
+ connect(ui->m_material_btn, &QToolButton::clicked,
+ this, &ElementInfoPartWidget::materialButtonClicked);
if (key_ == QETInformation::ELMT_WIDTH || key_ == QETInformation::ELMT_HEIGHT || key_ == QETInformation::ELMT_DEPTH)
{
@@ -174,6 +183,16 @@ void ElementInfoPartWidget::setEraseTextVisible(bool visible)
m_show_erase = visible;
}
+/**
+ @brief ElementInfoPartWidget::setMaterialButtonVisible
+ Show or hide the "..." button opening the material file for this row.
+ @param visible
+*/
+void ElementInfoPartWidget::setMaterialButtonVisible(bool visible)
+{
+ ui->m_material_btn->setVisible(visible);
+}
+
/**
@brief ElementInfoPartWidget::setEraseTextChecked
@param check
diff --git a/sources/ui/elementinfopartwidget.h b/sources/ui/elementinfopartwidget.h
index e6837cb83..019cf9858 100644
--- a/sources/ui/elementinfopartwidget.h
+++ b/sources/ui/elementinfopartwidget.h
@@ -56,10 +56,12 @@ class ElementInfoPartWidget : public QWidget
void setEraseTextVisible (bool visible);
void setEraseTextChecked (bool check);
Qt::CheckState EraseTextCheckState ()const;
+ void setMaterialButtonVisible (bool visible);
signals:
void textEdited (const QString & text);
void textChanged (const QString & text);
+ void materialButtonClicked();
//ATTRIBUTES
private slots:
diff --git a/sources/ui/elementinfopartwidget.ui b/sources/ui/elementinfopartwidget.ui
index d99ff9141..37d285299 100644
--- a/sources/ui/elementinfopartwidget.ui
+++ b/sources/ui/elementinfopartwidget.ui
@@ -65,6 +65,25 @@
+ -
+
+
+ Choisir un article dans la liste de matériaux
+
+
+ …
+
+
+ false
+
+
+ true
+
+
+ Qt::ToolButtonTextOnly
+
+
+
diff --git a/sources/ui/elementinfowidget.cpp b/sources/ui/elementinfowidget.cpp
index 885ef0cdc..f009bb374 100644
--- a/sources/ui/elementinfowidget.cpp
+++ b/sources/ui/elementinfowidget.cpp
@@ -20,8 +20,11 @@
#include
#include
#include "../diagram.h"
+#include "../materiallist/materiallist.h"
+#include "../materiallist/materialselectiondialog.h"
#include "../qetapp.h"
#include "../qetgraphicsitem/element.h"
+#include "../qetmessagebox.h"
#include "../dataBase/projectdatabase.h"
#include "../qetinformation.h"
#include "../qetproject.h"
@@ -222,6 +225,8 @@ void ElementInfoWidget::buildInterface()
m_eipw_list << eipw;
}
+ setupMaterialButtons();
+
m_add_custom_property_btn = new QPushButton(tr("Ajouter une propriété personnalisée"), this);
connect(m_add_custom_property_btn, &QPushButton::clicked, this, [this]() { addCustomProperty(); });
ui->scroll_vlayout->addWidget(m_add_custom_property_btn);
@@ -332,6 +337,150 @@ ElementInfoPartWidget *ElementInfoWidget::infoPartWidgetForKey(const QString &ke
return nullptr;
}
+/**
+ @brief ElementInfoWidget::setupMaterialButtons
+ Show the "..." button opening the material file on the description row
+ of each block: one for the main article, one per auxiliary article
+ which really has a description row.
+
+ Only that row carries the button: the same window fills the whole
+ block, so repeating it on every field of the block would just offer
+ five ways to do the same thing.
+*/
+void ElementInfoWidget::setupMaterialButtons()
+{
+ auto register_button = [this](ElementInfoPartWidget *eipw, int block)
+ {
+ eipw->setMaterialButtonVisible(true);
+ connect(eipw, &ElementInfoPartWidget::materialButtonClicked, this,
+ [this, block]() { materialFromFile(block); });
+ };
+
+ //Main article. A terminal has no description row: its article
+ //number is the one which identifies it, so the button sits there.
+ if (ElementInfoPartWidget *eipw = infoPartWidgetForKey(QETInformation::ELMT_DESCRIPTION)) {
+ register_button(eipw, 0);
+ } else if (ElementInfoPartWidget *eipw = infoPartWidgetForKey(QETInformation::ELMT_DESIGNATION)) {
+ register_button(eipw, 0);
+ }
+
+ //Auxiliary articles 1 to 4.
+ const QStringList auxiliary_keys = {
+ QETInformation::ELMT_DESCRIPTION_AUX1,
+ QETInformation::ELMT_DESCRIPTION_AUX2,
+ QETInformation::ELMT_DESCRIPTION_AUX3,
+ QETInformation::ELMT_DESCRIPTION_AUX4
+ };
+
+ for (int block = 1; block <= 4 && block <= auxiliary_keys.size(); ++block)
+ {
+ if (ElementInfoPartWidget *eipw = infoPartWidgetForKey(auxiliary_keys.at(block - 1))) {
+ register_button(eipw, block);
+ }
+ }
+}
+
+/**
+ @brief ElementInfoWidget::materialFromFile
+ Open the material file, let the user pick an article, and write it
+ into the block the button belongs to.
+ @param block 0 for the main article, 1 to 4 for an auxiliary article
+*/
+void ElementInfoWidget::materialFromFile(int block)
+{
+ //Nothing configured yet: the standard location is used, the same
+ //one the preferences offer to create, so that the catalogue can be
+ //reached from here without asking for a file to be saved first.
+ QString path = MaterialList::configuredPath();
+ if (path.isEmpty())
+ {
+ path = MaterialList::defaultPath();
+ MaterialList::setConfiguredPath(path);
+ }
+
+ //A missing or empty file has no entry to pick: offer to write the
+ //header line instead of opening an empty window.
+ if (MaterialList::isEmptyFile(path))
+ {
+ const auto answer = QET::QetMessageBox::question(
+ this,
+ tr("Liste de matériaux absente"),
+ tr("Aucun fichier de liste de matériaux n'existe à cet emplacement :\n"
+ "%1\n\nLe créer ?", "message asking to create the material file").arg(path),
+ QMessageBox::Yes | QMessageBox::No,
+ QMessageBox::Yes);
+
+ if (answer != QMessageBox::Yes) {
+ return;
+ }
+
+ QString error;
+ if (!MaterialList::createFile(path, &error))
+ {
+ QET::QetMessageBox::critical(this,
+ tr("Création impossible"),
+ tr("Impossible de créer le fichier :\n%1\n%2")
+ .arg(path, error));
+ return;
+ }
+ }
+
+ //The catalogue opens on the whole list : the search field starts
+ //empty, whatever the block already holds.
+ MaterialSelectionDialog dialog(path, this);
+ if (dialog.exec() == QDialog::Accepted) {
+ applyMaterialRecord(dialog.selectedRecord(), block);
+ }
+}
+
+/**
+ @brief ElementInfoWidget::applyMaterialRecord
+ Write a catalogue entry into the fields of one block.
+
+ Only the fields of that block are touched: applied to an auxiliary
+ article, the entry never reaches the main article. And a cell which is
+ empty in the file leaves the current value alone, so picking an entry
+ describing only the order reference never wipes a comment.
+ @param record the entry taken from the material file
+ @param block 0 for the main article, 1 to 4 for an auxiliary article
+*/
+void ElementInfoWidget::applyMaterialRecord(const MaterialRecord &record, int block)
+{
+ if (record.values.isEmpty()) {
+ return;
+ }
+
+ //Fill the fields without letting the live edit push one undo
+ //command per line edit: the whole entry must be undoable at once.
+ const bool live_edit = m_live_edit;
+ if (live_edit) {
+ disableLiveEdit();
+ }
+
+ for (const QString &column : MaterialList::columnsForBlock(block))
+ {
+ const QString value = record.value(column);
+ if (value.isEmpty()) {
+ continue;
+ }
+
+ const QString key = MaterialList::elementInfoKey(column, block);
+ if (key.isEmpty() || !QETInformation::elementInfoKeys().contains(key)) {
+ continue;
+ }
+
+ if (ElementInfoPartWidget *eipw = infoPartWidgetForKey(key)) {
+ eipw->setText(value);
+ }
+ }
+
+ if (live_edit) {
+ enableLiveEdit();
+ }
+
+ apply();
+}
+
/**
@brief ElementInfoWidget::updateSuggestions
Offer, for each information, the values already used by the other
diff --git a/sources/ui/elementinfowidget.h b/sources/ui/elementinfowidget.h
index d959cf19a..9eaeefdba 100644
--- a/sources/ui/elementinfowidget.h
+++ b/sources/ui/elementinfowidget.h
@@ -30,6 +30,7 @@ class CustomElementInfoPartWidget;
class ChangeElementInformationCommand;
class QCheckBox;
class QPushButton;
+struct MaterialRecord;
namespace Ui {
class ElementInfoWidget;
@@ -64,6 +65,9 @@ class ElementInfoWidget : public AbstractElementPropertiesEditorWidget
private:
void buildInterface();
+ void setupMaterialButtons();
+ void materialFromFile(int block);
+ void applyMaterialRecord(const MaterialRecord &record, int block);
ElementInfoPartWidget *infoPartWidgetForKey(const QString &key) const;
QStringList predefinedKeys() const;
void updateSuggestions();