diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake
index af7278f0c..9f92fcc3e 100644
--- a/cmake/qet_compilation_vars.cmake
+++ b/cmake/qet_compilation_vars.cmake
@@ -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
@@ -327,6 +329,7 @@ set(QET_SRC_FILES
${QET_DIR}/sources/autoNum/ui/selectautonumw.cpp
${QET_DIR}/sources/autoNum/ui/selectautonumw.h
+ ${QET_DIR}/sources/dataBase/legacyelementtypes.h
${QET_DIR}/sources/dataBase/projectdatabase.cpp
${QET_DIR}/sources/dataBase/projectdatabase.h
${QET_DIR}/sources/dataBase/sqlreadonly.cpp
@@ -838,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
diff --git a/sources/ElementsCollection/elementpickerpopup.cpp b/sources/ElementsCollection/elementpickerpopup.cpp
index 2cfc1aaa4..5ebc14fd4 100644
--- a/sources/ElementsCollection/elementpickerpopup.cpp
+++ b/sources/ElementsCollection/elementpickerpopup.cpp
@@ -404,10 +404,16 @@ void ElementPickerPopup::setCommands(const QStringList &ids)
button->setIcon(action->icon());
}
const QKeySequence key = action->shortcut();
- button->setToolTip(key.isEmpty()
- ? text
- : QStringLiteral("%1 (%2)").arg(
- text, key.toString(QKeySequence::NativeText)));
+ QString tip = key.isEmpty()
+ ? text
+ : QStringLiteral("%1 (%2)").arg(
+ text, key.toString(QKeySequence::NativeText));
+ //The status tip says what the command does, which a short
+ //name such as "Orienter les textes" does not
+ if (!action->statusTip().isEmpty()) {
+ tip += QLatin1Char('\n') + action->statusTip();
+ }
+ button->setToolTip(tip);
button->setEnabled(action->isEnabled());
button->setFocusPolicy(Qt::NoFocus);
connect(button, &QToolButton::clicked, this, [this, action]() {
diff --git a/sources/ElementsCollection/elementscollectionwidget.cpp b/sources/ElementsCollection/elementscollectionwidget.cpp
index 198e6ddb1..222425243 100644
--- a/sources/ElementsCollection/elementscollectionwidget.cpp
+++ b/sources/ElementsCollection/elementscollectionwidget.cpp
@@ -1103,7 +1103,32 @@ void ElementsCollectionWidget::search()
return;
}
- showFlatResults(rankedSearch(text, m_showed_index));
+ QSettings settings;
+ if (settings.value(QStringLiteral("elementscollection/search-flat-list"),
+ true).toBool())
+ {
+ showFlatResults(rankedSearch(text, m_showed_index));
+ return;
+ }
+
+ //Tree search: hide what does not match, expand what does.
+ clearFlatResults();
+ hideCollection(true);
+ const QStringList text_list = text.split("+", Qt::SkipEmptyParts);
+ QModelIndexList match_index;
+ for (const QString &txt : text_list) {
+ match_index << m_model->match(m_showed_index.isValid()
+ ? m_model->index(0,0,m_showed_index)
+ : m_model->index(0,0),
+ Qt::UserRole+1,
+ QVariant(txt),
+ -1,
+ Qt::MatchContains
+ | Qt::MatchRecursive);
+ }
+
+ for (const QModelIndex &index : std::as_const(match_index))
+ showAndExpandItem(index);
}
/**
diff --git a/sources/dataBase/legacyelementtypes.h b/sources/dataBase/legacyelementtypes.h
new file mode 100644
index 000000000..51943996b
--- /dev/null
+++ b/sources/dataBase/legacyelementtypes.h
@@ -0,0 +1,69 @@
+/*
+ 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 LEGACYELEMENTTYPES_H
+#define LEGACYELEMENTTYPES_H
+
+#include
+#include
+
+/**
+ Element type names in queries saved by versions before June 2022.
+ The project database then held Element::linkTypeToString() ("Simple",
+ "Terminale", ...); since it holds ElementData::typeToString()
+ ("simple", "terminal", ...). SQLite compares text case-sensitively, so
+ a nomenclature table saved with element_type = 'Simple' silently lost
+ every simple element, and its continuation tables were deleted as
+ empty.
+*/
+namespace LegacyElementTypes
+{
+ /**
+ @return query with every element_type = '' comparison
+ rewritten to the current name. Anything else is left as it is,
+ so a query saved by a current version comes back unchanged.
+ */
+ inline QString upgradeQuery(const QString &query)
+ {
+ static const QRegularExpression re(QStringLiteral(
+ "element_type\\s*=\\s*'(Simple|NextReport|PreviousReport|Master"
+ "|Slave|Terminale|Thumbnail)'"));
+
+ QString upgraded = query;
+ auto it = re.globalMatch(query);
+ int shift = 0;
+ while (it.hasNext())
+ {
+ const auto match = it.next();
+ const QString old_name = match.captured(1);
+ QString new_name;
+ if (old_name == QLatin1String("Simple")) new_name = QStringLiteral("simple");
+ else if (old_name == QLatin1String("NextReport")) new_name = QStringLiteral("next_report");
+ else if (old_name == QLatin1String("PreviousReport")) new_name = QStringLiteral("previous_report");
+ else if (old_name == QLatin1String("Master")) new_name = QStringLiteral("master");
+ else if (old_name == QLatin1String("Slave")) new_name = QStringLiteral("slave");
+ else if (old_name == QLatin1String("Terminale")) new_name = QStringLiteral("terminal");
+ else new_name = QStringLiteral("thumbnail");
+
+ upgraded.replace(match.capturedStart(1) + shift, old_name.size(), new_name);
+ shift += new_name.size() - old_name.size();
+ }
+ return upgraded;
+ }
+}
+
+#endif // LEGACYELEMENTTYPES_H
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/dataBase/ui/elementquerywidget.cpp b/sources/dataBase/ui/elementquerywidget.cpp
index f5a42beb7..6331efb63 100644
--- a/sources/dataBase/ui/elementquerywidget.cpp
+++ b/sources/dataBase/ui/elementquerywidget.cpp
@@ -20,6 +20,7 @@
#include "../../properties/elementdata.h"
#include "../../qetapp.h"
#include "../../qetinformation.h"
+#include "../legacyelementtypes.h"
#include "../projectdatabase.h"
#include "ui_elementquerywidget.h"
@@ -674,7 +675,9 @@ void ElementQueryWidget::on_m_load_pb_clicked()
auto value_object = value.toObject();
if (value_object.value("query").isString()) {
- setQuery(value_object.value("query").toString());
+ //A query saved before June 2022 names element types the old way
+ setQuery(LegacyElementTypes::upgradeQuery(
+ value_object.value("query").toString()));
}
}
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/diagramcontexttoolbar.cpp b/sources/diagramcontexttoolbar.cpp
index c8e8ab3be..610b2eec7 100644
--- a/sources/diagramcontexttoolbar.cpp
+++ b/sources/diagramcontexttoolbar.cpp
@@ -23,6 +23,7 @@
#include
#include
#include
+#include
#include
namespace {
@@ -92,7 +93,11 @@ void DiagramContextToolbar::showAt(const QPoint &viewport_pos,
} else {
button->setIcon(action->icon());
}
- button->setToolTip(text);
+ //The status tip says what the command does, which a short
+ //name such as "Orienter les textes" does not
+ button->setToolTip(action->statusTip().isEmpty()
+ ? text
+ : text % QLatin1Char('\n') % action->statusTip());
button->setEnabled(action->isEnabled());
connect(button, &QToolButton::clicked, action, &QAction::trigger);
m_layout->addWidget(button);
diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp
index a63b4350f..8e49a1fe3 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 1845df15f..0f47f63a3 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/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);
@@ -2059,7 +2070,7 @@ void QETDiagramEditor::selectionGroupTriggered(QAction *action)
//nothing to rotate.
if (RotateTextsCommand::hasSelectedTexts(diagram))
{
- qreal rotation = 0;
+ qreal rotation = RotateTextsCommand::currentRotation(diagram);
if (RotateTextsCommand::askRotation(rotation))
diagram->undoStack().push(new RotateTextsCommand(diagram, rotation));
}
@@ -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 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);
diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h
index 96f1177f9..ba35ea2f3 100644
--- a/sources/qetdiagrameditor.h
+++ b/sources/qetdiagrameditor.h
@@ -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
diff --git a/sources/qetgraphicsitem/ViewItem/projectdbmodel.cpp b/sources/qetgraphicsitem/ViewItem/projectdbmodel.cpp
index ca07e5ba8..a201fcb02 100644
--- a/sources/qetgraphicsitem/ViewItem/projectdbmodel.cpp
+++ b/sources/qetgraphicsitem/ViewItem/projectdbmodel.cpp
@@ -19,6 +19,7 @@
#include
+#include "../../dataBase/legacyelementtypes.h"
#include "../../dataBase/projectdatabase.h"
#include "../../qetapp.h"
#include "../../qetinformation.h"
@@ -294,7 +295,9 @@ void ProjectDBModel::fromXml(const QDomElement &element)
return;
setIdentifier(element.firstChildElement("identifier").text());
- setQuery(element.firstChildElement("query").text());
+ //A query saved before June 2022 names element types the old way
+ setQuery(LegacyElementTypes::upgradeQuery(
+ element.firstChildElement("query").text()));
//Index 0,0
auto index_00 = element.firstChildElement("index00");
diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp
index 41fa915bd..517548ad3 100644
--- a/sources/ui/configpage/generalconfigurationpage.cpp
+++ b/sources/ui/configpage/generalconfigurationpage.cpp
@@ -70,6 +70,7 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
//Stored as "inserts" but presented as "edits", so the default (insert)
//is the unchecked state -- a preference reads better as an opt-out.
ui->m_collection_dblclick_edits->setChecked(!settings.value("elementscollection/double-click-inserts", true).toBool());
+ ui->m_collection_search_flat_cb->setChecked(settings.value("elementscollection/search-flat-list", true).toBool());
ui->m_context_toolbar_cb->setChecked(settings.value("diagrameditor/context_toolbar", true).toBool());
ui->m_mouse_gestures_cb->setChecked(settings.value("diagrameditor/mouse_gestures", true).toBool());
ui->DiagramEditor_xGrid_sb->setValue(settings.value("diagrameditor/Xgrid", 10).toInt());
@@ -302,6 +303,7 @@ void GeneralConfigurationPage::applyConf()
settings.setValue("diagrameditor/grid_display_startup", ui->grid_startup_cb->isChecked());
settings.setValue("diagrameditor/guides_display_startup", ui->guides_startup_cb->isChecked());
settings.setValue("elementscollection/double-click-inserts", !ui->m_collection_dblclick_edits->isChecked());
+ settings.setValue("elementscollection/search-flat-list", ui->m_collection_search_flat_cb->isChecked());
settings.setValue("diagrameditor/context_toolbar", ui->m_context_toolbar_cb->isChecked());
settings.setValue("diagrameditor/mouse_gestures", ui->m_mouse_gestures_cb->isChecked());
//Grid step and key navigation
diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui
index cfd57dd5c..027b73704 100644
--- a/sources/ui/configpage/generalconfigurationpage.ui
+++ b/sources/ui/configpage/generalconfigurationpage.ui
@@ -94,6 +94,16 @@
+
+
+
+ Coché : une seule liste, la meilleure correspondance en premier. Décoché : l'arborescence de la collection, filtrée sur la recherche.
+
+
+ Afficher les résultats de recherche sous forme de liste triée
+
+
+
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/sources/undocommand/rotatetextscommand.cpp b/sources/undocommand/rotatetextscommand.cpp
index 704b7bcd8..9936721f1 100644
--- a/sources/undocommand/rotatetextscommand.cpp
+++ b/sources/undocommand/rotatetextscommand.cpp
@@ -25,6 +25,8 @@
#include "../qetgraphicsitem/elementtextitemgroup.h"
#include "../qtextorientationspinboxwidget.h"
+#include
+
/**
@brief RotateTextsCommand::hasSelectedTexts
@param diagram
@@ -41,6 +43,35 @@ bool RotateTextsCommand::hasSelectedTexts(Diagram *diagram)
return (!dc.selectedTexts().isEmpty() || !dc.selectedTextsGroup().isEmpty());
}
+/**
+ @brief RotateTextsCommand::currentRotation
+ @param diagram
+ @return the rotation shared by every selected text and text group of
+ @p diagram, so the dialog can open at the angle they already have.
+ 0 when nothing is selected or when their angles differ.
+*/
+qreal RotateTextsCommand::currentRotation(Diagram *diagram)
+{
+ if(!diagram)
+ return 0;
+
+ DiagramContent dc(diagram);
+ QList angles;
+ for(DiagramTextItem *dti : dc.selectedTexts())
+ angles << dti->rotation();
+ for(ElementTextItemGroup *etig : dc.selectedTextsGroup())
+ angles << etig->rotation();
+
+ if(angles.isEmpty())
+ return 0;
+ for(qreal angle : angles)
+ if(qAbs(angle - angles.first()) > 0.01)
+ return 0;
+
+ //The dialog's spin box accepts -360 to 360
+ return std::fmod(angles.first(), 360);
+}
+
/**
@brief RotateTextsCommand::RotateTextsCommand
@param diagram : Apply the rotation to the selected texts and group of texts
@@ -124,8 +155,8 @@ void RotateTextsCommand::redo()
/**
@brief RotateTextsCommand::askRotation
Ask the user for an orientation.
- @param rotation : set to the chosen angle when the dialog is accepted,
- left untouched otherwise.
+ @param rotation : the angle the dialog opens at; set to the chosen
+ angle when the dialog is accepted, left untouched otherwise.
@return true if the user accepted, false if they cancelled.
Deliberately static and separate from the command: a QUndoCommand that
@@ -145,6 +176,7 @@ bool RotateTextsCommand::askRotation(qreal &rotation)
QTextOrientationSpinBoxWidget *ori_widget = QETApp::createTextOrientationSpinBoxWidget();
ori_widget->setParent(&ori_text_dialog);
+ ori_widget->setOrientation(rotation);
ori_widget->spinBox()->selectAll();
QDialogButtonBox buttons(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
diff --git a/sources/undocommand/rotatetextscommand.h b/sources/undocommand/rotatetextscommand.h
index 0ddf58830..ebe7f3bf6 100644
--- a/sources/undocommand/rotatetextscommand.h
+++ b/sources/undocommand/rotatetextscommand.h
@@ -40,7 +40,7 @@ class QParallelAnimationGroup;
Typical interactive use:
@code
if (RotateTextsCommand::hasSelectedTexts(diagram)) {
- qreal rotation = 0;
+ qreal rotation = RotateTextsCommand::currentRotation(diagram);
if (RotateTextsCommand::askRotation(rotation))
diagram->undoStack().push(new RotateTextsCommand(diagram, rotation));
}
@@ -53,7 +53,9 @@ class RotateTextsCommand : public QUndoCommand
/// @return true if @p diagram has at least one selected text or text group to rotate.
static bool hasSelectedTexts(Diagram *diagram);
- /// Open the orientation dialog. @return true and set @p rotation if accepted, false if cancelled.
+ /// @return the angle the selected texts share, or 0 if they differ.
+ static qreal currentRotation(Diagram *diagram);
+ /// Open the orientation dialog at @p rotation. @return true and set @p rotation if accepted, false if cancelled.
static bool askRotation(qreal &rotation);
void undo() override;
diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt
index dea8b4e7e..94283cc09 100644
--- a/tests/qttest/CMakeLists.txt
+++ b/tests/qttest/CMakeLists.txt
@@ -111,6 +111,19 @@ 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)
+add_test(NAME tst_legacyelementtypes COMMAND tst_legacyelementtypes)
+target_include_directories(tst_legacyelementtypes PRIVATE ${QET_DIR}/sources)
+target_link_libraries(tst_legacyelementtypes PRIVATE Qt::Test)
+
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"
diff --git a/tests/qttest/tst_legacyelementtypes.cpp b/tests/qttest/tst_legacyelementtypes.cpp
new file mode 100644
index 000000000..bc36725e1
--- /dev/null
+++ b/tests/qttest/tst_legacyelementtypes.cpp
@@ -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 .
+*/
+#include "dataBase/legacyelementtypes.h"
+
+#include
+
+class TestLegacyElementTypes : public QObject
+{
+ Q_OBJECT
+
+ private slots:
+ void upgradesOldNames_data()
+ {
+ QTest::addColumn("saved");
+ QTest::addColumn("expected");
+
+ // The WHERE clause industrial.qet's parts list was saved with.
+ QTest::newRow("simple, with sub types")
+ << "WHERE ( element_type = 'Simple' OR element_sub_type = 'coil') ORDER BY label"
+ << "WHERE ( element_type = 'simple' OR element_sub_type = 'coil') ORDER BY label";
+ QTest::newRow("terminal and master, lengths change")
+ << "element_type = 'Terminale' OR element_type = 'Master' OR element_type = 'Simple'"
+ << "element_type = 'terminal' OR element_type = 'master' OR element_type = 'simple'";
+ QTest::newRow("reports and thumbnail")
+ << "element_type='NextReport' OR element_type = 'PreviousReport' OR element_type = 'Thumbnail'"
+ << "element_type='next_report' OR element_type = 'previous_report' OR element_type = 'thumbnail'";
+ QTest::newRow("current names unchanged")
+ << "element_type = 'simple' OR element_type = 'terminal'"
+ << "element_type = 'simple' OR element_type = 'terminal'";
+ QTest::newRow("the word elsewhere unchanged")
+ << "WHERE label = 'Simple' OR designation = 'Master'"
+ << "WHERE label = 'Simple' OR designation = 'Master'";
+ QTest::newRow("empty") << "" << "";
+ }
+
+ void upgradesOldNames()
+ {
+ QFETCH(QString, saved);
+ QFETCH(QString, expected);
+ QCOMPARE(LegacyElementTypes::upgradeQuery(saved), expected);
+ }
+};
+
+QTEST_APPLESS_MAIN(TestLegacyElementTypes)
+#include "tst_legacyelementtypes.moc"