/* 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; }