From d5ae15f568e303731a460f9fb47671c94ebf315f Mon Sep 17 00:00:00 2001 From: Kellermorph Date: Sun, 27 Sep 2026 21:07:27 +0200 Subject: [PATCH] Add a material list lookup for the element article fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The element description dialog only offered typing, so every article of a catalogue had to be copied in by hand into the Artikelbeschreibung row of each block, over and over. Settings > General now keeps the path of a CSV material list (Créer... writes a template with the header lines), and the Artikelbeschreibung row of every block of the Bauteil dialog shows a "..." button opening a searchable listing of that file. "Appliquer" fills the fields of that block only -- the main block gets everything but label and formula, an auxiliary block gets description, number, manufacturer, references, supplier, quantity, unit and additional info -- and a cell that is empty in the file never erases a value already in the element. The symbol editor is left alone. - sources/materiallist/: CSV reading and writing (UTF-8 BOM, ';', every field quoted, atomic write through QSaveFile), two header lines (translated labels then the canonical English keys), the selection window and the new entry form. - The selection window reads the file again every time it opens, keeps its size, column widths, column order and sort between openings, shows every column of the file, scrolls all four directions with the wheel (Shift + wheel moves the columns) and puts back the order of the file when the corner above the row numbers or a row number is clicked. - German translations for the new strings in lang/qet_de.ts. --- CMakeLists.txt | 1 + cmake/qet_compilation_vars.cmake | 8 + sources/materiallist/materialentrydialog.cpp | 144 ++++ sources/materiallist/materialentrydialog.h | 54 ++ sources/materiallist/materiallist.cpp | 778 ++++++++++++++++++ sources/materiallist/materiallist.h | 121 +++ .../materiallist/materialselectiondialog.cpp | 610 ++++++++++++++ .../materiallist/materialselectiondialog.h | 134 +++ .../materiallist/materialselectiondialog.ui | 150 ++++ .../configpage/generalconfigurationpage.cpp | 83 ++ .../ui/configpage/generalconfigurationpage.h | 2 + .../ui/configpage/generalconfigurationpage.ui | 41 + sources/ui/elementinfopartwidget.cpp | 19 + sources/ui/elementinfopartwidget.h | 2 + sources/ui/elementinfopartwidget.ui | 19 + sources/ui/elementinfowidget.cpp | 149 ++++ sources/ui/elementinfowidget.h | 4 + 17 files changed, 2319 insertions(+) create mode 100644 sources/materiallist/materialentrydialog.cpp create mode 100644 sources/materiallist/materialentrydialog.h create mode 100644 sources/materiallist/materiallist.cpp create mode 100644 sources/materiallist/materiallist.h create mode 100644 sources/materiallist/materialselectiondialog.cpp create mode 100644 sources/materiallist/materialselectiondialog.h create mode 100644 sources/materiallist/materialselectiondialog.ui 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 1bf68b48d..0ca6ab007 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 @@ -840,6 +841,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 06816e113..a2b922603 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 @@ -221,6 +223,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(); } @@ -400,6 +413,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()); } /** @@ -605,6 +624,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 3bdd12356..db6315300 100644 --- a/sources/ui/configpage/generalconfigurationpage.ui +++ b/sources/ui/configpage/generalconfigurationpage.ui @@ -412,6 +412,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();