Merge pull request #1074 from ispyisail/feat/1070-item-groups

Add Group and Ungroup for items on a folio
This commit is contained in:
Laurent Trinques
2026-09-27 20:15:37 +02:00
committed by GitHub
16 changed files with 787 additions and 7 deletions
+4
View File
@@ -234,6 +234,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
@@ -839,6 +841,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/alignselectioncommand.cpp
${QET_DIR}/sources/undocommand/alignselectioncommand.h
${QET_DIR}/sources/undocommand/rotateselectioncommand.cpp
+43 -4
View File
@@ -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<Element *>(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));
}
/**
+1
View File
@@ -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().
+85 -2
View File
@@ -42,7 +42,9 @@
#include "qetinformation.h"
#include "qetproject.h"
#include "diagramsortkeys.h"
#include "itemgroups.h"
#include "textgrid.h"
#include <QGraphicsView>
#include <QTextStream>
#include <algorithm>
#include <climits>
@@ -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<QGraphicsItem *> previous;
for (const QPointer<QGraphicsObject> &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
+7
View File
@@ -27,6 +27,7 @@
#include "qgimanager.h"
#include <QHash>
#include <QPointer>
#include <QUuid>
#include <QtWidgets>
#include <QtXml>
@@ -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<QPointer<QGraphicsObject>> 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 *);
+22
View File
@@ -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<QUuid, QUuid> 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
{
+7
View File
@@ -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);
+15
View File
@@ -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<QUuid, QUuid> 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();
+124
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "itemgroups.h"
#include <QDomElement>
#include <QGraphicsItem>
#include <QGraphicsScene>
#include <QSet>
/**
@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<QGraphicsItem *> &previous,
bool toggling)
{
if (!scene) {
return false;
}
QSet<QUuid> touched;
const QList<QGraphicsItem *> 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<QUuid> 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;
}
+60
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef ITEMGROUPS_H
#define ITEMGROUPS_H
#include <QList>
#include <QUuid>
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 <element>, <input>, <shape> and <image>.
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<QGraphicsItem *> &previous,
bool toggling);
}
#endif // ITEMGROUPS_H
+26 -1
View File
@@ -66,6 +66,7 @@
#include "undocommand/addelementtextcommand.h"
#include "utils/qetsettings.h"
#include "utils/qetutils.h"
#include "undocommand/groupitemscommand.h"
#include "undocommand/alignselectioncommand.h"
#include "undocommand/rotateselectioncommand.h"
#include "undocommand/rotatetextscommand.h"
@@ -815,6 +816,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);
@@ -920,6 +923,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);
@@ -2068,6 +2079,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<DynamicElementTextItem *> deti_list = dc.m_element_texts.values();
@@ -2250,7 +2270,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);
m_align_actions_group.setEnabled(false);
@@ -2315,6 +2337,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);
+2
View File
@@ -256,6 +256,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
+181
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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 <QSet>
namespace {
QList<QGraphicsObject *> selectedGroupable(Diagram *diagram)
{
QList<QGraphicsObject *> 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<QGraphicsObject *> 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<QGraphicsObject *> items = selectedGroupable(diagram);
if (items.size() < 2) {
return false;
}
QSet<QUuid> 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<QGraphicsObject *> &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);
}
}
}
+62
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef GROUPITEMSCOMMAND_H
#define GROUPITEMSCOMMAND_H
#include <QList>
#include <QPointer>
#include <QUndoCommand>
#include <QUuid>
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<QGraphicsObject *> &items, const QUuid &group);
void apply(bool redo);
struct Change {
QPointer<QGraphicsObject> item;
QUuid before;
};
QPointer<Diagram> m_diagram;
QList<Change> m_changes;
QUuid m_group;
};
#endif // GROUPITEMSCOMMAND_H
+6
View File
@@ -111,6 +111,12 @@ add_test(NAME tst_alignment COMMAND tst_alignment)
target_include_directories(tst_alignment PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_alignment 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)
# legacyelementtypes.h is header-only: the rewrite of element type names in
# nomenclature queries saved before June 2022.
add_executable(tst_legacyelementtypes tst_legacyelementtypes.cpp)
+142
View File
@@ -0,0 +1,142 @@
#include <QtTest>
#include <QDomDocument>
#include <QGraphicsRectItem>
#include <QGraphicsScene>
#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<QGraphicsItem *> selection() const { return scene->selectedItems(); }
// Select exactly @a items, as Qt leaves the scene after a click.
void select(const QList<QGraphicsItem *> &items)
{
scene->clearSelection();
for (auto item : items) {
item->setSelected(true);
}
}
bool selected(std::initializer_list<QGraphicsItem *> expected) const
{
QList<QGraphicsItem *> 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"