Merge pull request #1088 from Kellermorph/materiallist

Add a material list lookup for the element article fields
This commit is contained in:
Laurent Trinques
2026-09-27 21:43:52 +02:00
committed by GitHub
17 changed files with 2319 additions and 0 deletions
+1
View File
@@ -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)
+8
View File
@@ -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
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "materialentrydialog.h"
#include "../qetmessagebox.h"
#include <QDialogButtonBox>
#include <QGridLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
/**
@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();
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef MATERIALENTRYDIALOG_H
#define MATERIALENTRYDIALOG_H
#include "../materiallist/materiallist.h"
#include <QDialog>
#include <QList>
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<QLineEdit*> m_edits;
};
#endif // MATERIALENTRYDIALOG_H
+778
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "materiallist.h"
#include "../qetinformation.h"
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QSettings>
#include <QStandardPaths>
#include <utility>
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<QString, QString> MaterialList::headerAliasMap()
{
QMap<QString, QString> 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<QString, QString> &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<QString, QString> &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<QStringList> MaterialList::parseCsv(const QString &content, QChar separator)
{
QList<QStringList> 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<QStringList> &header_lines, const QList<QStringList> &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<QStringList> rows = parseCsv(content, data->separator);
if (rows.isEmpty()) {
return true;
}
const QStringList header = rows.takeFirst();
const QMap<QString, QString> 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<QStringList> 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<QStringList> 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);
}
+121
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef MATERIALLIST_H
#define MATERIALLIST_H
#include <QByteArray>
#include <QChar>
#include <QList>
#include <QMap>
#include <QSize>
#include <QString>
#include <QStringList>
/**
@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<QString, QString> 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<MaterialRecord> 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<QStringList> parseCsv(const QString &content, QChar separator);
static QByteArray serializeCsv(const QList<QStringList> &header_lines,
const QList<QStringList> &rows,
QChar separator);
static QChar detectSeparator(const QString &first_line);
private:
static QString canonicalColumn(const QString &header_cell,
const QStringList &already_used,
const QMap<QString, QString> &alias_map);
static QMap<QString, QString> headerAliasMap();
static bool isMachineHeaderLine(const QStringList &label_line,
const QStringList &candidate,
const QMap<QString, QString> &alias_map);
};
#endif // MATERIALLIST_H
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "materialselectiondialog.h"
#include "materialentrydialog.h"
#include "../qetmessagebox.h"
#include "ui_materialselectiondialog.h"
#include <QAbstractButton>
#include <QAbstractItemView>
#include <QHeaderView>
#include <QItemSelectionModel>
#include <QLineEdit>
#include <QPushButton>
#include <QScrollBar>
#include <QWheelEvent>
/**
@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<MaterialRecord> &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<QAbstractButton *>())
{
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<QWheelEvent *>(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<MaterialRecord> 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);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef MATERIALSELECTIONDIALOG_H
#define MATERIALSELECTIONDIALOG_H
#include "../materiallist/materiallist.h"
#include <QAbstractTableModel>
#include <QDialog>
#include <QSortFilterProxyModel>
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<MaterialRecord> &records);
QStringList columns() const {return m_columns;}
QList<MaterialRecord> 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<MaterialRecord> 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
@@ -0,0 +1,150 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MaterialSelectionDialog</class>
<widget class="QDialog" name="MaterialSelectionDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1120</width>
<height>800</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>780</width>
<height>560</height>
</size>
</property>
<property name="windowTitle">
<string>Liste de matériaux</string>
</property>
<property name="modal">
<bool>true</bool>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="m_search_layout">
<item>
<widget class="QLabel" name="m_search_label">
<property name="text">
<string>Rechercher :</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="m_search_edit">
<property name="toolTip">
<string extracomment="placeholder of the search field of the material list; several words narrow the results down (AND)">Tous les mots saisis doivent apparaître quelque part dans la ligne</string>
</property>
<property name="clearButtonEnabled">
<bool>true</bool>
</property>
<property name="placeholderText">
<string extracomment="placeholder of the search field of the material list">Rechercher dans toutes les colonnes</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTableView" name="m_table_view">
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="m_count_label">
<property name="text">
<string>Entrées : 0</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="m_buttons_layout">
<item>
<widget class="QPushButton" name="m_new_entry_btn">
<property name="toolTip">
<string extracomment="button adding a new row to the material list file">Ajouter un nouvel article à la liste</string>
</property>
<property name="text">
<string>Nouvelle entrée</string>
</property>
</widget>
</item>
<item>
<spacer name="m_buttons_spacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QDialogButtonBox" name="m_button_box">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>m_button_box</sender>
<signal>accepted()</signal>
<receiver>MaterialSelectionDialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>800</x>
<y>540</y>
</hint>
<hint type="destinationlabel">
<x>100</x>
<y>100</y>
</hint>
</hints>
</connection>
<connection>
<sender>m_button_box</sender>
<signal>rejected()</signal>
<receiver>MaterialSelectionDialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>880</x>
<y>540</y>
</hint>
<hint type="destinationlabel">
<x>100</x>
<y>100</y>
</hint>
</hints>
</connection>
</connections>
</ui>
@@ -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 <QFileDialog>
#include <QFileInfo>
#include <QFontDialog>
#include <QSettings>
@@ -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;
@@ -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);
@@ -459,6 +459,47 @@
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QLabel" name="m_material_list_label">
<property name="text">
<string>Fichier de la liste de matériaux</string>
</property>
</widget>
</item>
<item row="6" column="1">
<layout class="QHBoxLayout" name="m_material_list_layout">
<item>
<widget class="QLineEdit" name="m_material_list_path_le">
<property name="toolTip">
<string extracomment="tooltip of the material file path field">Répertoire de matériaux utilisé pour renseigner les articles d'un élément</string>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
<property name="placeholderText">
<string extracomment="hint shown when no material file is configured yet">Non configuré</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="m_material_list_browse_pb">
<property name="text">
<string>Parcourir...</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="m_material_list_create_pb">
<property name="toolTip">
<string extracomment="tooltip of the button creating the material file with its header line">Créer le fichier de la liste avec ses en-têtes</string>
</property>
<property name="text">
<string>Créer...</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
+19
View File
@@ -23,6 +23,7 @@
#include <QCompleter>
#include <QRegularExpressionValidator>
#include <QStringListModel>
#include <QToolButton>
#include <utility>
@@ -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
+2
View File
@@ -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:
+19
View File
@@ -65,6 +65,25 @@
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QToolButton" name="m_material_btn">
<property name="toolTip">
<string extracomment="tooltip of the button opening the material file; it fills the fields of this block from a catalogue entry">Choisir un article dans la liste de matériaux</string>
</property>
<property name="text">
<string notr="true">…</string>
</property>
<property name="visible">
<bool>false</bool>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
<property name="toolButtonStyle">
<enum>Qt::ToolButtonTextOnly</enum>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
+149
View File
@@ -20,8 +20,11 @@
#include <QCheckBox>
#include <QPushButton>
#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
+4
View File
@@ -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();