From ce0798bb18950dfdfbdb3520f6f218930792253e Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sun, 18 Oct 2026 21:44:00 +1300 Subject: [PATCH] Add editing the information of several elements at once (#1439) Selecting several elements left the Selection properties panel blank and Edit (Ctrl+E) disabled, so giving many elements the same supplier or manufacturer meant editing them one by one. The panel now shows the Information fields for two or more elements, or for elements selected with other items (left unchanged, and counted in the panel's header). Ctrl+E opens the same editor as a dialog when only elements are selected. - A field shows the value every element shares, or "Multiple values". Left alone, each element keeps its own value; typed text goes to every element; the erase box clears the field on every element. - A restore button per field, and Reset, bring back the values the elements had. - One apply is one undo step. The keystrokes typed in the panel merge into one step but never into an earlier change of the same elements (a paste of element information, another edit). - Numbering scheme, label freezing and the number picker are left out: they are about one element. Custom properties are kept as they are. Also: - An element's own texts, selected with it by Select all, count as part of the element, not as other items. - ElementInfoWidget::reset(): Reset in the element dialog now brings back the Information tab's values; it did nothing. - The panel opens an element's editor on the tab picked last, instead of the first tab, after the selection went to something else and back. Test: tst_multielementinfo. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 2 + sources/diagramview.cpp | 30 +- sources/factory/propertieseditorfactory.cpp | 40 +- sources/qetdiagrameditor.cpp | 60 +- sources/ui/elementinfowidget.cpp | 12 + sources/ui/elementinfowidget.h | 1 + sources/ui/elementpropertieswidget.cpp | 27 + sources/ui/elementpropertieswidget.h | 1 + sources/ui/multielementinfowidget.cpp | 837 ++++++++++++++++++++ sources/ui/multielementinfowidget.h | 125 +++ tests/qttest/CMakeLists.txt | 7 + tests/qttest/tst_multielementinfo.cpp | 412 ++++++++++ 12 files changed, 1506 insertions(+), 48 deletions(-) create mode 100644 sources/ui/multielementinfowidget.cpp create mode 100644 sources/ui/multielementinfowidget.h create mode 100644 tests/qttest/tst_multielementinfo.cpp 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"