diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 60fddcf55..a9b64a658 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -232,6 +232,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/exportpropertieswidget.h ${QET_DIR}/sources/genericpanel.cpp ${QET_DIR}/sources/genericpanel.h + ${QET_DIR}/sources/itemgroups.cpp + ${QET_DIR}/sources/itemgroups.h ${QET_DIR}/sources/lastusedstyle.cpp ${QET_DIR}/sources/lastusedstyle.h ${QET_DIR}/sources/machine_info.cpp @@ -836,6 +838,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/undocommand/removediagramcommand.h ${QET_DIR}/sources/undocommand/setautonumcontextcommand.cpp ${QET_DIR}/sources/undocommand/setautonumcontextcommand.h + ${QET_DIR}/sources/undocommand/groupitemscommand.cpp + ${QET_DIR}/sources/undocommand/groupitemscommand.h ${QET_DIR}/sources/undocommand/rotateselectioncommand.cpp ${QET_DIR}/sources/undocommand/rotateselectioncommand.h ${QET_DIR}/sources/undocommand/promoteshapecommand.cpp diff --git a/sources/dataBase/projectdatabase.cpp b/sources/dataBase/projectdatabase.cpp index 68174761a..7c6d29d8a 100644 --- a/sources/dataBase/projectdatabase.cpp +++ b/sources/dataBase/projectdatabase.cpp @@ -22,6 +22,7 @@ #include "../diagram.h" #include "../diagramposition.h" #include "../elementprovider.h" +#include "../itemgroups.h" #include "../qetapp.h" #include "../qetgraphicsitem/conductor.h" #include "../qetgraphicsitem/diagramimageitem.h" @@ -669,6 +670,13 @@ QUuid drawingItemUuid(QObject *object) return QUuid(); } +/// The group_uuid column of @p item's row: its group, or NULL. +QVariant groupValue(const QGraphicsItem *item) +{ + const QUuid group = ItemGroups::groupOf(item); + return group.isNull() ? QVariant() : QVariant(group.toString()); +} + } // namespace /** @@ -751,6 +759,33 @@ void projectDataBase::removeDrawingItem(QGraphicsItem *item) forgetDrawingItem(object); } +/** + @brief projectDataBase::itemGroupChanged + @a item joined or left a group (discussion #1070). An element's row is + updated at once; a drawing item's is queued like any other change to it. + @param item +*/ +void projectDataBase::itemGroupChanged(QGraphicsItem *item) +{ + if (auto element = qgraphicsitem_cast(item)) + { + QSqlQuery update(m_data_base); + update.prepare(QStringLiteral("UPDATE element SET group_uuid = :group_uuid WHERE uuid = :uuid")); + update.bindValue(QStringLiteral(":group_uuid"), groupValue(element)); + update.bindValue(QStringLiteral(":uuid"), element->uuid().toString()); + if (!update.exec()) { + qDebug() << "projectDataBase::itemGroupChanged update error : " << update.lastError(); + } + m_content_changed = true; + return; + } + + QGraphicsObject *object = item ? item->toGraphicsObject() : nullptr; + if (object && !drawingItemTable(object).isEmpty()) { + m_dirty_drawing_items.insert(object); + } +} + /** @brief projectDataBase::drawingItemChanged Queue the sender's row to be rewritten. @@ -884,6 +919,7 @@ bool projectDataBase::writeDrawingItem(QObject *object) query->bindValue(QStringLiteral(":y"), rect.y()); query->bindValue(QStringLiteral(":width"), rect.width()); query->bindValue(QStringLiteral(":height"), rect.height()); + query->bindValue(QStringLiteral(":group_uuid"), groupValue(item)); if (!query->exec()) { qDebug() << "projectDataBase::writeDrawingItem error : " << query->lastError(); return true; @@ -985,6 +1021,7 @@ bool projectDataBase::createDataBase() "pos VARCHAR(6) NOT NULL," "type VARCHAR(50)," "sub_type VARCHAR(50)," + "group_uuid VARCHAR(50)," "FOREIGN KEY (diagram_uuid) REFERENCES diagram (uuid)" ")"); if (!query_.exec(element_table)) { @@ -1085,7 +1122,8 @@ bool projectDataBase::createDataBase() "uuid VARCHAR(50) PRIMARY KEY NOT NULL, " "diagram_uuid VARCHAR(50) NOT NULL, " "pos VARCHAR(6), " - "x REAL, y REAL, width REAL, height REAL, "); + "x REAL, y REAL, width REAL, height REAL, " + "group_uuid VARCHAR(50), "); for (const QString &table : { QStringLiteral("CREATE TABLE shape (") + drawing_columns + "type VARCHAR(20), color VARCHAR(20), fill VARCHAR(20), " @@ -1516,8 +1554,8 @@ void projectDataBase::prepareQuery() //DRAWING ITEMS. OR REPLACE: a row is rewritten in place on every //change, see writeDrawingItem(). - const QString drawing_columns("uuid, diagram_uuid, pos, x, y, width, height"); - const QString drawing_values(":uuid, :diagram_uuid, :pos, :x, :y, :width, :height"); + const QString drawing_columns("uuid, diagram_uuid, pos, x, y, width, height, group_uuid"); + const QString drawing_values(":uuid, :diagram_uuid, :pos, :x, :y, :width, :height, :group_uuid"); m_insert_shape_query = QSqlQuery(m_data_base); m_insert_shape_query.prepare("INSERT OR REPLACE INTO shape (" + drawing_columns + ", type, color, fill) VALUES (" + drawing_values + @@ -1561,7 +1599,7 @@ void projectDataBase::prepareQuery() m_diagram_info_order_changed.prepare("UPDATE diagram_info SET folio = :folio WHERE diagram_uuid = :uuid"); //INSERT ELEMENT - QString insert_element_query("INSERT INTO element (uuid, diagram_uuid, pos, type, sub_type) VALUES (:uuid, :diagram_uuid, :pos, :type, :sub_type)"); + QString insert_element_query("INSERT INTO element (uuid, diagram_uuid, pos, type, sub_type, group_uuid) VALUES (:uuid, :diagram_uuid, :pos, :type, :sub_type, :group_uuid)"); m_insert_elements_query = QSqlQuery(m_data_base); m_insert_elements_query.prepare(insert_element_query); @@ -1675,6 +1713,7 @@ void projectDataBase::bindElementValues(QSqlQuery &query, Element *element, Diag query.bindValue(QStringLiteral(":pos"), diagram->convertPosition(element->scenePos()).toString()); query.bindValue(QStringLiteral(":type"), element_data.typeToString()); query.bindValue(QStringLiteral(":sub_type"), element_data.masterTypeToString()); + query.bindValue(QStringLiteral(":group_uuid"), groupValue(element)); } /** diff --git a/sources/dataBase/projectdatabase.h b/sources/dataBase/projectdatabase.h index 0ce1167ec..510d17eb7 100644 --- a/sources/dataBase/projectdatabase.h +++ b/sources/dataBase/projectdatabase.h @@ -105,6 +105,7 @@ class projectDataBase : public QObject //furniture. Anything else passed here is ignored. void addDrawingItem (QGraphicsItem *item); void removeDrawingItem (QGraphicsItem *item); + void itemGroupChanged (QGraphicsItem *item); private slots: //Refresh the sender()'s row after Conductor::setProperties(). diff --git a/sources/diagram.cpp b/sources/diagram.cpp index bbf1165c5..4831b6484 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -42,7 +42,9 @@ #include "qetinformation.h" #include "qetproject.h" #include "diagramsortkeys.h" +#include "itemgroups.h" #include "textgrid.h" +#include #include #include #include @@ -121,6 +123,7 @@ namespace { for (T *item : items) { Entry entry{stack_rank.value(item, INT_MAX), QString(), item->toXml(document)}; + ItemGroups::write(entry.xml, item); // Only an item the stacking query missed needs a tiebreak. if (entry.rank == INT_MAX) { QTextStream stream(&entry.xml_text); @@ -210,6 +213,7 @@ Diagram::Diagram(QETProject *project) : connect(&border_and_titleblock, &BorderTitleBlock::needTitleBlockTemplate, this, &Diagram::setTitleBlockTemplate); + connect(&border_and_titleblock, &BorderTitleBlock::informationChanged, this, &Diagram::titleChanged); @@ -432,7 +436,9 @@ void Diagram::mousePressEvent(QGraphicsSceneMouseEvent *event) } } + rememberSelection(); QGraphicsScene::mousePressEvent(event); + completeGroupSelection(); } /** @@ -471,6 +477,9 @@ void Diagram::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } QGraphicsScene::mouseReleaseEvent(event); + //A click on an already selected item changes the selection on + //release, not on press (Ctrl toggles it, a plain click keeps only it). + completeGroupSelection(); } /** @@ -1245,8 +1254,9 @@ QDomDocument Diagram::toXml(bool whole_content, bool is_copy_command) { if (!list_elements.isEmpty()) { auto dom_elements = document.createElement(QStringLiteral("elements")); for (auto elmt : list_elements) { - dom_elements.appendChild(elmt->toXml(document, - table_adr_id)); + QDomElement dom_element = elmt->toXml(document, table_adr_id); + ItemGroups::write(dom_element, elmt); + dom_elements.appendChild(dom_element); // If copy is active we have to undo the changes we have made during creating(filling) 'list_elements' if(is_copy_command && (elmt->linkType() == Element::Slave || elmt->linkType()&Element::AllReport)) restoreText(elmt); @@ -1666,6 +1676,7 @@ bool Diagram::fromXml(QDomElement &document, delete nvel_elmt; qDebug() << QStringLiteral("Diagram::fromXml() : Le chargement des parametres d'un element a echoue"); } else { + ItemGroups::setGroup(nvel_elmt, ItemGroups::read(element_xml)); added_elements << nvel_elmt; } } @@ -1701,6 +1712,7 @@ bool Diagram::fromXml(QDomElement &document, IndependentTextItem *iti = new IndependentTextItem(); iti -> fromXml(text_xml); settle_uuid(iti, text_xml, QStringLiteral("input"), added_texts.size()); + ItemGroups::setGroup(iti, ItemGroups::read(text_xml)); addItem(iti); added_texts << iti; } @@ -1713,6 +1725,7 @@ bool Diagram::fromXml(QDomElement &document, DiagramImageItem *dii = new DiagramImageItem (); dii -> fromXml(image_xml); settle_uuid(dii, image_xml, QStringLiteral("image"), added_images.size()); + ItemGroups::setGroup(dii, ItemGroups::read(image_xml)); addItem(dii); added_images << dii; } @@ -1725,6 +1738,7 @@ bool Diagram::fromXml(QDomElement &document, QetShapeItem *dii = new QetShapeItem (QPointF(0,0)); dii -> fromXml(shape_xml); settle_uuid(dii, shape_xml, QStringLiteral("shape"), added_shapes.size()); + ItemGroups::setGroup(dii, ItemGroups::read(shape_xml)); addItem(dii); added_shapes << dii; } @@ -2127,6 +2141,75 @@ void Diagram::selectAllTextFields() emit selectionChanged(); } +/** + @brief Diagram::setItemGroup + Put @a item in @a group, or take it out of its group if @a group is + null, and keep its row in the project database in step. + @param item + @param group +*/ +void Diagram::setItemGroup(QGraphicsItem *item, const QUuid &group) +{ + ItemGroups::setGroup(item, group); + if (m_project) { + m_project->dataBase()->itemGroupChanged(item); + } +} + +/** + @brief Diagram::completeGroupSelection + Make the selection whole groups again after the user changed it with a + click, see ItemGroups::completeSelection(). Called from the mouse + handlers and, when a rubber band is released, from DiagramView -- not + on every selectionChanged(): code that selects items itself (export, + search, Tab cycling) deselects and reselects one item at a time and + must not have groups pulled back in behind it. + Left alone while a rubber band is being dragged, which reselects exactly + what it covers on every mouse step. +*/ +void Diagram::completeGroupSelection() +{ + for (QGraphicsView *view : views()) { + if (!view->rubberBandRect().isNull()) { + return; + } + } + + QList previous; + for (const QPointer &item : std::as_const(m_previous_selection)) { + if (item) { + previous << item.data(); + } + } + + //One selectionChanged() for the whole group, not one per member: + //the properties dock rebuilds on each. + blockSignals(true); + const bool changed = ItemGroups::completeSelection( + this, previous, + QApplication::keyboardModifiers().testFlag(Qt::ControlModifier)); + blockSignals(false); + if (changed) { + emit selectionChanged(); + } + rememberSelection(); +} + +/** + @brief Diagram::rememberSelection + Keep the current selection, the "before" completeGroupSelection() needs + to tell a member being Ctrl+clicked off. +*/ +void Diagram::rememberSelection() +{ + m_previous_selection.clear(); + for (QGraphicsItem *item : selectedItems()) { + if (QGraphicsObject *object = item->toGraphicsObject()) { + m_previous_selection << object; + } + } +} + /** @brief Diagram::selectNextItem Select the next (or, if @a forward is false, the previous) selectable diff --git a/sources/diagram.h b/sources/diagram.h index 244299a04..8c2b453d6 100644 --- a/sources/diagram.h +++ b/sources/diagram.h @@ -27,6 +27,7 @@ #include "qgimanager.h" #include +#include #include #include #include @@ -138,6 +139,10 @@ class Diagram : public QGraphicsScene bool m_freeze_new_conductors_; QUuid m_uuid = QUuid::createUuid(); + //Selection before the current click, see completeGroupSelection() + QList> m_previous_selection; + void rememberSelection(); + bool uuidUsedByOtherDiagram(const QUuid &uuid) const; QUuid derivedUuid(const QDomElement &root, const QString &reason) const; @@ -282,6 +287,7 @@ class Diagram : public QGraphicsScene const QString& title, const QString& seq, NumerotationContext *nc); void changeZValue(QET::DepthOption option); + void setItemGroup(QGraphicsItem *item, const QUuid &group); public slots: void adjustSceneRect (); @@ -299,6 +305,7 @@ class Diagram : public QGraphicsScene void invertSelection(); void selectAllConductors(); void selectAllTextFields(); + void completeGroupSelection(); signals: void showDiagram (Diagram *); diff --git a/sources/diagramcommands.cpp b/sources/diagramcommands.cpp index 38c15a2a9..0927ce815 100644 --- a/sources/diagramcommands.cpp +++ b/sources/diagramcommands.cpp @@ -18,6 +18,7 @@ #include "diagramcommands.h" #include "diagram.h" +#include "itemgroups.h" #include "qetgraphicsitem/conductortextitem.h" #include "qetgraphicsitem/diagramimageitem.h" #include "qetgraphicsitem/dynamicelementtextitem.h" @@ -216,6 +217,27 @@ void PasteDiagramCommand::redo() } } } + + //Pasted groups become new groups: the members of one source + //group all get the same new uuid, never the source's, or the + //copy would join the original's group. After the elements got + //their own uuids: the database row is found by uuid, and before + //that it would have been the source element's row. + QHash renewed_groups; + for (QGraphicsItem *item : content.items(DiagramContent::Elements + | DiagramContent::TextFields + | DiagramContent::Images + | DiagramContent::Shapes)) + { + const QUuid source_group = ItemGroups::groupOf(item); + if (source_group.isNull()) { + continue; + } + if (!renewed_groups.contains(source_group)) { + renewed_groups.insert(source_group, QUuid::createUuid()); + } + diagram -> setItemGroup(item, renewed_groups.value(source_group)); + } } else { diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index e3996bf66..af3dec5e0 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -122,6 +122,13 @@ DiagramView::DiagramView(Diagram *diagram, QWidget *parent) : }); connect(m_diagram, &Diagram::showDiagram, this, &DiagramView::showDiagram); + //The diagram leaves group completion alone while a rubber band is + //dragged; finish it when the band is released (null rect). + connect(this, &QGraphicsView::rubberBandChanged, this, [this](QRect rect) { + if (rect.isNull()) { + m_diagram->completeGroupSelection(); + } + }); connect(m_diagram, &QGraphicsScene::sceneRectChanged, this, &DiagramView::adjustSceneRect); connect(&(m_diagram -> border_and_titleblock), &BorderTitleBlock::informationChanged, this, &DiagramView::updateWindowTitle); connect(diagram, &Diagram::findElementRequired, this, &DiagramView::findElementRequired); diff --git a/sources/elementspanelwidget.cpp b/sources/elementspanelwidget.cpp index 8b42bb594..71a0b6b9e 100644 --- a/sources/elementspanelwidget.cpp +++ b/sources/elementspanelwidget.cpp @@ -32,6 +32,7 @@ #include "qetgraphicsitem/diagramimageitem.h" #include "qetgraphicsitem/independenttextitem.h" #include "qetgraphicsitem/qetshapeitem.h" +#include "itemgroups.h" #include "qetinformation.h" /* @@ -833,6 +834,20 @@ void ElementsPanelWidget::duplicateDiagram() } } } + + // Groups too: a group of the copy is its own, so selecting it + // is the same on both folios but the database tells them apart. + QHash renewed_groups; + for (QGraphicsItem *item : new_diagram->items()) { + const QUuid source_group = ItemGroups::groupOf(item); + if (source_group.isNull()) { + continue; + } + if (!renewed_groups.contains(source_group)) { + renewed_groups.insert(source_group, QUuid::createUuid()); + } + new_diagram->setItemGroup(item, renewed_groups.value(source_group)); + } } elements_panel->reload(); diff --git a/sources/itemgroups.cpp b/sources/itemgroups.cpp new file mode 100644 index 000000000..4e4c7ac9d --- /dev/null +++ b/sources/itemgroups.cpp @@ -0,0 +1,124 @@ +/* + 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 "itemgroups.h" + +#include +#include +#include +#include + +/** + @return the uuid of the group @a item belongs to, or a null uuid. +*/ +QUuid ItemGroups::groupOf(const QGraphicsItem *item) +{ + return item ? item->data(data_key).toUuid() : QUuid(); +} + +/** + Put @a item in @a group, or take it out of any group if @a group is null. +*/ +void ItemGroups::setGroup(QGraphicsItem *item, const QUuid &group) +{ + if (!item) { + return; + } + item->setData(data_key, group.isNull() ? QVariant() : QVariant(group)); +} + +/** + Write @a item's group, if it has one, on its XML element @a xml. + Nothing is written otherwise, so a project without groups saves exactly + as before. +*/ +void ItemGroups::write(QDomElement &xml, const QGraphicsItem *item) +{ + const QUuid group = groupOf(item); + if (!group.isNull()) { + xml.setAttribute(QString::fromLatin1(xml_attribute), group.toString()); + } +} + +/** + @return the group written on @a xml, or a null uuid. +*/ +QUuid ItemGroups::read(const QDomElement &xml) +{ + return QUuid(xml.attribute(QString::fromLatin1(xml_attribute))); +} + +/** + Make the selection of @a scene whole groups again after it changed. + A group with a selected member is selected entirely, except when the + change took members of an entirely selected group out of the selection + while @a toggling (Ctrl+click on a member): then the whole group leaves + the selection, as the user meant. + @param previous : the selection before the change + @param toggling : true when Ctrl is held + @return true if the selection was changed +*/ +bool ItemGroups::completeSelection(QGraphicsScene *scene, + const QList &previous, + bool toggling) +{ + if (!scene) { + return false; + } + + QSet touched; + const QList selected = scene->selectedItems(); + for (QGraphicsItem *item : selected) { + const QUuid group = groupOf(item); + if (!group.isNull()) { + touched << group; + } + } + + //Groups that just lost a member from the selection + QSet shrunk; + for (QGraphicsItem *item : previous) { + if (item->scene() == scene && !item->isSelected()) { + const QUuid group = groupOf(item); + if (!group.isNull()) { + shrunk << group; + } + } + } + + if (touched.isEmpty()) { + return false; + } + + //One pass over the folio for every touched group, not one per group: + //select all on a folio of 2000 items in 200 groups took 44 ms the + //other way. + bool changed = false; + for (QGraphicsItem *item : scene->items()) + { + const QUuid group = groupOf(item); + if (group.isNull() || !touched.contains(group)) { + continue; + } + const bool select = !(toggling && shrunk.contains(group)); + if (item->isSelected() != select) { + item->setSelected(select); + changed = true; + } + } + return changed; +} diff --git a/sources/itemgroups.h b/sources/itemgroups.h new file mode 100644 index 000000000..7e3c944b1 --- /dev/null +++ b/sources/itemgroups.h @@ -0,0 +1,60 @@ +/* + 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 ITEMGROUPS_H +#define ITEMGROUPS_H + +#include +#include + +class QDomElement; +class QGraphicsItem; +class QGraphicsScene; + +/** + Groups of items on a folio (discussion #1070): symbols, free texts, + shapes and pictures that select, move, copy and delete together. + + A group is not an object in the scene. Each member keeps its place and + carries the group's uuid, and selecting one member selects them all; + moving, copying and deleting then need nothing new, because they act on + the selection. Re-parenting the members under a QGraphicsItemGroup would + make every position group-relative, which every piece of code reading a + symbol's position would have to learn about. + + This part knows nothing of Diagram, so it can be tested on a plain scene. +*/ +namespace ItemGroups +{ + /// QGraphicsItem::data() key holding an item's group uuid. + inline constexpr int data_key = 0x475250; // "GRP" + + /// XML attribute holding it, on , , and . + inline constexpr char xml_attribute[] = "group"; + + QUuid groupOf(const QGraphicsItem *item); + void setGroup(QGraphicsItem *item, const QUuid &group); + + void write(QDomElement &xml, const QGraphicsItem *item); + QUuid read(const QDomElement &xml); + + bool completeSelection(QGraphicsScene *scene, + const QList &previous, + bool toggling); +} + +#endif // ITEMGROUPS_H diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index d97d08b24..85dad80d8 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -66,6 +66,7 @@ #include "undocommand/addelementtextcommand.h" #include "utils/qetsettings.h" #include "utils/qetutils.h" +#include "undocommand/groupitemscommand.h" #include "undocommand/rotateselectioncommand.h" #include "undocommand/rotatetextscommand.h" #include "diagram.h" @@ -813,6 +814,8 @@ void QETDiagramEditor::setUpActions() m_find_element = m_selection_actions_group.addAction( QET::Icons::ZoomDraw, tr("Retrouver dans le panel") ); m_edit_selection = m_selection_actions_group.addAction( QET::Icons::ElementEdit, tr("Éditer l'item sélectionné") ); m_group_selected_texts = m_selection_actions_group.addAction( QET::Icons::textGroup, tr("Grouper les textes sélectionnés")); + m_group_selection = m_selection_actions_group.addAction( tr("Grouper") ); + m_ungroup_selection = m_selection_actions_group.addAction( tr("Dégrouper") ); ShortcutManager::instance().registerAction(m_delete_selection, "diagrameditor.delete_selection", tr("Éditeur de schémas"), Qt::Key_Delete); ShortcutManager::instance().registerAction(m_rotate_selection, "diagrameditor.rotate_selection", tr("Éditeur de schémas"), Qt::Key_Space); @@ -918,6 +921,14 @@ void QETDiagramEditor::setUpActions() m_find_element ->setData("find_selected_element"); m_edit_selection ->setData("edit_selected_element"); m_group_selected_texts->setData("group_selected_texts"); + m_group_selection ->setData("group_selection"); + m_ungroup_selection ->setData("ungroup_selection"); + + //No default key: Ctrl+G is "jump to element". A user can bind one. + ShortcutManager::instance().registerAction(m_group_selection, "diagrameditor.group_selection", tr("Éditeur de schémas"), QKeySequence()); + ShortcutManager::instance().registerAction(m_ungroup_selection, "diagrameditor.ungroup_selection", tr("Éditeur de schémas"), QKeySequence()); + m_group_selection ->setStatusTip(tr("Groupe les éléments, textes, formes et images sélectionnés : ils se sélectionnent, se déplacent et se copient ensemble", "status bar tip")); + m_ungroup_selection->setStatusTip(tr("Défait les groupes sélectionnés", "status bar tip")); connect(&m_selection_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::selectionGroupTriggered); @@ -2055,6 +2066,15 @@ void QETDiagramEditor::selectionGroupTriggered(QAction *action) findElementInPanel(currentElement()->location()); else if (value == "edit_selected_element") dv->editSelection(); + else if (value == "group_selection" || value == "ungroup_selection") + { + GroupItemsCommand *command = value == "group_selection" + ? GroupItemsCommand::group(diagram) + : GroupItemsCommand::ungroup(diagram); + if (command) { + diagram->undoStack().push(command); + } + } else if (value == "group_selected_texts") { QList deti_list = dc.m_element_texts.values(); @@ -2198,7 +2218,9 @@ void QETDiagramEditor::slot_updateComplexActions() << m_rotate_selection << m_rotate_group_selection << m_edit_selection - << m_group_selected_texts; + << m_group_selected_texts + << m_group_selection + << m_ungroup_selection; for(QAction *action : action_list) action->setEnabled(false); @@ -2262,6 +2284,9 @@ void QETDiagramEditor::slot_updateComplexActions() else m_group_selected_texts->setDisabled(true); + m_group_selection ->setEnabled(GroupItemsCommand::canGroup(diagram_)); + m_ungroup_selection->setEnabled(GroupItemsCommand::canUngroup(diagram_)); + // actions need only one editable item int selected_image = dc.count(DiagramContent::Images); diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index c4ac40835..a09ad8d4c 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -253,6 +253,8 @@ class QETDiagramEditor : public QETMainWindow *m_rotate_texts, ///< Direct selected text items to a specific angle *m_find_element, ///< Find the selected element in the panel *m_group_selected_texts = nullptr, + *m_group_selection = nullptr, ///< Group the selected items (#1070) + *m_ungroup_selection = nullptr, ///< Ungroup the selected groups *m_close_file, ///< Close current project file *m_save_file, ///< Save current project *m_save_file_as, ///< Save current project as a specific file diff --git a/sources/undocommand/groupitemscommand.cpp b/sources/undocommand/groupitemscommand.cpp new file mode 100644 index 000000000..83f94bf18 --- /dev/null +++ b/sources/undocommand/groupitemscommand.cpp @@ -0,0 +1,181 @@ +/* + 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 "groupitemscommand.h" + +#include "../diagram.h" +#include "../itemgroups.h" +#include "../qetgraphicsitem/diagramimageitem.h" +#include "../qetgraphicsitem/element.h" +#include "../qetgraphicsitem/independenttextitem.h" +#include "../qetgraphicsitem/qetshapeitem.h" + +#include + +namespace { + QList selectedGroupable(Diagram *diagram) + { + QList items; + for (QGraphicsItem *item : diagram->selectedItems()) { + if (GroupItemsCommand::isGroupable(item)) { + items << item->toGraphicsObject(); + } + } + return items; + } +} + +/** + @return a command grouping the selected symbols, free texts, shapes and + pictures of @a diagram into one new group, or nullptr if fewer than two + are selected. The selection already holds whole groups (see + ItemGroups::completeSelection()), so grouping items that were in groups + merges those groups into the new one. +*/ +GroupItemsCommand *GroupItemsCommand::group(Diagram *diagram) +{ + if (!canGroup(diagram)) { + return nullptr; + } + auto command = new GroupItemsCommand(diagram, selectedGroupable(diagram), QUuid::createUuid()); + command->setText(QObject::tr("Grouper %n objet(s)", "", command->m_changes.size())); + return command; +} + +/** + @return a command taking every member of the selected groups of + @a diagram out of its group, or nullptr if no group is selected. +*/ +GroupItemsCommand *GroupItemsCommand::ungroup(Diagram *diagram) +{ + if (!canUngroup(diagram)) { + return nullptr; + } + QList items; + for (QGraphicsObject *item : selectedGroupable(diagram)) { + if (!ItemGroups::groupOf(item).isNull()) { + items << item; + } + } + auto command = new GroupItemsCommand(diagram, items, QUuid()); + command->setText(QObject::tr("Dégrouper %n objet(s)", "", command->m_changes.size())); + return command; +} + +/** + @return true for the kinds of item a group can hold: symbols, free texts, + shapes and pictures. Never conductors, which follow their symbols, and + never a symbol's own texts, which belong to it. +*/ +bool GroupItemsCommand::isGroupable(const QGraphicsItem *item) +{ + if (!item) { + return false; + } + switch (item->type()) + { + case Element::Type: + case IndependentTextItem::Type: + case QetShapeItem::Type: + case DiagramImageItem::Type: + return true; + default: + return false; + } +} + +/** + @return true if the selection of @a diagram holds at least two items + that could be grouped, and is not already exactly one group. +*/ +bool GroupItemsCommand::canGroup(Diagram *diagram) +{ + if (!diagram || diagram->isReadOnly()) { + return false; + } + const QList items = selectedGroupable(diagram); + if (items.size() < 2) { + return false; + } + QSet groups; + for (QGraphicsObject *item : items) { + groups << ItemGroups::groupOf(item); + } + return groups.size() > 1 || groups.contains(QUuid()); +} + +/** + @return true if the selection of @a diagram holds a grouped item. +*/ +bool GroupItemsCommand::canUngroup(Diagram *diagram) +{ + if (!diagram || diagram->isReadOnly()) { + return false; + } + for (QGraphicsObject *item : selectedGroupable(diagram)) { + if (!ItemGroups::groupOf(item).isNull()) { + return true; + } + } + return false; +} + +/** + @brief GroupItemsCommand::GroupItemsCommand + @param diagram : diagram holding @a items + @param items : items to put in @a group + @param group : the new group, or a null uuid to ungroup +*/ +GroupItemsCommand::GroupItemsCommand(Diagram *diagram, + const QList &items, + const QUuid &group) : + m_diagram(diagram), + m_group(group) +{ + for (QGraphicsObject *item : items) { + m_changes.append({item, ItemGroups::groupOf(item)}); + } +} + +/** + @brief GroupItemsCommand::undo +*/ +void GroupItemsCommand::undo() +{ + apply(false); +} + +/** + @brief GroupItemsCommand::redo +*/ +void GroupItemsCommand::redo() +{ + apply(true); +} + +void GroupItemsCommand::apply(bool redo) +{ + if (!m_diagram) { + return; + } + m_diagram->showMe(); + for (const Change &change : std::as_const(m_changes)) { + if (change.item) { + m_diagram->setItemGroup(change.item, redo ? m_group : change.before); + } + } +} diff --git a/sources/undocommand/groupitemscommand.h b/sources/undocommand/groupitemscommand.h new file mode 100644 index 000000000..4357f6996 --- /dev/null +++ b/sources/undocommand/groupitemscommand.h @@ -0,0 +1,62 @@ +/* + 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 GROUPITEMSCOMMAND_H +#define GROUPITEMSCOMMAND_H + +#include +#include +#include +#include + +class Diagram; +class QGraphicsItem; +class QGraphicsObject; + +/** + @brief The GroupItemsCommand class + Groups or ungroups the selected items of a diagram (discussion #1070). + Undo gives every item back the exact group it had, so a redo after it + still refers to the same groups. +*/ +class GroupItemsCommand : public QUndoCommand +{ + public: + static GroupItemsCommand *group(Diagram *diagram); + static GroupItemsCommand *ungroup(Diagram *diagram); + static bool isGroupable(const QGraphicsItem *item); + static bool canGroup(Diagram *diagram); + static bool canUngroup(Diagram *diagram); + + void undo() override; + void redo() override; + + private: + GroupItemsCommand(Diagram *diagram, const QList &items, const QUuid &group); + void apply(bool redo); + + struct Change { + QPointer item; + QUuid before; + }; + + QPointer m_diagram; + QList m_changes; + QUuid m_group; +}; + +#endif // GROUPITEMSCOMMAND_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 66074282d..7757aad08 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -104,6 +104,13 @@ add_test(NAME tst_textgrid COMMAND tst_textgrid) target_include_directories(tst_textgrid PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_textgrid PRIVATE Qt::Test) +# itemgroups.cpp keeps group membership and the selection rule apart from +# Diagram, so it is tested here on a plain scene of rectangles. +add_executable(tst_itemgroups tst_itemgroups.cpp ${QET_DIR}/sources/itemgroups.cpp) +add_test(NAME tst_itemgroups COMMAND tst_itemgroups) +target_include_directories(tst_itemgroups PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_itemgroups PRIVATE Qt::Test Qt::Widgets Qt::Xml) + add_executable( tst_qetpalette tst_qetpalette.cpp diff --git a/tests/qttest/tst_itemgroups.cpp b/tests/qttest/tst_itemgroups.cpp new file mode 100644 index 000000000..079c8c94c --- /dev/null +++ b/tests/qttest/tst_itemgroups.cpp @@ -0,0 +1,142 @@ +#include +#include +#include +#include + +#include "itemgroups.h" + +class tst_itemgroups : public QObject +{ + Q_OBJECT + + QGraphicsScene *scene = nullptr; + QGraphicsRectItem *a = nullptr, *b = nullptr, *c = nullptr, *d = nullptr, *e = nullptr; + QUuid g1, g2; + + QGraphicsRectItem *add() + { + auto item = scene->addRect(0, 0, 10, 10); + item->setFlag(QGraphicsItem::ItemIsSelectable); + return item; + } + + QList selection() const { return scene->selectedItems(); } + + // Select exactly @a items, as Qt leaves the scene after a click. + void select(const QList &items) + { + scene->clearSelection(); + for (auto item : items) { + item->setSelected(true); + } + } + + bool selected(std::initializer_list expected) const + { + QList got = selection(); + if (got.size() != int(expected.size())) { + return false; + } + for (auto item : expected) { + if (!got.contains(item)) { + return false; + } + } + return true; + } + +private slots: + // a, b in g1; c alone; d, e in g2 + void init() + { + scene = new QGraphicsScene; + a = add(); b = add(); c = add(); d = add(); e = add(); + g1 = QUuid::createUuid(); + g2 = QUuid::createUuid(); + ItemGroups::setGroup(a, g1); + ItemGroups::setGroup(b, g1); + ItemGroups::setGroup(d, g2); + ItemGroups::setGroup(e, g2); + } + + void cleanup() + { + delete scene; + scene = nullptr; + } + + void clickingAMemberSelectsTheGroup() + { + select({a}); + QVERIFY(ItemGroups::completeSelection(scene, {}, false)); + QVERIFY(selected({a, b})); + } + + // Releasing a click on one member of the selected group leaves only it + // selected in Qt; the group must stay whole. + void reclickingAMemberKeepsTheGroup() + { + select({b}); + ItemGroups::completeSelection(scene, {a, b}, false); + QVERIFY(selected({a, b})); + } + + void ctrlClickingAMemberOffDeselectsTheGroup() + { + select({b}); + ItemGroups::completeSelection(scene, {a, b}, true); + QVERIFY(selected({})); + } + + void ctrlClickingAMemberOnAddsTheGroup() + { + select({c, a}); + ItemGroups::completeSelection(scene, {c}, true); + QVERIFY(selected({a, b, c})); + } + + void otherGroupsAreUntouched() + { + select({b, d, e}); + ItemGroups::completeSelection(scene, {a, b, d, e}, true); + QVERIFY(selected({d, e})); + } + + void ungroupedItemsAreUntouched() + { + select({c}); + QVERIFY(!ItemGroups::completeSelection(scene, {}, false)); + QVERIFY(selected({c})); + } + + // A member taken off the scene (deleted, kept by the undo stack) must + // not count as "deselected". + void itemsLeavingTheSceneAreIgnored() + { + select({a, b}); + scene->removeItem(b); + ItemGroups::completeSelection(scene, {a, b}, true); + QVERIFY(selected({a})); + delete b; + b = nullptr; + } + + void xmlRoundTrip() + { + QDomDocument doc; + QDomElement grouped = doc.createElement(QStringLiteral("element")); + ItemGroups::write(grouped, a); + QCOMPARE(ItemGroups::read(grouped), g1); + + QDomElement alone = doc.createElement(QStringLiteral("element")); + ItemGroups::write(alone, c); + QVERIFY(!alone.hasAttribute(QStringLiteral("group"))); + QVERIFY(ItemGroups::read(alone).isNull()); + + ItemGroups::setGroup(a, QUuid()); + QVERIFY(ItemGroups::groupOf(a).isNull()); + } +}; + +QTEST_MAIN(tst_itemgroups) +#include "tst_itemgroups.moc"