Add grouping of folio items: Group and Ungroup (#1070)

Symbols, free texts, shapes and pictures can be grouped from the Edit
menu, the selection's context menu or the command search. Clicking one
member selects the group, so moving, copying and deleting act on all of
it; Ctrl+click on a member deselects the group; a rubber band touching
part of a group selects all of it when released. No default shortcut:
Ctrl+G is "jump to element".

A group is not an object in the scene. Each member keeps its place and
carries the group's uuid (QGraphicsItem::data()), saved as a "group"
attribute written only when set: a project without groups saves exactly
as before, and older versions open a grouped one and ignore the groups.
Re-parenting under a QGraphicsItemGroup would have made every member's
position group-relative; ElementTextItemGroup already needs nine special
cases for that.

- Selection is completed on clicks and at the end of a rubber band, not
  on every selectionChanged(): export, search and Tab select items
  themselves and must not have groups pulled back in.
- Project database: group_uuid on element, shape, independent_text and
  image, kept in step by projectDataBase::itemGroupChanged().
- Paste and folio duplication give each source group one new uuid.
- Undo of Ungroup restores each item's exact group.

Builds on #1065 (uuids and database rows for texts, shapes and images).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
This commit is contained in:
ispyisail
2026-09-27 18:40:25 +13:00
parent 44cabbcfe4
commit f7591e0c9d
16 changed files with 788 additions and 7 deletions
+4
View File
@@ -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
+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/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<DynamicElementTextItem *> 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);
+2
View File
@@ -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
+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
+7
View File
@@ -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
+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"