diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index b27a9c2dd..1abb63747 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -829,6 +829,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/elementinfopartwidget.h ${QET_DIR}/sources/ui/elementinfowidget.cpp ${QET_DIR}/sources/ui/elementinfowidget.h + ${QET_DIR}/sources/ui/multielementinfowidget.cpp + ${QET_DIR}/sources/ui/multielementinfowidget.h ${QET_DIR}/sources/ui/terminalnumberingdialog.cpp ${QET_DIR}/sources/ui/terminalnumberingdialog.h ${QET_DIR}/sources/ui/cablenumberingdialog.cpp diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 1e4cdc637..288ee0915 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -39,7 +39,9 @@ #include "qeticons.h" #include "qetpalette.h" #include "titleblock/integrationmovetemplateshandler.h" +#include "PropertiesEditor/propertieseditordialog.h" #include "ui/diagrampropertiesdialog.h" +#include "ui/multielementinfowidget.h" #include "ui/multipastedialog.h" #include "undocommand/changetitleblockcommand.h" #include "utils/conductorcreator.h" @@ -2080,9 +2082,33 @@ bool DiagramView::selectedItemHasFocus() */ void DiagramView::editSelection() { - if (m_diagram -> isReadOnly() || m_diagram -> selectedItems().size() != 1 ) return; + if (m_diagram -> isReadOnly()) return; - QGraphicsItem *item = m_diagram->selectedItems().first(); + //Several elements and nothing else: edit their information together (#1439) + const QList selection = + MultiElementInfoWidget::withoutOwnTexts(m_diagram -> selectedItems()); + if (selection.size() > 1) + { + bool only_elements = true; + for (QGraphicsItem *qgi : selection) { + if (qgi->type() != Element::Type) { + only_elements = false; + break; + } + } + const QList elements = MultiElementInfoWidget::informationElements(selection); + if (only_elements && elements.size() > 1) + { + PropertiesEditorDialog dialog( + new MultiElementInfoWidget(elements, selection.size() - elements.size()), + this); + dialog.exec(); + } + return; + } + if (selection.size() != 1) return; + + QGraphicsItem *item = selection.first(); //We use dynamic_cast instead of qgraphicsitem_cast for QetGraphicsItem //because they haven't got their own type(). diff --git a/sources/factory/propertieseditorfactory.cpp b/sources/factory/propertieseditorfactory.cpp index cc7ae1dc9..569eff953 100644 --- a/sources/factory/propertieseditorfactory.cpp +++ b/sources/factory/propertieseditorfactory.cpp @@ -39,6 +39,7 @@ #include "../ui/conductorpropertieseditorwidget.h" #include "../ui/imagepropertieswidget.h" #include "../ui/inditextpropertieswidget.h" +#include "../ui/multielementinfowidget.h" #include "../ui/shapegraphicsitempropertieswidget.h" #include @@ -115,7 +116,7 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor( mapped << qgi; } } - items = mapped; + items = MultiElementInfoWidget::withoutOwnTexts(mapped); const int count_ = items.size(); if (count_ == 0) { @@ -124,19 +125,37 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor( QGraphicsItem *item = items.first(); const int type_ = item->type(); - //The editor widget can only edit one item - //or several items of the same type - for (auto qgi : items) { - if (qgi->type() != type_) { - return nullptr; - } - } - QString class_name; if (editor) { class_name = editor->metaObject()->className(); } + //The editor widget can only edit one item + //or several items of the same type, + //or the information of several elements (#1439) + bool same_type = true; + for (auto qgi : items) { + if (qgi->type() != type_) { + same_type = false; + break; + } + } + if (!same_type || (type_ == Element::Type && count_ > 1)) + { + const QList elements = + MultiElementInfoWidget::informationElements(items); + if (elements.isEmpty()) { + return nullptr; + } + const int other_items = count_ - elements.size(); + if (class_name == MultiElementInfoWidget::staticMetaObject.className()) + { + static_cast(editor)->setElements(elements, other_items); + return editor; + } + return new MultiElementInfoWidget(elements, other_items, parent); + } + switch (type_) { case Conductor::Type: //1001 @@ -162,9 +181,6 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor( } case Element::Type: //1000 { - if (count_ > 1) { - return nullptr; - } auto elmt = static_cast(item); //auto created_editor = new ElementPropertiesWidget(elmt, parent); diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index f0b7fc371..ed35d1085 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -64,6 +64,7 @@ #include "factory/qetgraphicstablefactory.h" #include "print/projectprintwindow.h" #include "project/projectpropertieshandler.h" +#include "ui/multielementinfowidget.h" #include "ui/projectpropertiesdialog.h" #include "projectview.h" #include "qetproject.h" @@ -3085,6 +3086,21 @@ void QETDiagramEditor::slot_updateUndoStack() undo_group.setActiveStack(currentProjectView()->project()->undoStack()); } +namespace { +/** + @return the elements of @p diagram's selection which take information +*/ +QList selectedInformationElements(Diagram *diagram) +{ + QList list; + for (Element *element : DiagramContent(diagram).m_elements) { + if (MultiElementInfoWidget::takesInformations(element)) + list << element; + } + return list; +} +} // namespace + /** @brief QETDiagramEditor::slot_updateComplexActions Manage the actions that need some conditions to be enabled or not. @@ -3211,7 +3227,16 @@ void QETDiagramEditor::slot_updateComplexActions() + selected_conductors_count + selected_cables; - if (selected_editable == 1) + //Several elements and nothing else: edit their information together (#1439) + if (!ro && selected_editable > 1 && selected_editable == selected_elements_count + && selectedInformationElements(diagram_).size() > 1) + { + m_edit_selection -> setEnabled(true); + m_edit_selection -> setText(tr("Edit the information of the elements", + "edit several elements")); + m_edit_selection -> setIcon(QET::Icons::ElementEdit); + } + else if (selected_editable == 1) { m_edit_selection -> setEnabled(true); //edit element @@ -3323,39 +3348,6 @@ void QETDiagramEditor::slot_updateModeActions() } } -namespace { -/** - @return true if @p element has information the user can edit, the - same element kinds ElementPropertiesWidget gives an ElementInfoWidget. - Plain slaves and folio reports take theirs from what they are linked to. -*/ -bool takesElementInformations(const Element *element) -{ - switch (element->linkType()) { - case Element::Simple: - case Element::Thumbnail: - case Element::Master: - case Element::Terminale: - return true; - default: - return false; - } -} - -/** - @return the elements of @p diagram's selection which take information -*/ -QList selectedInformationElements(Diagram *diagram) -{ - QList list; - for (Element *element : DiagramContent(diagram).m_elements) { - if (takesElementInformations(element)) - list << element; - } - return list; -} -} // namespace - /** @brief QETDiagramEditor::slot_updatePasteAction Gere les actions ayant besoin du presse-papier diff --git a/sources/ui/elementinfowidget.cpp b/sources/ui/elementinfowidget.cpp index 018a3a4bd..cd1640bb9 100644 --- a/sources/ui/elementinfowidget.cpp +++ b/sources/ui/elementinfowidget.cpp @@ -111,6 +111,18 @@ void ElementInfoWidget::apply() m_element -> diagram() -> undoStack().push(undo); } +/** + @brief ElementInfoWidget::reset + Forget the edits not applied yet: show again the information + the element has. +*/ +void ElementInfoWidget::reset() +{ + if (m_element) { + updateUi(); + } +} + /** @brief ElementInfoWidget::associatedUndo If the edited info is different of the actual element info, diff --git a/sources/ui/elementinfowidget.h b/sources/ui/elementinfowidget.h index 1ad95c80d..2271becf8 100644 --- a/sources/ui/elementinfowidget.h +++ b/sources/ui/elementinfowidget.h @@ -53,6 +53,7 @@ class ElementInfoWidget : public AbstractElementPropertiesEditorWidget void setElement(Element *element) override; void apply() override; + void reset() override; QUndoCommand *associatedUndo () const override; QString title() const override {return tr("Information");} bool setLiveEdit(bool live_edit) override; diff --git a/sources/ui/elementpropertieswidget.cpp b/sources/ui/elementpropertieswidget.cpp index dfcc9723d..51b9843ed 100644 --- a/sources/ui/elementpropertieswidget.cpp +++ b/sources/ui/elementpropertieswidget.cpp @@ -36,6 +36,11 @@ #include #include +namespace { + //The tab last picked in the selection properties panel +QString s_live_tab; +} + /** @brief ElementPropertiesWidget::ElementPropertiesWidget Default constructor @@ -149,6 +154,7 @@ void ElementPropertiesWidget::setDynamicText(DynamicElementTextItem *text) { DynamicElementTextItemEditor *detie = static_cast(aepew); m_tab->setCurrentWidget(detie); + m_text_tab_wanted = true; detie->setCurrentText(text); } } @@ -175,6 +181,7 @@ void ElementPropertiesWidget::setTextsGroup(ElementTextItemGroup *group) { DynamicElementTextItemEditor *detie = static_cast(aepew); m_tab->setCurrentWidget(detie); + m_text_tab_wanted = true; detie->setCurrentGroup(group); } } @@ -225,6 +232,18 @@ bool ElementPropertiesWidget::setLiveEdit(bool live_edit) foreach (AbstractElementPropertiesEditorWidget *aepew, m_list_editor) aepew->setLiveEdit(m_live_edit); + if (m_live_edit && !m_text_tab_wanted && !s_live_tab.isEmpty()) + { + for (int i = 0 ; i < m_tab->count() ; ++i) + { + if (m_tab->tabText(i) == s_live_tab) + { + m_tab->setCurrentIndex(i); + break; + } + } + } + return true; } @@ -262,6 +281,14 @@ void ElementPropertiesWidget::editElement() void ElementPropertiesWidget::buildGui() { m_tab = new QTabWidget(this); + //The selection properties panel builds a new editor each time + //the selection goes from one element to something else and back: + //it opens on the tab last picked there (#1439) + connect(m_tab, &QTabWidget::tabBarClicked, this, [this](int index) { + if (m_live_edit && index >= 0) { + s_live_tab = m_tab->tabText(index); + } + }); QVBoxLayout *main_layout = new QVBoxLayout(this); main_layout -> addWidget(m_tab); setLayout(main_layout); diff --git a/sources/ui/elementpropertieswidget.h b/sources/ui/elementpropertieswidget.h index 4ca82b348..cdc564e88 100644 --- a/sources/ui/elementpropertieswidget.h +++ b/sources/ui/elementpropertieswidget.h @@ -61,6 +61,7 @@ class ElementPropertiesWidget : public AbstractElementPropertiesEditorWidget QTabWidget *m_tab; QList m_list_editor; QWidget *m_general_widget; + bool m_text_tab_wanted = false; }; #endif // ELEMENTPROPERTIESWIDGET_H diff --git a/sources/ui/multielementinfowidget.cpp b/sources/ui/multielementinfowidget.cpp new file mode 100644 index 000000000..be226cbae --- /dev/null +++ b/sources/ui/multielementinfowidget.cpp @@ -0,0 +1,837 @@ +/* + 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 "multielementinfowidget.h" + +#include "../dataBase/projectdatabase.h" +#include "../diagram.h" +#include "../qet.h" +#include "../qeticons.h" +#include "../qetgraphicsitem/dynamicelementtextitem.h" +#include "../qetgraphicsitem/element.h" +#include "../qetgraphicsitem/elementtextitemgroup.h" +#include "../qetinformation.h" +#include "../qetproject.h" +#include "../undocommand/changeelementinformationcommand.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +/** + The change of one editing session: the keystrokes applied one by one in + the selection panel make one undo step, but never join a change made + before, by the same editor or another one, to the same elements. +*/ +class MultiElementInfoCommand : public ChangeElementInformationCommand +{ + public: + MultiElementInfoCommand( + const QMap, QPair> &map, + quint64 session) : + ChangeElementInformationCommand(map), + m_session(session) + {} + + int id() const override {return 1439;} + bool mergeWith(const QUndoCommand *other) override + { + if (other->id() != id() + || static_cast(other)->m_session != m_session) { + return false; + } + return ChangeElementInformationCommand::mergeWith(other); + } + + private: + quint64 m_session; +}; + +quint64 s_last_session = 0; + +const QString EXCLUDE_FROM_BOM = QStringLiteral("exclude_from_bom"); +const QString AUTO_NUM_LOCKED = QStringLiteral("auto_num_locked"); +const QString POTENTIAL_ISOLATING = QStringLiteral("potential_isolating"); + +QString flagText(bool on) +{ + return on ? QStringLiteral("true") : QStringLiteral("false"); +} +} + +/** + @brief MultiElementInfoWidget::MultiElementInfoWidget + @param elements : the elements to edit + @param other_items : how many other selected items this editor leaves + alone, only shown to the user + @param parent +*/ +MultiElementInfoWidget::MultiElementInfoWidget(const QList &elements, + int other_items, + QWidget *parent) : + PropertiesEditorWidget(parent) +{ + buildInterface(); + setElements(elements, other_items); +} + +MultiElementInfoWidget::~MultiElementInfoWidget() +{ + qDeleteAll(m_rows); + qDeleteAll(m_flags); +} + +/** + @brief MultiElementInfoWidget::takesInformations + @return true if @p element has information the user can edit: the + element kinds ElementPropertiesWidget gives an Information tab. + Plain slaves and folio reports take theirs from what they are linked to. +*/ +bool MultiElementInfoWidget::takesInformations(const Element *element) +{ + if (!element) { + return false; + } + switch (element->linkType()) { + case Element::Simple: + case Element::Thumbnail: + case Element::Master: + case Element::Terminale: + return true; + default: + return false; + } +} + +/** + @brief MultiElementInfoWidget::informationElements + @return the elements of @p items which take information +*/ +QList MultiElementInfoWidget::informationElements(const QList &items) +{ + QList list; + for (QGraphicsItem *qgi : items) { + if (qgi->type() != Element::Type) { + continue; + } + auto element = static_cast(qgi); + if (takesInformations(element)) { + list << element; + } + } + return list; +} + +/** + @brief MultiElementInfoWidget::withoutOwnTexts + @return @p items without the texts and text groups of elements which + are in @p items too: select all takes an element's texts with it, and + they are part of the element rather than other items. +*/ +QList MultiElementInfoWidget::withoutOwnTexts(const QList &items) +{ + QSet elements; + for (QGraphicsItem *qgi : items) { + if (qgi->type() == Element::Type) { + elements.insert(qgi); + } + } + if (elements.isEmpty()) { + return items; + } + + QList list; + for (QGraphicsItem *qgi : items) + { + Element *owner = nullptr; + if (qgi->type() == DynamicElementTextItem::Type) { + owner = static_cast(qgi)->parentElement(); + } else if (auto group = dynamic_cast(qgi)) { + owner = group->parentElement(); + } + if (!owner || !elements.contains(owner)) { + list << qgi; + } + } + return list; +} + +/** + @brief MultiElementInfoWidget::setElements + Edit @p elements, forgetting any pending change. +*/ +void MultiElementInfoWidget::setElements(const QList &elements, int other_items) +{ + for (const auto &element : std::as_const(m_elements)) { + if (element) { + disconnect(element.data(), &Element::elementInfoChange, + this, &MultiElementInfoWidget::elementInfoChanged); + } + } + + m_elements.clear(); + for (Element *element : elements) { + m_elements << QPointer(element); + connect(element, &Element::elementInfoChange, + this, &MultiElementInfoWidget::elementInfoChanged); + } + m_other_items = other_items; + + updateUi(); +} + +/** + @brief MultiElementInfoWidget::elements + @return the edited elements still alive +*/ +QList MultiElementInfoWidget::elements() const +{ + QList list; + for (const auto &element : m_elements) { + if (element) { + list << element.data(); + } + } + return list; +} + +/** + @brief MultiElementInfoWidget::apply + Push the change of every element, as one undo step. +*/ +void MultiElementInfoWidget::apply() +{ + QUndoCommand *undo = associatedUndo(); + if (!undo) { + return; + } + + const auto list = elements(); + if (list.isEmpty() || !list.first()->diagram()) { + delete undo; + return; + } + + m_applying = true; + list.first()->diagram()->undoStack().push(undo); + m_applying = false; +} + +/** + @brief MultiElementInfoWidget::associatedUndo + @return the command giving each element its new information, or + nullptr if no element would change. + A field left alone gives each element back the value it had when the + editor was filled, so restoring a field in live edit undoes it. +*/ +QUndoCommand *MultiElementInfoWidget::associatedUndo() const +{ + QMap, QPair> changes; + + for (Element *element : elements()) + { + const DiagramContext old_info = element->elementInformations(); + const DiagramContext snap = m_snapshot.value(element, old_info); + DiagramContext new_info = old_info; + + for (const Row *row : m_rows) + { + QString txt; + switch (row->state) { + case Keep: + if (snap.contains(row->key)) { + new_info.addValue(row->key, snap.value(row->key), + snap.keyMustShow(row->key)); + } else { + new_info.remove(row->key); + } + continue; + case Clear: + new_info.remove(row->key); + continue; + case Set: + if (!row->edit->hasAcceptableInput()) { + continue; + } + txt = row->edit->text(); + txt.remove(QStringLiteral("\r")); + txt.remove(QStringLiteral("\n")); + if (txt.isEmpty()) { + new_info.remove(row->key); + } else { + new_info.addValue(row->key, txt, + old_info.contains(row->key) + ? old_info.keyMustShow(row->key) + : true); + } + continue; + } + } + + //The element keeps following its numbering scheme as long as + //its formula is left as it was; a formula given by hand is its own. + if (new_info.value(QETInformation::ELMT_FORMULA) + == snap.value(QETInformation::ELMT_FORMULA) + && snap.contains(QETInformation::ELMT_FORMULA_ID)) { + new_info.addValue(QETInformation::ELMT_FORMULA_ID, + snap.value(QETInformation::ELMT_FORMULA_ID), false); + } else { + new_info.remove(QETInformation::ELMT_FORMULA_ID); + } + + for (const Flag *flag : m_flags) + { + if (flag->check->isHidden()) { + continue; + } + if (!flag->changed) { + if (snap.contains(flag->key)) { + new_info.addValue(flag->key, snap.value(flag->key), + snap.keyMustShow(flag->key)); + } else { + new_info.remove(flag->key); + } + } else { + new_info.addValue(flag->key, flagText(flag->check->isChecked())); + } + } + + if (new_info != old_info) { + changes.insert(QPointer(element), qMakePair(old_info, new_info)); + } + } + + if (changes.isEmpty()) { + return nullptr; + } + return new MultiElementInfoCommand(changes, m_session); +} + +/** + @brief MultiElementInfoWidget::reset + Bring back every field to what the elements had, and in live edit + give it back to the elements. +*/ +void MultiElementInfoWidget::reset() +{ + m_updating = true; + for (Row *row : std::as_const(m_rows)) { + restoreRow(row); + } + for (Flag *flag : std::as_const(m_flags)) { + restoreFlag(flag); + } + m_updating = false; + updateLabelRow(); + changed(); +} + +/** + @brief MultiElementInfoWidget::updateUi + Read the elements again, forgetting any pending change. +*/ +void MultiElementInfoWidget::updateUi() +{ + takeSnapshot(); + fillRows(); + updateHeader(); + updateSuggestions(); +} + +/** + @brief MultiElementInfoWidget::fieldState + @return what the field @p key does on apply +*/ +MultiElementInfoWidget::FieldState MultiElementInfoWidget::fieldState(const QString &key) const +{ + const Row *row = rowForKey(key); + return row ? row->state : Keep; +} + +/** + @brief MultiElementInfoWidget::setFieldText + Type @p text in the field @p key, as the user would. +*/ +void MultiElementInfoWidget::setFieldText(const QString &key, const QString &text) +{ + if (Row *row = rowForKey(key)) { + row->edit->setText(text); + } +} + +/** + @brief MultiElementInfoWidget::setFieldCleared + Tick or untick the erase box of the field @p key, as the user would. +*/ +void MultiElementInfoWidget::setFieldCleared(const QString &key, bool cleared) +{ + if (Row *row = rowForKey(key)) { + row->erase->setChecked(cleared); + rowErased(row); + } +} + +/** + @brief MultiElementInfoWidget::restoreField + Click the restore button of the field @p key. +*/ +void MultiElementInfoWidget::restoreField(const QString &key) +{ + if (Row *row = rowForKey(key)) { + restoreRow(row); + changed(); + } +} + +/** + @brief MultiElementInfoWidget::fieldIsMixed + @return true if the elements had different values for @p key +*/ +bool MultiElementInfoWidget::fieldIsMixed(const QString &key) const +{ + const Row *row = rowForKey(key); + return row && row->mixed; +} + +/** + @brief MultiElementInfoWidget::setLiveEdit + @param live_edit : true to apply every change at once + @return always true +*/ +bool MultiElementInfoWidget::setLiveEdit(bool live_edit) +{ + m_live_edit = live_edit; + return true; +} + +/** + @brief MultiElementInfoWidget::buildInterface + One row per information key: name, text, erase box, restore button. + The numbering scheme, label freezing and number picker of the + one-element editor are left out: they are about one element. +*/ +void MultiElementInfoWidget::buildInterface() +{ + auto main_layout = new QVBoxLayout(this); + main_layout->setContentsMargins(0, 0, 0, 0); + + m_header = new QLabel(this); + m_header->setWordWrap(true); + m_header->setContentsMargins(5, 5, 5, 0); + main_layout->addWidget(m_header); + + auto flags_layout = new QVBoxLayout(); + main_layout->addLayout(flags_layout); + + const QList> flags { + {EXCLUDE_FROM_BOM, tr("Exclude from the bill of materials")}, + {POTENTIAL_ISOLATING, tr("Function separation")}, + {AUTO_NUM_LOCKED, tr("Exclude from automatic numbering")} + }; + for (const auto &pair : flags) + { + auto flag = new Flag; + flag->key = pair.first; + flag->check = new QCheckBox(pair.second, this); + flag->restore = new QToolButton(this); + flag->restore->setIcon(QET::Icons::EditUndo); + flag->restore->setAutoRaise(true); + flag->restore->setToolTip(tr("Restore the values the elements had")); + auto row_layout = new QHBoxLayout(); + row_layout->setContentsMargins(5, 0, 5, 0); + row_layout->addWidget(flag->check, 1); + row_layout->addWidget(flag->restore); + flags_layout->addLayout(row_layout); + m_flags << flag; + + connect(flag->check, &QCheckBox::clicked, this, [this, flag]() { + flagClicked(flag); + }); + connect(flag->restore, &QToolButton::clicked, this, [this, flag]() { + restoreFlag(flag); + changed(); + }); + } + + auto scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + auto content = new QWidget(scroll); + auto grid = new QGridLayout(content); + grid->setColumnStretch(1, 1); + + QStringList keys = QETInformation::elementInfoKeys(); + //It has its own check box above, see ElementInfoWidget::buildInterface + keys.removeAll(EXCLUDE_FROM_BOM); + + int line = 0; + for (const QString &key : std::as_const(keys)) + { + auto row = new Row; + row->key = key; + row->label = new QLabel(QETInformation::translatedInfoKey(key), content); + row->edit = new QLineEdit(content); + row->edit->setClearButtonEnabled(true); + row->erase = new QCheckBox(content); + row->erase->setToolTip(tr("Delete this text")); + row->restore = new QToolButton(content); + row->restore->setIcon(QET::Icons::EditUndo); + row->restore->setAutoRaise(true); + row->restore->setToolTip(tr("Restore the values the elements had")); + + if (key == QETInformation::ELMT_WIDTH + || key == QETInformation::ELMT_HEIGHT + || key == QETInformation::ELMT_DEPTH) { + row->edit->setValidator(new QETInformation::NumericInfoValidator(row->edit)); + row->edit->setToolTip(tr("Decimal number with a dot as separator (e.g. 80.5)")); + } + + grid->addWidget(row->label, line, 0); + grid->addWidget(row->edit, line, 1); + grid->addWidget(row->erase, line, 2); + grid->addWidget(row->restore, line, 3); + ++line; + m_rows << row; + + connect(row->edit, &QLineEdit::textChanged, this, [this, row]() { + if (!m_updating) { + rowEdited(row); + } + }); + connect(row->erase, &QCheckBox::clicked, this, [this, row]() { + rowErased(row); + }); + connect(row->restore, &QToolButton::clicked, this, [this, row]() { + restoreRow(row); + changed(); + }); + } + grid->setRowStretch(line, 1); + + scroll->setWidget(content); + main_layout->addWidget(scroll, 1); +} + +/** + @brief MultiElementInfoWidget::takeSnapshot + Keep the information every element has now: the values a field left + alone gives back. +*/ +void MultiElementInfoWidget::takeSnapshot() +{ + m_session = ++s_last_session; + m_snapshot.clear(); + for (Element *element : elements()) { + m_snapshot.insert(element, element->elementInformations()); + } +} + +/** + @brief MultiElementInfoWidget::fillRows + Show in each field the value shared by every element, or that the + values differ. +*/ +void MultiElementInfoWidget::fillRows() +{ + const auto list = elements(); + m_updating = true; + + for (Row *row : std::as_const(m_rows)) + { + row->mixed = false; + row->initial.clear(); + bool first = true; + for (Element *element : list) { + const QString value = m_snapshot.value(element).value(row->key).toString(); + if (first) { + row->initial = value; + first = false; + } else if (value != row->initial) { + row->mixed = true; + row->initial.clear(); + break; + } + } + restoreRow(row); + } + + bool all_terminals = !list.isEmpty(); + for (Element *element : list) { + if (element->elementData().m_type != ElementData::Terminal) { + all_terminals = false; + break; + } + } + + for (Flag *flag : std::as_const(m_flags)) + { + flag->check->setVisible(flag->key == EXCLUDE_FROM_BOM || all_terminals); + flag->restore->setVisible(flag->check->isVisible()); + + int on = 0; + for (Element *element : list) { + if (QET::infoFlagIsTrue(m_snapshot.value(element).value(flag->key).toString())) { + ++on; + } + } + flag->initial = on == 0 ? Qt::Unchecked + : on == list.size() ? Qt::Checked + : Qt::PartiallyChecked; + restoreFlag(flag); + } + + m_updating = false; + updateLabelRow(); +} + +/** + @brief MultiElementInfoWidget::updateHeader + Say how many elements are edited, and that the rest of the selection + is not. +*/ +void MultiElementInfoWidget::updateHeader() +{ + QString text = tr("Elements edited: %1").arg(elements().size()); + if (m_other_items > 0) { + text += QStringLiteral("\n") + + tr("Other selected items, left unchanged: %1").arg(m_other_items); + } + m_header->setText(text); +} + +/** + @brief MultiElementInfoWidget::updateSuggestions + Offer, while typing, the values the project already uses, + the way ElementInfoWidget::updateSuggestions does. +*/ +void MultiElementInfoWidget::updateSuggestions() +{ + const auto list = elements(); + Diagram *diagram = list.isEmpty() ? nullptr : list.first()->diagram(); + QETProject *project = diagram ? diagram->project() : nullptr; + if (!project || !project->dataBase()) { + return; + } + + //Only a column of element_info can be queried; the key becomes + //part of the SQL text, so it is checked against that list. + const QStringList columns = QETInformation::elementInfoKeys(); + for (Row *row : std::as_const(m_rows)) + { + if (row->key == QETInformation::ELMT_LABEL || !columns.contains(row->key)) { + continue; + } + + QStringList values; + auto query = project->dataBase()->newQuery(QStringLiteral( + "SELECT v, MAX(n) FROM (" + "SELECT \"%1\" AS v, COUNT(*) AS n FROM element_info " + "WHERE \"%1\" IS NOT NULL AND \"%1\" != '' " + "GROUP BY \"%1\") " + "GROUP BY v COLLATE NOCASE " + "ORDER BY v COLLATE NOCASE").arg(row->key)); + while (query.next()) { + values << query.value(0).toString(); + } + + if (values.isEmpty()) { + row->edit->setCompleter(nullptr); + continue; + } + auto completer = new QCompleter(values, row->edit); + completer->setCaseSensitivity(Qt::CaseInsensitive); + completer->setFilterMode(Qt::MatchContains); + row->edit->setCompleter(completer); + } +} + +/** + @brief MultiElementInfoWidget::rowEdited + The user typed in a field: its text goes to every element, unless + it is back to what they all had. +*/ +void MultiElementInfoWidget::rowEdited(Row *row) +{ + const QString text = row->edit->text(); + if (row->mixed ? text.isEmpty() : text == row->initial) { + row->state = Keep; + } else { + row->state = Set; + } + updateRowLook(row); + if (row->key == QETInformation::ELMT_FORMULA) { + updateLabelRow(); + } + changed(); +} + +/** + @brief MultiElementInfoWidget::rowErased + The erase box of a field was ticked or unticked. +*/ +void MultiElementInfoWidget::rowErased(Row *row) +{ + m_updating = true; + if (row->erase->isChecked()) { + row->state = Clear; + row->edit->clear(); + } else { + row->state = Keep; + row->edit->setText(row->initial); + } + m_updating = false; + updateRowLook(row); + if (row->key == QETInformation::ELMT_FORMULA) { + updateLabelRow(); + } + changed(); +} + +/** + @brief MultiElementInfoWidget::restoreRow + Bring a field back to what the elements had. Does not apply. +*/ +void MultiElementInfoWidget::restoreRow(Row *row) +{ + const bool was_updating = m_updating; + m_updating = true; + row->state = Keep; + row->erase->setChecked(false); + row->edit->setText(row->initial); + m_updating = was_updating; + updateRowLook(row); + if (!m_updating && row->key == QETInformation::ELMT_FORMULA) { + updateLabelRow(); + } +} + +/** + @brief MultiElementInfoWidget::restoreFlag + Bring a check box back to what the elements had. Does not apply. +*/ +void MultiElementInfoWidget::restoreFlag(Flag *flag) +{ + flag->changed = false; + flag->check->setTristate(flag->initial == Qt::PartiallyChecked); + flag->check->setCheckState(flag->initial); + flag->restore->setEnabled(false); +} + +/** + @brief MultiElementInfoWidget::flagClicked + A click gives the box's state to every element. A box which started + "partly checked" goes on as a plain on/off box: cycling back through + "partly" is what its restore button is for. +*/ +void MultiElementInfoWidget::flagClicked(Flag *flag) +{ + if (flag->check->isTristate()) { + flag->check->setTristate(false); + if (flag->check->checkState() == Qt::PartiallyChecked) { + flag->check->setCheckState(Qt::Checked); + } + } + flag->changed = flag->check->checkState() != flag->initial; + flag->restore->setEnabled(flag->changed); + changed(); +} + +/** + @brief MultiElementInfoWidget::updateRowLook + A field whose values differ says so; a field which will change + something has its restore button on and its name in bold. +*/ +void MultiElementInfoWidget::updateRowLook(Row *row) +{ + row->edit->setDisabled(row->state == Clear); + if (row->state == Clear) { + row->edit->setPlaceholderText(tr("Delete this text")); + } else if (row->mixed) { + row->edit->setPlaceholderText(tr("Multiple values")); + } else { + row->edit->setPlaceholderText(QString()); + } + row->restore->setEnabled(row->state != Keep); + QFont font = row->label->font(); + font.setBold(row->state != Keep); + row->label->setFont(font); +} + +/** + @brief MultiElementInfoWidget::updateLabelRow + As in the one-element editor, the label cannot be typed while the + formula field holds a formula: the formula makes the label. +*/ +void MultiElementInfoWidget::updateLabelRow() +{ + Row *formula = rowForKey(QETInformation::ELMT_FORMULA); + Row *label = rowForKey(QETInformation::ELMT_LABEL); + if (formula && label) { + const bool has_formula = !formula->edit->text().isEmpty(); + label->edit->setEnabled(!has_formula && label->state != Clear); + label->erase->setEnabled(!has_formula); + } +} + +/** + @brief MultiElementInfoWidget::changed + In live edit, every change is applied at once. +*/ +void MultiElementInfoWidget::changed() +{ + if (m_live_edit && !m_updating) { + apply(); + } +} + +/** + @brief MultiElementInfoWidget::elementInfoChanged + The information of an element was changed by something else than + this editor (undo, another editor): read the elements again. +*/ +void MultiElementInfoWidget::elementInfoChanged() +{ + if (m_applying || m_refresh_pending) { + return; + } + //An undo changes every element one after the other: + //read them once, when it is done. + m_refresh_pending = true; + QTimer::singleShot(0, this, [this]() { + m_refresh_pending = false; + updateUi(); + }); +} + +MultiElementInfoWidget::Row *MultiElementInfoWidget::rowForKey(const QString &key) const +{ + for (Row *row : m_rows) { + if (row->key == key) { + return row; + } + } + return nullptr; +} diff --git a/sources/ui/multielementinfowidget.h b/sources/ui/multielementinfowidget.h new file mode 100644 index 000000000..aa103755d --- /dev/null +++ b/sources/ui/multielementinfowidget.h @@ -0,0 +1,125 @@ +/* + 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 MULTIELEMENTINFOWIDGET_H +#define MULTIELEMENTINFOWIDGET_H + +#include "../PropertiesEditor/propertieseditorwidget.h" +#include "../diagramcontext.h" + +#include +#include + +class Element; +class QCheckBox; +class QGraphicsItem; +class QLabel; +class QLineEdit; +class QToolButton; + +/** + @brief The MultiElementInfoWidget class + Edit the information (manufacturer, supplier, references...) of + several elements at once (issue #1439). + + Each field shows the value when every element has the same one, or + "Multiple values" when they differ. A field left alone keeps each + element's own value, a typed text is given to every element, and the + erase box clears the field on every element. The restore button of a + field, and reset(), bring back the values the elements had. + All the changes of one apply are a single undo step. +*/ +class MultiElementInfoWidget : public PropertiesEditorWidget +{ + Q_OBJECT + + public: + explicit MultiElementInfoWidget(const QList &elements, + int other_items = 0, + QWidget *parent = nullptr); + ~MultiElementInfoWidget() override; + + void setElements(const QList &elements, int other_items = 0); + QList elements() const; + + void apply() override; + void reset() override; + QUndoCommand *associatedUndo() const override; + QString title() const override {return tr("Information");} + void updateUi() override; + bool setLiveEdit(bool live_edit) override; + + static bool takesInformations(const Element *element); + static QList informationElements(const QList &items); + static QList withoutOwnTexts(const QList &items); + + //What a field will do to the elements on apply + enum FieldState {Keep, Set, Clear}; + FieldState fieldState(const QString &key) const; + void setFieldText(const QString &key, const QString &text); + void setFieldCleared(const QString &key, bool cleared); + void restoreField(const QString &key); + bool fieldIsMixed(const QString &key) const; + + private: + struct Row { + QString key; + QLabel *label = nullptr; + QLineEdit *edit = nullptr; + QCheckBox *erase = nullptr; + QToolButton *restore = nullptr; + QString initial; + bool mixed = false; + FieldState state = Keep; + }; + struct Flag { + QString key; + QCheckBox *check = nullptr; + QToolButton *restore = nullptr; + Qt::CheckState initial = Qt::Unchecked; + bool changed = false; + }; + + void buildInterface(); + void takeSnapshot(); + void fillRows(); + void updateHeader(); + void updateSuggestions(); + void rowEdited(Row *row); + void rowErased(Row *row); + void restoreRow(Row *row); + void restoreFlag(Flag *flag); + void flagClicked(Flag *flag); + void updateRowLook(Row *row); + void updateLabelRow(); + void changed(); + void elementInfoChanged(); + Row *rowForKey(const QString &key) const; + + QList> m_elements; + QHash m_snapshot; + int m_other_items = 0; + QLabel *m_header = nullptr; + QList m_rows; + QList m_flags; + bool m_updating = false; + bool m_applying = false; + bool m_refresh_pending = false; + quint64 m_session = 0; +}; + +#endif // MULTIELEMENTINFOWIDGET_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 768ea7d99..b2747cba0 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -267,6 +267,13 @@ endfunction() # alone and with a group. qet_add_app_test(tst_rotateselectionimage tst_rotateselectionimage.cpp) +# Editing the information of several elements at once (#1439): kept, set +# and cleared fields, restore and reset, one undo step, the selection +# panel's editor and the Edit action. +qet_add_app_test(tst_multielementinfo tst_multielementinfo.cpp) +target_compile_definitions(tst_multielementinfo PRIVATE + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # Accepting the settings dialog writes qet-assistant.json with the # collection paths just chosen, not the ones they replace. qet_add_app_test(tst_assistantinfopaths tst_assistantinfopaths.cpp) diff --git a/tests/qttest/tst_multielementinfo.cpp b/tests/qttest/tst_multielementinfo.cpp new file mode 100644 index 000000000..b0bc23935 --- /dev/null +++ b/tests/qttest/tst_multielementinfo.cpp @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "qetapptest.h" + +#include "diagramcontent.h" +#include "factory/propertieseditorfactory.h" +#include "qetgraphicsitem/conductor.h" +#include "qetgraphicsitem/dynamicelementtextitem.h" +#include "qetgraphicsitem/element.h" +#include "qetinformation.h" +#include "projectview.h" +#include "qetproject.h" +#include "ui/elementinfopartwidget.h" +#include "ui/elementinfowidget.h" +#include "ui/elementpropertieswidget.h" +#include "ui/multielementinfowidget.h" +#include "undocommand/changeelementinformationcommand.h" + +#include +#include +#include + +// Editing the information of several elements at once (#1439): a field left +// alone keeps each element's own value, a typed text goes to every element, +// the erase box clears the field on every element, the restore button and +// reset() bring back what the elements had, and one apply is one undo step. +class tst_multielementinfo : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + Diagram *m_diagram = nullptr; + QList m_elements; + Conductor *m_conductor = nullptr; + + QString info(Element *e, const QString &key) const + { + return e->elementInformations().value(key).toString(); + } + + QUndoStack &stack() { return m_diagram->undoStack(); } + + // The three test elements get known information, as one undo step + // the test then leaves on the stack + void setKnownInfo() + { + QMap, QPair> map; + const QStringList suppliers {"S1", "S2", ""}; + for (int i = 0 ; i < 3 ; ++i) + { + Element *e = m_elements.at(i); + DiagramContext info; + info.addValue(QETInformation::ELMT_MANUFACTURER, "Maker"); + if (!suppliers.at(i).isEmpty()) + info.addValue(QETInformation::ELMT_SUPPLIER, suppliers.at(i)); + info.addValue(QETInformation::ELMT_DESCRIPTION, QString("desc %1").arg(i)); + info.addValue("my_custom_key", QString("custom %1").arg(i)); + if (i == 0) + info.addValue("exclude_from_bom", "true"); + map.insert(e, qMakePair(e->elementInformations(), info)); + } + stack().push(new ChangeElementInformationCommand(map)); + } + + private slots: + void initTestCase() + { + QETDiagramEditor *editor = QetAppTest::editor(); + QVERIFY(editor); + // The first example folio with three plain elements and a wire + const QStringList files = QDir(QET_EXAMPLES_DIR).entryList({"*.qet"}, QDir::Files, QDir::Name); + for (const QString &file : files) + { + // A copy: opening a project leaves a lock file beside it + const QString copy = m_dir.filePath(file); + QVERIFY(QFile::copy(QDir(QET_EXAMPLES_DIR).filePath(file), copy)); + QVERIFY(editor->openAndAddProject(copy, false)); + QETProject *project = editor->currentProjectView()->project(); + for (Diagram *d : project->diagrams()) + { + QList simple; + Conductor *conductor = nullptr; + for (QGraphicsItem *qgi : d->items()) + { + if (qgi->type() == Element::Type + && static_cast(qgi)->linkType() == Element::Simple) + simple << static_cast(qgi); + else if (!conductor && qgi->type() == Conductor::Type) + conductor = static_cast(qgi); + } + if (simple.size() >= 3 && conductor) + { + m_diagram = d; + m_elements = simple.mid(0, 3); + m_conductor = conductor; + break; + } + } + if (m_diagram) + break; + } + QVERIFY2(m_diagram, "no example folio with three plain elements and a wire"); + setKnownInfo(); + } + + void init() + { + stack().setClean(); + } + + void cleanup() + { + // Back to the known information + while (stack().index() > stack().cleanIndex()) + stack().undo(); + } + + void showsSharedAndMixedValues() + { + MultiElementInfoWidget w(m_elements); + QCOMPARE(w.fieldIsMixed(QETInformation::ELMT_MANUFACTURER), false); + QCOMPARE(w.fieldIsMixed(QETInformation::ELMT_SUPPLIER), true); + QCOMPARE(w.fieldIsMixed(QETInformation::ELMT_DESCRIPTION), true); + // No element has a value: same (empty) value, not mixed + QCOMPARE(w.fieldIsMixed(QETInformation::ELMT_QUANTITY), false); + QVERIFY(!w.associatedUndo()); + } + + void setGivesTextToEveryElementAsOneUndo() + { + MultiElementInfoWidget w(m_elements); + w.setFieldText(QETInformation::ELMT_SUPPLIER, "ACME"); + QCOMPARE(w.fieldState(QETInformation::ELMT_SUPPLIER), MultiElementInfoWidget::Set); + const int before = stack().index(); + w.apply(); + QCOMPARE(stack().index(), before + 1); + for (Element *e : m_elements) + { + QCOMPARE(info(e, QETInformation::ELMT_SUPPLIER), QString("ACME")); + // Untouched fields keep each element's own value + QCOMPARE(info(e, QETInformation::ELMT_MANUFACTURER), QString("Maker")); + QCOMPARE(info(e, "my_custom_key"), + QString("custom %1").arg(m_elements.indexOf(e))); + } + QCOMPARE(info(m_elements[0], QETInformation::ELMT_DESCRIPTION), QString("desc 0")); + + stack().undo(); + QCOMPARE(info(m_elements[0], QETInformation::ELMT_SUPPLIER), QString("S1")); + QCOMPARE(info(m_elements[1], QETInformation::ELMT_SUPPLIER), QString("S2")); + QVERIFY(!m_elements[2]->elementInformations().contains(QETInformation::ELMT_SUPPLIER)); + } + + void eraseClearsEveryElement() + { + MultiElementInfoWidget w(m_elements); + w.setFieldCleared(QETInformation::ELMT_DESCRIPTION, true); + QCOMPARE(w.fieldState(QETInformation::ELMT_DESCRIPTION), MultiElementInfoWidget::Clear); + w.apply(); + for (Element *e : m_elements) { + QVERIFY(!e->elementInformations().contains(QETInformation::ELMT_DESCRIPTION)); + QCOMPARE(info(e, QETInformation::ELMT_MANUFACTURER), QString("Maker")); + } + } + + // Emptying a field every element shares clears it, as in the + // one-element editor + void emptyingASharedValueClearsIt() + { + MultiElementInfoWidget w(m_elements); + w.setFieldText(QETInformation::ELMT_MANUFACTURER, QString()); + QCOMPARE(w.fieldState(QETInformation::ELMT_MANUFACTURER), MultiElementInfoWidget::Set); + w.apply(); + for (Element *e : m_elements) + QVERIFY(!e->elementInformations().contains(QETInformation::ELMT_MANUFACTURER)); + } + + // A mixed field emptied again, or a shared one typed back to its + // value, changes nothing + void typingBackToTheValueKeeps() + { + MultiElementInfoWidget w(m_elements); + w.setFieldText(QETInformation::ELMT_SUPPLIER, "x"); + w.setFieldText(QETInformation::ELMT_SUPPLIER, QString()); + QCOMPARE(w.fieldState(QETInformation::ELMT_SUPPLIER), MultiElementInfoWidget::Keep); + w.setFieldText(QETInformation::ELMT_MANUFACTURER, "Make"); + w.setFieldText(QETInformation::ELMT_MANUFACTURER, "Maker"); + QCOMPARE(w.fieldState(QETInformation::ELMT_MANUFACTURER), MultiElementInfoWidget::Keep); + QVERIFY(!w.associatedUndo()); + } + + void restoreAndResetBringBackTheValues() + { + MultiElementInfoWidget w(m_elements); + w.setFieldText(QETInformation::ELMT_SUPPLIER, "ACME"); + w.setFieldCleared(QETInformation::ELMT_DESCRIPTION, true); + w.restoreField(QETInformation::ELMT_SUPPLIER); + QCOMPARE(w.fieldState(QETInformation::ELMT_SUPPLIER), MultiElementInfoWidget::Keep); + QCOMPARE(w.fieldState(QETInformation::ELMT_DESCRIPTION), MultiElementInfoWidget::Clear); + w.reset(); + QCOMPARE(w.fieldState(QETInformation::ELMT_DESCRIPTION), MultiElementInfoWidget::Keep); + QVERIFY(!w.associatedUndo()); + } + + // The selection panel applies each keystroke; they make one undo + // step, and restoring the field in the panel undoes it + void liveEditMergesAndRestores() + { + MultiElementInfoWidget w(m_elements); + w.setLiveEdit(true); + const int before = stack().index(); + w.setFieldText(QETInformation::ELMT_SUPPLIER, "A"); + w.setFieldText(QETInformation::ELMT_SUPPLIER, "AC"); + w.setFieldText(QETInformation::ELMT_SUPPLIER, "ACME"); + QCOMPARE(stack().index(), before + 1); + for (Element *e : m_elements) + QCOMPARE(info(e, QETInformation::ELMT_SUPPLIER), QString("ACME")); + + w.restoreField(QETInformation::ELMT_SUPPLIER); + QCOMPARE(info(m_elements[0], QETInformation::ELMT_SUPPLIER), QString("S1")); + QCOMPARE(info(m_elements[1], QETInformation::ELMT_SUPPLIER), QString("S2")); + QVERIFY(!m_elements[2]->elementInformations().contains(QETInformation::ELMT_SUPPLIER)); + } + + // The first live change never joins an earlier change to the + // same elements (here the paste-like command of initTestCase) + void liveEditDoesNotJoinEarlierChange() + { + QMap, QPair> map; + for (Element *e : m_elements) { + DiagramContext info = e->elementInformations(); + info.addValue(QETInformation::ELMT_PLANT, "P1"); + map.insert(e, qMakePair(e->elementInformations(), info)); + } + stack().push(new ChangeElementInformationCommand(map)); + const int before = stack().index(); + + MultiElementInfoWidget w(m_elements); + w.setLiveEdit(true); + w.setFieldText(QETInformation::ELMT_SUPPLIER, "ACME"); + QCOMPARE(stack().index(), before + 1); + stack().undo(); + for (Element *e : m_elements) + QCOMPARE(info(e, QETInformation::ELMT_PLANT), QString("P1")); + } + + // An undo from outside the editor shows again what the elements have + void undoRefreshesTheEditor() + { + MultiElementInfoWidget w(m_elements); + w.setLiveEdit(true); + w.setFieldText(QETInformation::ELMT_SUPPLIER, "ACME"); + QCOMPARE(w.fieldState(QETInformation::ELMT_SUPPLIER), MultiElementInfoWidget::Set); + stack().undo(); + QCoreApplication::processEvents(); + QCOMPARE(w.fieldIsMixed(QETInformation::ELMT_SUPPLIER), true); + QCOMPARE(w.fieldState(QETInformation::ELMT_SUPPLIER), MultiElementInfoWidget::Keep); + } + + // "Exclude from the bill of materials": partly checked when the + // elements differ, a click sets it on every element + void flagMixedThenSet() + { + MultiElementInfoWidget w(m_elements); + QCheckBox *bom = nullptr; + for (QCheckBox *cb : w.findChildren()) + if (cb->text() == QObject::tr("Exclude from the bill of materials")) + bom = cb; + QVERIFY(bom); + QCOMPARE(bom->checkState(), Qt::PartiallyChecked); + bom->click(); + w.apply(); + for (Element *e : m_elements) + QVERIFY(QET::infoFlagIsTrue(info(e, "exclude_from_bom"))); + } + + void formulaIdKeptOnlyWithItsFormula() + { + QMap, QPair> map; + for (Element *e : m_elements) + { + DiagramContext info = e->elementInformations(); + info.addValue(QETInformation::ELMT_FORMULA, "K%{id}"); + info.addValue(QETInformation::ELMT_FORMULA_ID, "{00000000-0000-0000-0000-000000000001}", false); + map.insert(e, qMakePair(e->elementInformations(), info)); + } + // Element drops an id naming no numbering of the project; + // what is checked is that the editor itself keeps or drops it + stack().push(new ChangeElementInformationCommand(map)); + const bool stored = m_elements[0]->elementInformations().contains(QETInformation::ELMT_FORMULA_ID); + + MultiElementInfoWidget w(m_elements); + w.setFieldText(QETInformation::ELMT_SUPPLIER, "ACME"); + w.apply(); + QCOMPARE(m_elements[0]->elementInformations().contains(QETInformation::ELMT_FORMULA_ID), stored); + + MultiElementInfoWidget w2(m_elements); + w2.setFieldText(QETInformation::ELMT_FORMULA, "Q%{id}"); + w2.apply(); + for (Element *e : m_elements) { + QVERIFY(!e->elementInformations().contains(QETInformation::ELMT_FORMULA_ID)); + QCOMPARE(info(e, QETInformation::ELMT_FORMULA), QString("Q%{id}")); + } + } + + // The selection panel: one element, several, and a mix + void factoryPicksTheEditor() + { + QList one {m_elements[0]}; + std::unique_ptr e1(PropertiesEditorFactory::propertiesEditor(one, nullptr)); + QVERIFY(qobject_cast(e1.get())); + + QList two {m_elements[0], m_elements[1]}; + std::unique_ptr e2(PropertiesEditorFactory::propertiesEditor(two, nullptr)); + auto multi = qobject_cast(e2.get()); + QVERIFY(multi); + QCOMPARE(multi->elements().size(), 2); + + // Same kind of editor: reused for the new selection + QList mix {m_elements[0], m_elements[1], m_elements[2], m_conductor}; + PropertiesEditorWidget *e3 = PropertiesEditorFactory::propertiesEditor(mix, e2.get()); + QCOMPARE(e3, e2.get()); + QCOMPARE(multi->elements().size(), 3); + + // Select all takes the elements' own texts: not other items + QList with_texts {m_elements[0], m_elements[1]}; + for (QGraphicsItem *child : m_elements[0]->childItems()) + if (child->type() == DynamicElementTextItem::Type) + with_texts << child; + QVERIFY(with_texts.size() > 2); + QCOMPARE(MultiElementInfoWidget::withoutOwnTexts(with_texts).size(), 2); + // A text alone, or with another element, stays + QList other_text {m_elements[1], with_texts.last()}; + QCOMPARE(MultiElementInfoWidget::withoutOwnTexts(other_text).size(), 2); + + QList wire {m_conductor}; + std::unique_ptr e4(PropertiesEditorFactory::propertiesEditor(wire, nullptr)); + QVERIFY(!qobject_cast(e4.get())); + } + + // Edit (Ctrl+E) is offered for several elements, not for a mix + void editActionForSeveralElements() + { + QETDiagramEditor *editor = QetAppTest::editor(); + QAction *edit = QetAppTest::action("edit_selected_element"); + QVERIFY(edit); + + m_diagram->clearSelection(); + m_elements[0]->setSelected(true); + m_elements[1]->setSelected(true); + editor->slot_updateComplexActions(); + QVERIFY(edit->isEnabled()); + + // with their own texts, as select all gives them + for (QGraphicsItem *child : m_elements[0]->childItems()) + child->setSelected(true); + editor->slot_updateComplexActions(); + QVERIFY(edit->isEnabled()); + + m_conductor->setSelected(true); + editor->slot_updateComplexActions(); + QVERIFY(!edit->isEnabled()); + m_diagram->clearSelection(); + } + + // The selection panel builds a new editor when the selection goes + // from one element to several and back: it opens on the tab + // picked last there, not on the first one + void panelKeepsTheTabPicked() + { + auto tabsOf = [](QWidget *w) { return w->findChild(); }; + int general = -1; + { + ElementPropertiesWidget first(m_elements[0]); + first.setLiveEdit(true); + QTabWidget *tabs = tabsOf(&first); + QVERIFY(tabs); + for (int i = 0 ; i < tabs->count() ; ++i) + if (tabs->tabText(i) == QObject::tr("General")) + general = i; + QVERIFY(general > 0); + QVERIFY(tabs->currentIndex() != general); + emit tabs->tabBarClicked(general); + tabs->setCurrentIndex(general); + } + ElementPropertiesWidget second(m_elements[1]); + second.setLiveEdit(true); + QCOMPARE(tabsOf(&second)->currentIndex(), general); + + // A dialog (not live) still opens on the first tab + ElementPropertiesWidget dialog(m_elements[1]); + QCOMPARE(tabsOf(&dialog)->currentIndex(), 0); + } + + // The Reset button of the one-element dialog brings back the + // Information tab's values + void oneElementResetRestores() + { + ElementInfoWidget w(m_elements[0]); + ElementInfoPartWidget *part = nullptr; + for (auto *p : w.findChildren()) + if (p->key() == QETInformation::ELMT_SUPPLIER) + part = p; + QVERIFY(part); + part->setText("changed"); + QCOMPARE(w.currentInfo().value(QETInformation::ELMT_SUPPLIER).toString(), QString("changed")); + w.reset(); + QCOMPARE(w.currentInfo().value(QETInformation::ELMT_SUPPLIER).toString(), QString("S1")); + } +}; + +QET_APP_TEST_MAIN(tst_multielementinfo) + +#include "tst_multielementinfo.moc"