From f7591e0c9da1d2263673cf6d030a201f3120dff7 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sun, 27 Sep 2026 18:40:25 +1300 Subject: [PATCH 1/6] 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 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG --- cmake/qet_compilation_vars.cmake | 4 + sources/dataBase/projectdatabase.cpp | 47 +++++- sources/dataBase/projectdatabase.h | 1 + sources/diagram.cpp | 87 ++++++++++- sources/diagram.h | 7 + sources/diagramcommands.cpp | 22 +++ sources/diagramview.cpp | 7 + sources/elementspanelwidget.cpp | 15 ++ sources/itemgroups.cpp | 124 +++++++++++++++ sources/itemgroups.h | 60 +++++++ sources/qetdiagrameditor.cpp | 27 +++- sources/qetdiagrameditor.h | 2 + sources/undocommand/groupitemscommand.cpp | 181 ++++++++++++++++++++++ sources/undocommand/groupitemscommand.h | 62 ++++++++ tests/qttest/CMakeLists.txt | 7 + tests/qttest/tst_itemgroups.cpp | 142 +++++++++++++++++ 16 files changed, 788 insertions(+), 7 deletions(-) create mode 100644 sources/itemgroups.cpp create mode 100644 sources/itemgroups.h create mode 100644 sources/undocommand/groupitemscommand.cpp create mode 100644 sources/undocommand/groupitemscommand.h create mode 100644 tests/qttest/tst_itemgroups.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 60fddcf55..a9b64a658 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -232,6 +232,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/exportpropertieswidget.h ${QET_DIR}/sources/genericpanel.cpp ${QET_DIR}/sources/genericpanel.h + ${QET_DIR}/sources/itemgroups.cpp + ${QET_DIR}/sources/itemgroups.h ${QET_DIR}/sources/lastusedstyle.cpp ${QET_DIR}/sources/lastusedstyle.h ${QET_DIR}/sources/machine_info.cpp @@ -836,6 +838,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/undocommand/removediagramcommand.h ${QET_DIR}/sources/undocommand/setautonumcontextcommand.cpp ${QET_DIR}/sources/undocommand/setautonumcontextcommand.h + ${QET_DIR}/sources/undocommand/groupitemscommand.cpp + ${QET_DIR}/sources/undocommand/groupitemscommand.h ${QET_DIR}/sources/undocommand/rotateselectioncommand.cpp ${QET_DIR}/sources/undocommand/rotateselectioncommand.h ${QET_DIR}/sources/undocommand/promoteshapecommand.cpp diff --git a/sources/dataBase/projectdatabase.cpp b/sources/dataBase/projectdatabase.cpp index 68174761a..7c6d29d8a 100644 --- a/sources/dataBase/projectdatabase.cpp +++ b/sources/dataBase/projectdatabase.cpp @@ -22,6 +22,7 @@ #include "../diagram.h" #include "../diagramposition.h" #include "../elementprovider.h" +#include "../itemgroups.h" #include "../qetapp.h" #include "../qetgraphicsitem/conductor.h" #include "../qetgraphicsitem/diagramimageitem.h" @@ -669,6 +670,13 @@ QUuid drawingItemUuid(QObject *object) return QUuid(); } +/// The group_uuid column of @p item's row: its group, or NULL. +QVariant groupValue(const QGraphicsItem *item) +{ + const QUuid group = ItemGroups::groupOf(item); + return group.isNull() ? QVariant() : QVariant(group.toString()); +} + } // namespace /** @@ -751,6 +759,33 @@ void projectDataBase::removeDrawingItem(QGraphicsItem *item) forgetDrawingItem(object); } +/** + @brief projectDataBase::itemGroupChanged + @a item joined or left a group (discussion #1070). An element's row is + updated at once; a drawing item's is queued like any other change to it. + @param item +*/ +void projectDataBase::itemGroupChanged(QGraphicsItem *item) +{ + if (auto element = qgraphicsitem_cast(item)) + { + QSqlQuery update(m_data_base); + update.prepare(QStringLiteral("UPDATE element SET group_uuid = :group_uuid WHERE uuid = :uuid")); + update.bindValue(QStringLiteral(":group_uuid"), groupValue(element)); + update.bindValue(QStringLiteral(":uuid"), element->uuid().toString()); + if (!update.exec()) { + qDebug() << "projectDataBase::itemGroupChanged update error : " << update.lastError(); + } + m_content_changed = true; + return; + } + + QGraphicsObject *object = item ? item->toGraphicsObject() : nullptr; + if (object && !drawingItemTable(object).isEmpty()) { + m_dirty_drawing_items.insert(object); + } +} + /** @brief projectDataBase::drawingItemChanged Queue the sender's row to be rewritten. @@ -884,6 +919,7 @@ bool projectDataBase::writeDrawingItem(QObject *object) query->bindValue(QStringLiteral(":y"), rect.y()); query->bindValue(QStringLiteral(":width"), rect.width()); query->bindValue(QStringLiteral(":height"), rect.height()); + query->bindValue(QStringLiteral(":group_uuid"), groupValue(item)); if (!query->exec()) { qDebug() << "projectDataBase::writeDrawingItem error : " << query->lastError(); return true; @@ -985,6 +1021,7 @@ bool projectDataBase::createDataBase() "pos VARCHAR(6) NOT NULL," "type VARCHAR(50)," "sub_type VARCHAR(50)," + "group_uuid VARCHAR(50)," "FOREIGN KEY (diagram_uuid) REFERENCES diagram (uuid)" ")"); if (!query_.exec(element_table)) { @@ -1085,7 +1122,8 @@ bool projectDataBase::createDataBase() "uuid VARCHAR(50) PRIMARY KEY NOT NULL, " "diagram_uuid VARCHAR(50) NOT NULL, " "pos VARCHAR(6), " - "x REAL, y REAL, width REAL, height REAL, "); + "x REAL, y REAL, width REAL, height REAL, " + "group_uuid VARCHAR(50), "); for (const QString &table : { QStringLiteral("CREATE TABLE shape (") + drawing_columns + "type VARCHAR(20), color VARCHAR(20), fill VARCHAR(20), " @@ -1516,8 +1554,8 @@ void projectDataBase::prepareQuery() //DRAWING ITEMS. OR REPLACE: a row is rewritten in place on every //change, see writeDrawingItem(). - const QString drawing_columns("uuid, diagram_uuid, pos, x, y, width, height"); - const QString drawing_values(":uuid, :diagram_uuid, :pos, :x, :y, :width, :height"); + const QString drawing_columns("uuid, diagram_uuid, pos, x, y, width, height, group_uuid"); + const QString drawing_values(":uuid, :diagram_uuid, :pos, :x, :y, :width, :height, :group_uuid"); m_insert_shape_query = QSqlQuery(m_data_base); m_insert_shape_query.prepare("INSERT OR REPLACE INTO shape (" + drawing_columns + ", type, color, fill) VALUES (" + drawing_values + @@ -1561,7 +1599,7 @@ void projectDataBase::prepareQuery() m_diagram_info_order_changed.prepare("UPDATE diagram_info SET folio = :folio WHERE diagram_uuid = :uuid"); //INSERT ELEMENT - QString insert_element_query("INSERT INTO element (uuid, diagram_uuid, pos, type, sub_type) VALUES (:uuid, :diagram_uuid, :pos, :type, :sub_type)"); + QString insert_element_query("INSERT INTO element (uuid, diagram_uuid, pos, type, sub_type, group_uuid) VALUES (:uuid, :diagram_uuid, :pos, :type, :sub_type, :group_uuid)"); m_insert_elements_query = QSqlQuery(m_data_base); m_insert_elements_query.prepare(insert_element_query); @@ -1675,6 +1713,7 @@ void projectDataBase::bindElementValues(QSqlQuery &query, Element *element, Diag query.bindValue(QStringLiteral(":pos"), diagram->convertPosition(element->scenePos()).toString()); query.bindValue(QStringLiteral(":type"), element_data.typeToString()); query.bindValue(QStringLiteral(":sub_type"), element_data.masterTypeToString()); + query.bindValue(QStringLiteral(":group_uuid"), groupValue(element)); } /** diff --git a/sources/dataBase/projectdatabase.h b/sources/dataBase/projectdatabase.h index 0ce1167ec..510d17eb7 100644 --- a/sources/dataBase/projectdatabase.h +++ b/sources/dataBase/projectdatabase.h @@ -105,6 +105,7 @@ class projectDataBase : public QObject //furniture. Anything else passed here is ignored. void addDrawingItem (QGraphicsItem *item); void removeDrawingItem (QGraphicsItem *item); + void itemGroupChanged (QGraphicsItem *item); private slots: //Refresh the sender()'s row after Conductor::setProperties(). diff --git a/sources/diagram.cpp b/sources/diagram.cpp index bbf1165c5..4831b6484 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -42,7 +42,9 @@ #include "qetinformation.h" #include "qetproject.h" #include "diagramsortkeys.h" +#include "itemgroups.h" #include "textgrid.h" +#include #include #include #include @@ -121,6 +123,7 @@ namespace { for (T *item : items) { Entry entry{stack_rank.value(item, INT_MAX), QString(), item->toXml(document)}; + ItemGroups::write(entry.xml, item); // Only an item the stacking query missed needs a tiebreak. if (entry.rank == INT_MAX) { QTextStream stream(&entry.xml_text); @@ -210,6 +213,7 @@ Diagram::Diagram(QETProject *project) : connect(&border_and_titleblock, &BorderTitleBlock::needTitleBlockTemplate, this, &Diagram::setTitleBlockTemplate); + connect(&border_and_titleblock, &BorderTitleBlock::informationChanged, this, &Diagram::titleChanged); @@ -432,7 +436,9 @@ void Diagram::mousePressEvent(QGraphicsSceneMouseEvent *event) } } + rememberSelection(); QGraphicsScene::mousePressEvent(event); + completeGroupSelection(); } /** @@ -471,6 +477,9 @@ void Diagram::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } QGraphicsScene::mouseReleaseEvent(event); + //A click on an already selected item changes the selection on + //release, not on press (Ctrl toggles it, a plain click keeps only it). + completeGroupSelection(); } /** @@ -1245,8 +1254,9 @@ QDomDocument Diagram::toXml(bool whole_content, bool is_copy_command) { if (!list_elements.isEmpty()) { auto dom_elements = document.createElement(QStringLiteral("elements")); for (auto elmt : list_elements) { - dom_elements.appendChild(elmt->toXml(document, - table_adr_id)); + QDomElement dom_element = elmt->toXml(document, table_adr_id); + ItemGroups::write(dom_element, elmt); + dom_elements.appendChild(dom_element); // If copy is active we have to undo the changes we have made during creating(filling) 'list_elements' if(is_copy_command && (elmt->linkType() == Element::Slave || elmt->linkType()&Element::AllReport)) restoreText(elmt); @@ -1666,6 +1676,7 @@ bool Diagram::fromXml(QDomElement &document, delete nvel_elmt; qDebug() << QStringLiteral("Diagram::fromXml() : Le chargement des parametres d'un element a echoue"); } else { + ItemGroups::setGroup(nvel_elmt, ItemGroups::read(element_xml)); added_elements << nvel_elmt; } } @@ -1701,6 +1712,7 @@ bool Diagram::fromXml(QDomElement &document, IndependentTextItem *iti = new IndependentTextItem(); iti -> fromXml(text_xml); settle_uuid(iti, text_xml, QStringLiteral("input"), added_texts.size()); + ItemGroups::setGroup(iti, ItemGroups::read(text_xml)); addItem(iti); added_texts << iti; } @@ -1713,6 +1725,7 @@ bool Diagram::fromXml(QDomElement &document, DiagramImageItem *dii = new DiagramImageItem (); dii -> fromXml(image_xml); settle_uuid(dii, image_xml, QStringLiteral("image"), added_images.size()); + ItemGroups::setGroup(dii, ItemGroups::read(image_xml)); addItem(dii); added_images << dii; } @@ -1725,6 +1738,7 @@ bool Diagram::fromXml(QDomElement &document, QetShapeItem *dii = new QetShapeItem (QPointF(0,0)); dii -> fromXml(shape_xml); settle_uuid(dii, shape_xml, QStringLiteral("shape"), added_shapes.size()); + ItemGroups::setGroup(dii, ItemGroups::read(shape_xml)); addItem(dii); added_shapes << dii; } @@ -2127,6 +2141,75 @@ void Diagram::selectAllTextFields() emit selectionChanged(); } +/** + @brief Diagram::setItemGroup + Put @a item in @a group, or take it out of its group if @a group is + null, and keep its row in the project database in step. + @param item + @param group +*/ +void Diagram::setItemGroup(QGraphicsItem *item, const QUuid &group) +{ + ItemGroups::setGroup(item, group); + if (m_project) { + m_project->dataBase()->itemGroupChanged(item); + } +} + +/** + @brief Diagram::completeGroupSelection + Make the selection whole groups again after the user changed it with a + click, see ItemGroups::completeSelection(). Called from the mouse + handlers and, when a rubber band is released, from DiagramView -- not + on every selectionChanged(): code that selects items itself (export, + search, Tab cycling) deselects and reselects one item at a time and + must not have groups pulled back in behind it. + Left alone while a rubber band is being dragged, which reselects exactly + what it covers on every mouse step. +*/ +void Diagram::completeGroupSelection() +{ + for (QGraphicsView *view : views()) { + if (!view->rubberBandRect().isNull()) { + return; + } + } + + QList previous; + for (const QPointer &item : std::as_const(m_previous_selection)) { + if (item) { + previous << item.data(); + } + } + + //One selectionChanged() for the whole group, not one per member: + //the properties dock rebuilds on each. + blockSignals(true); + const bool changed = ItemGroups::completeSelection( + this, previous, + QApplication::keyboardModifiers().testFlag(Qt::ControlModifier)); + blockSignals(false); + if (changed) { + emit selectionChanged(); + } + rememberSelection(); +} + +/** + @brief Diagram::rememberSelection + Keep the current selection, the "before" completeGroupSelection() needs + to tell a member being Ctrl+clicked off. +*/ +void Diagram::rememberSelection() +{ + m_previous_selection.clear(); + for (QGraphicsItem *item : selectedItems()) { + if (QGraphicsObject *object = item->toGraphicsObject()) { + m_previous_selection << object; + } + } +} + /** @brief Diagram::selectNextItem Select the next (or, if @a forward is false, the previous) selectable diff --git a/sources/diagram.h b/sources/diagram.h index 244299a04..8c2b453d6 100644 --- a/sources/diagram.h +++ b/sources/diagram.h @@ -27,6 +27,7 @@ #include "qgimanager.h" #include +#include #include #include #include @@ -138,6 +139,10 @@ class Diagram : public QGraphicsScene bool m_freeze_new_conductors_; QUuid m_uuid = QUuid::createUuid(); + //Selection before the current click, see completeGroupSelection() + QList> m_previous_selection; + void rememberSelection(); + bool uuidUsedByOtherDiagram(const QUuid &uuid) const; QUuid derivedUuid(const QDomElement &root, const QString &reason) const; @@ -282,6 +287,7 @@ class Diagram : public QGraphicsScene const QString& title, const QString& seq, NumerotationContext *nc); void changeZValue(QET::DepthOption option); + void setItemGroup(QGraphicsItem *item, const QUuid &group); public slots: void adjustSceneRect (); @@ -299,6 +305,7 @@ class Diagram : public QGraphicsScene void invertSelection(); void selectAllConductors(); void selectAllTextFields(); + void completeGroupSelection(); signals: void showDiagram (Diagram *); diff --git a/sources/diagramcommands.cpp b/sources/diagramcommands.cpp index 38c15a2a9..0927ce815 100644 --- a/sources/diagramcommands.cpp +++ b/sources/diagramcommands.cpp @@ -18,6 +18,7 @@ #include "diagramcommands.h" #include "diagram.h" +#include "itemgroups.h" #include "qetgraphicsitem/conductortextitem.h" #include "qetgraphicsitem/diagramimageitem.h" #include "qetgraphicsitem/dynamicelementtextitem.h" @@ -216,6 +217,27 @@ void PasteDiagramCommand::redo() } } } + + //Pasted groups become new groups: the members of one source + //group all get the same new uuid, never the source's, or the + //copy would join the original's group. After the elements got + //their own uuids: the database row is found by uuid, and before + //that it would have been the source element's row. + QHash renewed_groups; + for (QGraphicsItem *item : content.items(DiagramContent::Elements + | DiagramContent::TextFields + | DiagramContent::Images + | DiagramContent::Shapes)) + { + const QUuid source_group = ItemGroups::groupOf(item); + if (source_group.isNull()) { + continue; + } + if (!renewed_groups.contains(source_group)) { + renewed_groups.insert(source_group, QUuid::createUuid()); + } + diagram -> setItemGroup(item, renewed_groups.value(source_group)); + } } else { diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index e3996bf66..af3dec5e0 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -122,6 +122,13 @@ DiagramView::DiagramView(Diagram *diagram, QWidget *parent) : }); connect(m_diagram, &Diagram::showDiagram, this, &DiagramView::showDiagram); + //The diagram leaves group completion alone while a rubber band is + //dragged; finish it when the band is released (null rect). + connect(this, &QGraphicsView::rubberBandChanged, this, [this](QRect rect) { + if (rect.isNull()) { + m_diagram->completeGroupSelection(); + } + }); connect(m_diagram, &QGraphicsScene::sceneRectChanged, this, &DiagramView::adjustSceneRect); connect(&(m_diagram -> border_and_titleblock), &BorderTitleBlock::informationChanged, this, &DiagramView::updateWindowTitle); connect(diagram, &Diagram::findElementRequired, this, &DiagramView::findElementRequired); diff --git a/sources/elementspanelwidget.cpp b/sources/elementspanelwidget.cpp index 8b42bb594..71a0b6b9e 100644 --- a/sources/elementspanelwidget.cpp +++ b/sources/elementspanelwidget.cpp @@ -32,6 +32,7 @@ #include "qetgraphicsitem/diagramimageitem.h" #include "qetgraphicsitem/independenttextitem.h" #include "qetgraphicsitem/qetshapeitem.h" +#include "itemgroups.h" #include "qetinformation.h" /* @@ -833,6 +834,20 @@ void ElementsPanelWidget::duplicateDiagram() } } } + + // Groups too: a group of the copy is its own, so selecting it + // is the same on both folios but the database tells them apart. + QHash renewed_groups; + for (QGraphicsItem *item : new_diagram->items()) { + const QUuid source_group = ItemGroups::groupOf(item); + if (source_group.isNull()) { + continue; + } + if (!renewed_groups.contains(source_group)) { + renewed_groups.insert(source_group, QUuid::createUuid()); + } + new_diagram->setItemGroup(item, renewed_groups.value(source_group)); + } } elements_panel->reload(); diff --git a/sources/itemgroups.cpp b/sources/itemgroups.cpp new file mode 100644 index 000000000..4e4c7ac9d --- /dev/null +++ b/sources/itemgroups.cpp @@ -0,0 +1,124 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "itemgroups.h" + +#include +#include +#include +#include + +/** + @return the uuid of the group @a item belongs to, or a null uuid. +*/ +QUuid ItemGroups::groupOf(const QGraphicsItem *item) +{ + return item ? item->data(data_key).toUuid() : QUuid(); +} + +/** + Put @a item in @a group, or take it out of any group if @a group is null. +*/ +void ItemGroups::setGroup(QGraphicsItem *item, const QUuid &group) +{ + if (!item) { + return; + } + item->setData(data_key, group.isNull() ? QVariant() : QVariant(group)); +} + +/** + Write @a item's group, if it has one, on its XML element @a xml. + Nothing is written otherwise, so a project without groups saves exactly + as before. +*/ +void ItemGroups::write(QDomElement &xml, const QGraphicsItem *item) +{ + const QUuid group = groupOf(item); + if (!group.isNull()) { + xml.setAttribute(QString::fromLatin1(xml_attribute), group.toString()); + } +} + +/** + @return the group written on @a xml, or a null uuid. +*/ +QUuid ItemGroups::read(const QDomElement &xml) +{ + return QUuid(xml.attribute(QString::fromLatin1(xml_attribute))); +} + +/** + Make the selection of @a scene whole groups again after it changed. + A group with a selected member is selected entirely, except when the + change took members of an entirely selected group out of the selection + while @a toggling (Ctrl+click on a member): then the whole group leaves + the selection, as the user meant. + @param previous : the selection before the change + @param toggling : true when Ctrl is held + @return true if the selection was changed +*/ +bool ItemGroups::completeSelection(QGraphicsScene *scene, + const QList &previous, + bool toggling) +{ + if (!scene) { + return false; + } + + QSet touched; + const QList selected = scene->selectedItems(); + for (QGraphicsItem *item : selected) { + const QUuid group = groupOf(item); + if (!group.isNull()) { + touched << group; + } + } + + //Groups that just lost a member from the selection + QSet shrunk; + for (QGraphicsItem *item : previous) { + if (item->scene() == scene && !item->isSelected()) { + const QUuid group = groupOf(item); + if (!group.isNull()) { + shrunk << group; + } + } + } + + if (touched.isEmpty()) { + return false; + } + + //One pass over the folio for every touched group, not one per group: + //select all on a folio of 2000 items in 200 groups took 44 ms the + //other way. + bool changed = false; + for (QGraphicsItem *item : scene->items()) + { + const QUuid group = groupOf(item); + if (group.isNull() || !touched.contains(group)) { + continue; + } + const bool select = !(toggling && shrunk.contains(group)); + if (item->isSelected() != select) { + item->setSelected(select); + changed = true; + } + } + return changed; +} diff --git a/sources/itemgroups.h b/sources/itemgroups.h new file mode 100644 index 000000000..7e3c944b1 --- /dev/null +++ b/sources/itemgroups.h @@ -0,0 +1,60 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef ITEMGROUPS_H +#define ITEMGROUPS_H + +#include +#include + +class QDomElement; +class QGraphicsItem; +class QGraphicsScene; + +/** + Groups of items on a folio (discussion #1070): symbols, free texts, + shapes and pictures that select, move, copy and delete together. + + A group is not an object in the scene. Each member keeps its place and + carries the group's uuid, and selecting one member selects them all; + moving, copying and deleting then need nothing new, because they act on + the selection. Re-parenting the members under a QGraphicsItemGroup would + make every position group-relative, which every piece of code reading a + symbol's position would have to learn about. + + This part knows nothing of Diagram, so it can be tested on a plain scene. +*/ +namespace ItemGroups +{ + /// QGraphicsItem::data() key holding an item's group uuid. + inline constexpr int data_key = 0x475250; // "GRP" + + /// XML attribute holding it, on , , and . + inline constexpr char xml_attribute[] = "group"; + + QUuid groupOf(const QGraphicsItem *item); + void setGroup(QGraphicsItem *item, const QUuid &group); + + void write(QDomElement &xml, const QGraphicsItem *item); + QUuid read(const QDomElement &xml); + + bool completeSelection(QGraphicsScene *scene, + const QList &previous, + bool toggling); +} + +#endif // ITEMGROUPS_H diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index d97d08b24..85dad80d8 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -66,6 +66,7 @@ #include "undocommand/addelementtextcommand.h" #include "utils/qetsettings.h" #include "utils/qetutils.h" +#include "undocommand/groupitemscommand.h" #include "undocommand/rotateselectioncommand.h" #include "undocommand/rotatetextscommand.h" #include "diagram.h" @@ -813,6 +814,8 @@ void QETDiagramEditor::setUpActions() m_find_element = m_selection_actions_group.addAction( QET::Icons::ZoomDraw, tr("Retrouver dans le panel") ); m_edit_selection = m_selection_actions_group.addAction( QET::Icons::ElementEdit, tr("Éditer l'item sélectionné") ); m_group_selected_texts = m_selection_actions_group.addAction( QET::Icons::textGroup, tr("Grouper les textes sélectionnés")); + m_group_selection = m_selection_actions_group.addAction( tr("Grouper") ); + m_ungroup_selection = m_selection_actions_group.addAction( tr("Dégrouper") ); ShortcutManager::instance().registerAction(m_delete_selection, "diagrameditor.delete_selection", tr("Éditeur de schémas"), Qt::Key_Delete); ShortcutManager::instance().registerAction(m_rotate_selection, "diagrameditor.rotate_selection", tr("Éditeur de schémas"), Qt::Key_Space); @@ -918,6 +921,14 @@ void QETDiagramEditor::setUpActions() m_find_element ->setData("find_selected_element"); m_edit_selection ->setData("edit_selected_element"); m_group_selected_texts->setData("group_selected_texts"); + m_group_selection ->setData("group_selection"); + m_ungroup_selection ->setData("ungroup_selection"); + + //No default key: Ctrl+G is "jump to element". A user can bind one. + ShortcutManager::instance().registerAction(m_group_selection, "diagrameditor.group_selection", tr("Éditeur de schémas"), QKeySequence()); + ShortcutManager::instance().registerAction(m_ungroup_selection, "diagrameditor.ungroup_selection", tr("Éditeur de schémas"), QKeySequence()); + m_group_selection ->setStatusTip(tr("Groupe les éléments, textes, formes et images sélectionnés : ils se sélectionnent, se déplacent et se copient ensemble", "status bar tip")); + m_ungroup_selection->setStatusTip(tr("Défait les groupes sélectionnés", "status bar tip")); connect(&m_selection_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::selectionGroupTriggered); @@ -2055,6 +2066,15 @@ void QETDiagramEditor::selectionGroupTriggered(QAction *action) findElementInPanel(currentElement()->location()); else if (value == "edit_selected_element") dv->editSelection(); + else if (value == "group_selection" || value == "ungroup_selection") + { + GroupItemsCommand *command = value == "group_selection" + ? GroupItemsCommand::group(diagram) + : GroupItemsCommand::ungroup(diagram); + if (command) { + diagram->undoStack().push(command); + } + } else if (value == "group_selected_texts") { QList deti_list = dc.m_element_texts.values(); @@ -2198,7 +2218,9 @@ void QETDiagramEditor::slot_updateComplexActions() << m_rotate_selection << m_rotate_group_selection << m_edit_selection - << m_group_selected_texts; + << m_group_selected_texts + << m_group_selection + << m_ungroup_selection; for(QAction *action : action_list) action->setEnabled(false); @@ -2262,6 +2284,9 @@ void QETDiagramEditor::slot_updateComplexActions() else m_group_selected_texts->setDisabled(true); + m_group_selection ->setEnabled(GroupItemsCommand::canGroup(diagram_)); + m_ungroup_selection->setEnabled(GroupItemsCommand::canUngroup(diagram_)); + // actions need only one editable item int selected_image = dc.count(DiagramContent::Images); diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index c4ac40835..a09ad8d4c 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -253,6 +253,8 @@ class QETDiagramEditor : public QETMainWindow *m_rotate_texts, ///< Direct selected text items to a specific angle *m_find_element, ///< Find the selected element in the panel *m_group_selected_texts = nullptr, + *m_group_selection = nullptr, ///< Group the selected items (#1070) + *m_ungroup_selection = nullptr, ///< Ungroup the selected groups *m_close_file, ///< Close current project file *m_save_file, ///< Save current project *m_save_file_as, ///< Save current project as a specific file diff --git a/sources/undocommand/groupitemscommand.cpp b/sources/undocommand/groupitemscommand.cpp new file mode 100644 index 000000000..83f94bf18 --- /dev/null +++ b/sources/undocommand/groupitemscommand.cpp @@ -0,0 +1,181 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "groupitemscommand.h" + +#include "../diagram.h" +#include "../itemgroups.h" +#include "../qetgraphicsitem/diagramimageitem.h" +#include "../qetgraphicsitem/element.h" +#include "../qetgraphicsitem/independenttextitem.h" +#include "../qetgraphicsitem/qetshapeitem.h" + +#include + +namespace { + QList selectedGroupable(Diagram *diagram) + { + QList items; + for (QGraphicsItem *item : diagram->selectedItems()) { + if (GroupItemsCommand::isGroupable(item)) { + items << item->toGraphicsObject(); + } + } + return items; + } +} + +/** + @return a command grouping the selected symbols, free texts, shapes and + pictures of @a diagram into one new group, or nullptr if fewer than two + are selected. The selection already holds whole groups (see + ItemGroups::completeSelection()), so grouping items that were in groups + merges those groups into the new one. +*/ +GroupItemsCommand *GroupItemsCommand::group(Diagram *diagram) +{ + if (!canGroup(diagram)) { + return nullptr; + } + auto command = new GroupItemsCommand(diagram, selectedGroupable(diagram), QUuid::createUuid()); + command->setText(QObject::tr("Grouper %n objet(s)", "", command->m_changes.size())); + return command; +} + +/** + @return a command taking every member of the selected groups of + @a diagram out of its group, or nullptr if no group is selected. +*/ +GroupItemsCommand *GroupItemsCommand::ungroup(Diagram *diagram) +{ + if (!canUngroup(diagram)) { + return nullptr; + } + QList items; + for (QGraphicsObject *item : selectedGroupable(diagram)) { + if (!ItemGroups::groupOf(item).isNull()) { + items << item; + } + } + auto command = new GroupItemsCommand(diagram, items, QUuid()); + command->setText(QObject::tr("Dégrouper %n objet(s)", "", command->m_changes.size())); + return command; +} + +/** + @return true for the kinds of item a group can hold: symbols, free texts, + shapes and pictures. Never conductors, which follow their symbols, and + never a symbol's own texts, which belong to it. +*/ +bool GroupItemsCommand::isGroupable(const QGraphicsItem *item) +{ + if (!item) { + return false; + } + switch (item->type()) + { + case Element::Type: + case IndependentTextItem::Type: + case QetShapeItem::Type: + case DiagramImageItem::Type: + return true; + default: + return false; + } +} + +/** + @return true if the selection of @a diagram holds at least two items + that could be grouped, and is not already exactly one group. +*/ +bool GroupItemsCommand::canGroup(Diagram *diagram) +{ + if (!diagram || diagram->isReadOnly()) { + return false; + } + const QList items = selectedGroupable(diagram); + if (items.size() < 2) { + return false; + } + QSet groups; + for (QGraphicsObject *item : items) { + groups << ItemGroups::groupOf(item); + } + return groups.size() > 1 || groups.contains(QUuid()); +} + +/** + @return true if the selection of @a diagram holds a grouped item. +*/ +bool GroupItemsCommand::canUngroup(Diagram *diagram) +{ + if (!diagram || diagram->isReadOnly()) { + return false; + } + for (QGraphicsObject *item : selectedGroupable(diagram)) { + if (!ItemGroups::groupOf(item).isNull()) { + return true; + } + } + return false; +} + +/** + @brief GroupItemsCommand::GroupItemsCommand + @param diagram : diagram holding @a items + @param items : items to put in @a group + @param group : the new group, or a null uuid to ungroup +*/ +GroupItemsCommand::GroupItemsCommand(Diagram *diagram, + const QList &items, + const QUuid &group) : + m_diagram(diagram), + m_group(group) +{ + for (QGraphicsObject *item : items) { + m_changes.append({item, ItemGroups::groupOf(item)}); + } +} + +/** + @brief GroupItemsCommand::undo +*/ +void GroupItemsCommand::undo() +{ + apply(false); +} + +/** + @brief GroupItemsCommand::redo +*/ +void GroupItemsCommand::redo() +{ + apply(true); +} + +void GroupItemsCommand::apply(bool redo) +{ + if (!m_diagram) { + return; + } + m_diagram->showMe(); + for (const Change &change : std::as_const(m_changes)) { + if (change.item) { + m_diagram->setItemGroup(change.item, redo ? m_group : change.before); + } + } +} diff --git a/sources/undocommand/groupitemscommand.h b/sources/undocommand/groupitemscommand.h new file mode 100644 index 000000000..4357f6996 --- /dev/null +++ b/sources/undocommand/groupitemscommand.h @@ -0,0 +1,62 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef GROUPITEMSCOMMAND_H +#define GROUPITEMSCOMMAND_H + +#include +#include +#include +#include + +class Diagram; +class QGraphicsItem; +class QGraphicsObject; + +/** + @brief The GroupItemsCommand class + Groups or ungroups the selected items of a diagram (discussion #1070). + Undo gives every item back the exact group it had, so a redo after it + still refers to the same groups. +*/ +class GroupItemsCommand : public QUndoCommand +{ + public: + static GroupItemsCommand *group(Diagram *diagram); + static GroupItemsCommand *ungroup(Diagram *diagram); + static bool isGroupable(const QGraphicsItem *item); + static bool canGroup(Diagram *diagram); + static bool canUngroup(Diagram *diagram); + + void undo() override; + void redo() override; + + private: + GroupItemsCommand(Diagram *diagram, const QList &items, const QUuid &group); + void apply(bool redo); + + struct Change { + QPointer item; + QUuid before; + }; + + QPointer m_diagram; + QList m_changes; + QUuid m_group; +}; + +#endif // GROUPITEMSCOMMAND_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 66074282d..7757aad08 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -104,6 +104,13 @@ add_test(NAME tst_textgrid COMMAND tst_textgrid) target_include_directories(tst_textgrid PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_textgrid PRIVATE Qt::Test) +# itemgroups.cpp keeps group membership and the selection rule apart from +# Diagram, so it is tested here on a plain scene of rectangles. +add_executable(tst_itemgroups tst_itemgroups.cpp ${QET_DIR}/sources/itemgroups.cpp) +add_test(NAME tst_itemgroups COMMAND tst_itemgroups) +target_include_directories(tst_itemgroups PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_itemgroups PRIVATE Qt::Test Qt::Widgets Qt::Xml) + add_executable( tst_qetpalette tst_qetpalette.cpp diff --git a/tests/qttest/tst_itemgroups.cpp b/tests/qttest/tst_itemgroups.cpp new file mode 100644 index 000000000..079c8c94c --- /dev/null +++ b/tests/qttest/tst_itemgroups.cpp @@ -0,0 +1,142 @@ +#include +#include +#include +#include + +#include "itemgroups.h" + +class tst_itemgroups : public QObject +{ + Q_OBJECT + + QGraphicsScene *scene = nullptr; + QGraphicsRectItem *a = nullptr, *b = nullptr, *c = nullptr, *d = nullptr, *e = nullptr; + QUuid g1, g2; + + QGraphicsRectItem *add() + { + auto item = scene->addRect(0, 0, 10, 10); + item->setFlag(QGraphicsItem::ItemIsSelectable); + return item; + } + + QList selection() const { return scene->selectedItems(); } + + // Select exactly @a items, as Qt leaves the scene after a click. + void select(const QList &items) + { + scene->clearSelection(); + for (auto item : items) { + item->setSelected(true); + } + } + + bool selected(std::initializer_list expected) const + { + QList got = selection(); + if (got.size() != int(expected.size())) { + return false; + } + for (auto item : expected) { + if (!got.contains(item)) { + return false; + } + } + return true; + } + +private slots: + // a, b in g1; c alone; d, e in g2 + void init() + { + scene = new QGraphicsScene; + a = add(); b = add(); c = add(); d = add(); e = add(); + g1 = QUuid::createUuid(); + g2 = QUuid::createUuid(); + ItemGroups::setGroup(a, g1); + ItemGroups::setGroup(b, g1); + ItemGroups::setGroup(d, g2); + ItemGroups::setGroup(e, g2); + } + + void cleanup() + { + delete scene; + scene = nullptr; + } + + void clickingAMemberSelectsTheGroup() + { + select({a}); + QVERIFY(ItemGroups::completeSelection(scene, {}, false)); + QVERIFY(selected({a, b})); + } + + // Releasing a click on one member of the selected group leaves only it + // selected in Qt; the group must stay whole. + void reclickingAMemberKeepsTheGroup() + { + select({b}); + ItemGroups::completeSelection(scene, {a, b}, false); + QVERIFY(selected({a, b})); + } + + void ctrlClickingAMemberOffDeselectsTheGroup() + { + select({b}); + ItemGroups::completeSelection(scene, {a, b}, true); + QVERIFY(selected({})); + } + + void ctrlClickingAMemberOnAddsTheGroup() + { + select({c, a}); + ItemGroups::completeSelection(scene, {c}, true); + QVERIFY(selected({a, b, c})); + } + + void otherGroupsAreUntouched() + { + select({b, d, e}); + ItemGroups::completeSelection(scene, {a, b, d, e}, true); + QVERIFY(selected({d, e})); + } + + void ungroupedItemsAreUntouched() + { + select({c}); + QVERIFY(!ItemGroups::completeSelection(scene, {}, false)); + QVERIFY(selected({c})); + } + + // A member taken off the scene (deleted, kept by the undo stack) must + // not count as "deselected". + void itemsLeavingTheSceneAreIgnored() + { + select({a, b}); + scene->removeItem(b); + ItemGroups::completeSelection(scene, {a, b}, true); + QVERIFY(selected({a})); + delete b; + b = nullptr; + } + + void xmlRoundTrip() + { + QDomDocument doc; + QDomElement grouped = doc.createElement(QStringLiteral("element")); + ItemGroups::write(grouped, a); + QCOMPARE(ItemGroups::read(grouped), g1); + + QDomElement alone = doc.createElement(QStringLiteral("element")); + ItemGroups::write(alone, c); + QVERIFY(!alone.hasAttribute(QStringLiteral("group"))); + QVERIFY(ItemGroups::read(alone).isNull()); + + ItemGroups::setGroup(a, QUuid()); + QVERIFY(ItemGroups::groupOf(a).isNull()); + } +}; + +QTEST_MAIN(tst_itemgroups) +#include "tst_itemgroups.moc" From 166ee34f5279f1da395f53d0dbda9fa00f510549 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sun, 27 Sep 2026 21:46:30 +1300 Subject: [PATCH 2/6] Fix parts lists saved before June 2022 losing rows and tables on open A nomenclature table saved by an older version filters on the element type names the project database used then: element_type = 'Simple', 'Terminale', 'Master'. Commit 2e70d2e59 (June 2022) changed the database to "simple", "terminal", "master", and SQLite compares text case-sensitively, so such a table silently lost every row of that type on open. Its continuation tables were then empty, and removeUselessNextTable() deleted them: opening and saving the example industrial.qet removed seven of its ten parts-list tables (folios 44-50), and the remaining three listed 76 of 258 parts. ProjectDBModel::fromXml() now rewrites old names in element_type = '...' comparisons to the current ones (LegacyElementTypes::upgradeQuery()), which also lets the query editor tick the right boxes again. A query saved by a current version is unchanged, and so is any other text that happens to contain "Simple". Checked in the GUI on industrial.qet, open then save: master keeps tables on 3 of folios 41-50, this keeps all 10, with 258 rows (the last table 24 of 26) and the query saved as 'simple'. tst_legacyelementtypes fails when a name maps wrongly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG --- cmake/qet_compilation_vars.cmake | 1 + sources/dataBase/legacyelementtypes.h | 69 +++++++++++++++++++ .../ViewItem/projectdbmodel.cpp | 5 +- tests/qttest/CMakeLists.txt | 7 ++ tests/qttest/tst_legacyelementtypes.cpp | 60 ++++++++++++++++ 5 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 sources/dataBase/legacyelementtypes.h create mode 100644 tests/qttest/tst_legacyelementtypes.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 60fddcf55..398cdce8c 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -325,6 +325,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 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/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/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 66074282d..d2437661d 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -104,6 +104,13 @@ add_test(NAME tst_textgrid COMMAND tst_textgrid) target_include_directories(tst_textgrid PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_textgrid PRIVATE Qt::Test) +# 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_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" From f62a44827bfb032498f3ec8a8413400a1aa40636 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 05:43:11 +1300 Subject: [PATCH 3/6] Add a setting to show collection search results as a tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1051 replaced the filtered tree with a ranked flat list. A new checkbox in Settings > General, "Afficher les résultats de recherche sous forme de liste triée" (elementscollection/search-flat-list, default on), keeps the ranked list; unticked restores the pre-#1051 filtered tree search. The Insert picker and shortcut bar call rankedSearch() directly and are unaffected. Down/Enter from the search field only apply to the flat list. Requested by scorpio810 on #1051. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG --- .../elementscollectionwidget.cpp | 27 ++++++++++++++++++- .../configpage/generalconfigurationpage.cpp | 2 ++ .../ui/configpage/generalconfigurationpage.ui | 10 +++++++ 3 files changed, 38 insertions(+), 1 deletion(-) 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/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 + + + From adcf11270c4e25bfa38cffead6c0ff8267c2770a Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 05:55:20 +1300 Subject: [PATCH 4/6] Upgrade old element type names in saved query presets too A query saved to nomenclature.json (Load/Save in the query editor, since 2020) before June 2022 still says element_type = 'Simple'. Loading it ticked none of the type boxes and kept the old query, so the new table came out empty. Pass it through LegacyElementTypes::upgradeQuery() as ProjectDBModel::fromXml() now does. Co-Authored-By: Claude Opus 5.5 --- sources/dataBase/ui/elementquerywidget.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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())); } } From 239065af7d16e1816c117e7a697f9b11e8abef12 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 06:16:17 +1300 Subject: [PATCH 5/6] Open the text orientation dialog at the texts' current angle The "Orienter les textes" dialog always opened at 0, so turning a text at 90 degrees by a little meant typing the angle in again. It now opens at the angle every selected text and text group shares, and at 0 as before when they differ. The shortcut bar showed only a command's short name as its tooltip, which for this one ("Choose texts orientation") does not say what it does. It now adds the command's status tip on a second line: "Rotate selected texts to a specific angle". Checked in the GUI on a folio holding one text at 90 degrees: master opens the dialog at 0.00, this at 90.00. Fixes #1082 Co-Authored-By: Claude Opus 5.5 --- sources/diagramcontexttoolbar.cpp | 7 ++++- sources/qetdiagrameditor.cpp | 2 +- sources/undocommand/rotatetextscommand.cpp | 36 ++++++++++++++++++++-- sources/undocommand/rotatetextscommand.h | 6 ++-- 4 files changed, 45 insertions(+), 6 deletions(-) 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/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 1845df15f..7407c892e 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -2059,7 +2059,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)); } 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; From 83bb7e14e07cb96cb07fa55f27800328dd5c7ed4 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 06:23:33 +1300 Subject: [PATCH 6/6] Add the status tip to the S shortcut bar's tooltips too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit changed only the bar that appears beside the cursor after a click. The S shortcut bar builds its own tooltips, so its "Orienter les textes" button still said only that. Its tooltips now carry the status tip on a second line as well, after the keyboard shortcut. Checked in the GUI: the S bar's second button shows "Orienter les textes (Ctrl+Space)" over "Pivote les textes sélectionnés à un angle précis". Co-Authored-By: Claude Opus 5.5 --- sources/ElementsCollection/elementpickerpopup.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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]() {