diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake
index 60fddcf55..acab33855 100644
--- a/cmake/qet_compilation_vars.cmake
+++ b/cmake/qet_compilation_vars.cmake
@@ -836,6 +836,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/alignselectioncommand.cpp
+ ${QET_DIR}/sources/undocommand/alignselectioncommand.h
${QET_DIR}/sources/undocommand/rotateselectioncommand.cpp
${QET_DIR}/sources/undocommand/rotateselectioncommand.h
${QET_DIR}/sources/undocommand/promoteshapecommand.cpp
diff --git a/sources/alignment.h b/sources/alignment.h
new file mode 100644
index 000000000..fba24c56b
--- /dev/null
+++ b/sources/alignment.h
@@ -0,0 +1,49 @@
+/*
+ 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 ALIGNMENT_H
+#define ALIGNMENT_H
+
+#include "textgrid.h"
+
+#include
+
+/**
+ The geometry behind the align commands, kept free of any scene so it
+ can be tested on its own.
+*/
+namespace Alignment
+{
+ /**
+ @return the movement that puts p on a grid of x_grid by y_grid,
+ divided by divisor as TextGrid::snap() does, or a null point when
+ p is already on it. Unlike Diagram::snapToGrid(), this never looks
+ at the keyboard: a command run from a shortcut with Ctrl in it must
+ not quietly round to the pixel instead.
+ Less than a millionth of a pixel counts as on the grid: positions
+ that went through arithmetic carry residues of that size, and
+ qFuzzyIsNull() (1e-12) is too strict to absorb them.
+ */
+ inline QPointF gridOffset(const QPointF &p, int x_grid, int y_grid, qreal divisor = 1)
+ {
+ const QPointF offset = TextGrid::snap(p, x_grid, y_grid, divisor) - p;
+ auto clean = [](qreal v) { return qAbs(v) < 1e-6 ? 0.0 : v; };
+ return QPointF(clean(offset.x()), clean(offset.y()));
+ }
+}
+
+#endif // ALIGNMENT_H
diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp
index e3996bf66..a63b4350f 100644
--- a/sources/diagramview.cpp
+++ b/sources/diagramview.cpp
@@ -1782,6 +1782,7 @@ QList DiagramView::contextMenuActions() const
list << qde->m_conductor_reset;
list << m_separators.at(1);
list << qde->m_selection_actions_group.actions();
+ list << qde->m_align_menu->menuAction();
list << m_separators.at(2);
list << qde->m_depth_action_group->actions();
}
diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp
index d97d08b24..1845df15f 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/alignselectioncommand.h"
#include "undocommand/rotateselectioncommand.h"
#include "undocommand/rotatetextscommand.h"
#include "diagram.h"
@@ -96,6 +97,7 @@ QETDiagramEditor::QETDiagramEditor(const QStringList &files, QWidget *parent) :
QETMainWindow(parent),
m_row_column_actions_group (this),
m_selection_actions_group (this),
+ m_align_actions_group (this),
m_add_item_actions_group (this),
m_zoom_actions_group (this),
m_select_actions_group (this),
@@ -921,6 +923,15 @@ void QETDiagramEditor::setUpActions()
connect(&m_selection_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::selectionGroupTriggered);
+ //Align actions. No default shortcut: they are reached from the
+ //Edit menu, the selection's context menu and the command search,
+ //and a user can bind one if they want.
+ QAction *snap_to_grid = m_align_actions_group.addAction(tr("Aligner sur la grille"));
+ ShortcutManager::instance().registerAction(snap_to_grid, "diagrameditor.snap_selection_to_grid", tr("Éditeur de schémas"), QKeySequence());
+ snap_to_grid->setStatusTip(tr("Remet les éléments, images et textes sélectionnés sur la grille", "status bar tip"));
+ snap_to_grid->setData("snap_selection_to_grid");
+ connect(&m_align_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::alignGroupTriggered);
+
//Select Action
QAction *select_all = m_select_actions_group.addAction( QET::Icons::EditSelectAll, tr("Tout sélectionner") );
QAction *select_nothing = m_select_actions_group.addAction( QET::Icons::EditSelectNone, tr("Désélectionner tout") );
@@ -1215,6 +1226,8 @@ void QETDiagramEditor::setUpMenu()
menu_edition -> addActions(m_select_actions_group.actions());
menu_edition -> addSeparator();
menu_edition -> addActions(m_selection_actions_group.actions());
+ m_align_menu = menu_edition -> addMenu(tr("Aligner"));
+ m_align_menu -> addActions(m_align_actions_group.actions());
menu_edition -> addSeparator();
menu_edition -> addAction(m_conductor_reset);
menu_edition -> addSeparator();
@@ -2065,6 +2078,45 @@ void QETDiagramEditor::selectionGroupTriggered(QAction *action)
}
}
+/**
+ @brief QETDiagramEditor::alignGroupTriggered
+ Run the align action @a action on the selection of the current diagram,
+ and say in the status bar what it did, including when there was nothing
+ to do or when locked items were left in place.
+ @param action
+*/
+void QETDiagramEditor::alignGroupTriggered(QAction *action)
+{
+ DiagramView *dv = currentDiagramView();
+ if (!dv || action->data().toString() != QLatin1String("snap_selection_to_grid"))
+ return;
+
+ Diagram *diagram = dv->diagram();
+ auto *command = new AlignSelectionCommand(diagram, AlignSelectionCommand::SnapToGrid);
+ const int locked = command->lockedCount();
+
+ QString message;
+ if (command->isValid())
+ {
+ message = tr("%n objet(s) remis sur la grille", "", command->movedCount());
+ diagram->undoStack().push(command);
+ }
+ else
+ {
+ message = tr("La sélection est déjà sur la grille");
+ delete command;
+ }
+ if (locked)
+ message += QLatin1Char(' ') + tr("(%n objet(s) verrouillé(s) laissé(s) en place)", "", locked);
+
+ //Queued, not shown at once: when the action comes from a menu or the
+ //command search, closing it queues events that would clear a message
+ //shown right now.
+ QTimer::singleShot(0, this, [this, message]() {
+ statusBar()->showMessage(message, 5000);
+ });
+}
+
void QETDiagramEditor::rowColumnGroupTriggered(QAction *action)
{
QString value = action->data().toString();
@@ -2201,6 +2253,7 @@ void QETDiagramEditor::slot_updateComplexActions()
<< m_group_selected_texts;
for(QAction *action : action_list)
action->setEnabled(false);
+ m_align_actions_group.setEnabled(false);
return;
}
@@ -2322,6 +2375,11 @@ void QETDiagramEditor::slot_updateComplexActions()
| DiagramContent::Shapes
| DiagramContent::Images);
m_depth_action_group->setEnabled(list.isEmpty()? false : true);
+
+ //Align actions: symbols, pictures and free texts take part
+ m_align_actions_group.setEnabled(!ro && (selected_elements_count
+ || selected_image
+ || dc.count(DiagramContent::TextFields)));
}
/**
diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h
index c4ac40835..96f1177f9 100644
--- a/sources/qetdiagrameditor.h
+++ b/sources/qetdiagrameditor.h
@@ -122,6 +122,7 @@ class QETDiagramEditor : public QETMainWindow
void selectGroupTriggered (QAction *action);
void addItemGroupTriggered (QAction *action);
void selectionGroupTriggered (QAction *action);
+ void alignGroupTriggered (QAction *action);
void rowColumnGroupTriggered (QAction *action);
void slot_updateActions();
void slot_updateUndoStack();
@@ -189,10 +190,12 @@ class QETDiagramEditor : public QETMainWindow
QActionGroup
m_row_column_actions_group, /// Action related to add/remove rows/column in diagram
m_selection_actions_group, ///Action related to edit a selected item
+ m_align_actions_group, ///Action related to align the selected items
*m_depth_action_group = nullptr;
QMenu
*m_add_item_menu = nullptr, ///< Submenu of m_add_item_actions_group
+ *m_align_menu = nullptr, ///< Submenu of m_align_actions_group
*m_row_column_menu = nullptr; ///< Submenu of m_row_column_actions_group
private:
diff --git a/sources/undocommand/alignselectioncommand.cpp b/sources/undocommand/alignselectioncommand.cpp
new file mode 100644
index 000000000..28eb62a93
--- /dev/null
+++ b/sources/undocommand/alignselectioncommand.cpp
@@ -0,0 +1,119 @@
+/*
+ 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 "alignselectioncommand.h"
+
+#include "../QPropertyUndoCommand/qpropertyundocommand.h"
+#include "../alignment.h"
+#include "../diagram.h"
+#include "../diagramcontent.h"
+#include "../qetgraphicsitem/diagramimageitem.h"
+#include "../qetgraphicsitem/element.h"
+#include "../qetgraphicsitem/independenttextitem.h"
+
+#include
+
+/**
+ @brief AlignSelectionCommand::AlignSelectionCommand
+ Works out the movement of every selected item for @a mode. Nothing
+ moves until the command is pushed.
+ Elements redraw their conductors themselves when their position
+ changes (see Element's constructor), so a plain "pos" property undo
+ per item is enough.
+ @param diagram : diagram whose selection is aligned
+ @param mode : what to align the selection to
+ @param parent : parent undo command
+*/
+AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoCommand *parent) :
+ QUndoCommand(parent),
+ m_diagram(diagram)
+{
+ Q_UNUSED(mode)
+
+ DiagramContent dc(diagram);
+ m_locked_count = dc.removeNonMovableItems();
+
+ QSettings settings;
+ const int x_grid = settings.value(QStringLiteral("diagrameditor/Xgrid"), Diagram::xGrid).toInt();
+ const int y_grid = settings.value(QStringLiteral("diagrameditor/Ygrid"), Diagram::yGrid).toInt();
+ const qreal text_divisor = settings.value(TextGrid::settings_key, 1).toReal();
+
+ auto move = [this](QGraphicsObject *item, const QPointF &offset)
+ {
+ if (!offset.isNull())
+ new QPropertyUndoCommand(item, "pos", item->pos(), item->pos() + offset, this);
+ };
+
+ //Each kind goes where dragging it would have left it: symbols and
+ //pictures on the folio grid, free texts on the text grid.
+ //Shapes are left out: they are made of several points and no single
+ //one of them is the obvious one to snap.
+ for (Element *element : std::as_const(dc.m_elements))
+ move(element, Alignment::gridOffset(element->pos(), x_grid, y_grid));
+ for (DiagramImageItem *image : std::as_const(dc.m_images))
+ move(image, Alignment::gridOffset(image->pos(), x_grid, y_grid));
+ for (IndependentTextItem *text : std::as_const(dc.m_text_fields))
+ move(text, Alignment::gridOffset(text->pos(), x_grid, y_grid, text_divisor));
+
+ setText(QObject::tr("Aligner %n objet(s) sur la grille", "", childCount()));
+}
+
+/**
+ @brief AlignSelectionCommand::undo
+*/
+void AlignSelectionCommand::undo()
+{
+ if (m_diagram)
+ m_diagram->showMe();
+ QUndoCommand::undo();
+}
+
+/**
+ @brief AlignSelectionCommand::redo
+*/
+void AlignSelectionCommand::redo()
+{
+ if (m_diagram)
+ m_diagram->showMe();
+ QUndoCommand::redo();
+}
+
+/**
+ @brief AlignSelectionCommand::isValid
+ @return true if this command moves at least one item.
+*/
+bool AlignSelectionCommand::isValid() const
+{
+ return childCount() > 0;
+}
+
+/**
+ @return the number of items this command moves.
+*/
+int AlignSelectionCommand::movedCount() const
+{
+ return childCount();
+}
+
+/**
+ @return the number of selected items left in place because their
+ position is locked.
+*/
+int AlignSelectionCommand::lockedCount() const
+{
+ return m_locked_count;
+}
diff --git a/sources/undocommand/alignselectioncommand.h b/sources/undocommand/alignselectioncommand.h
new file mode 100644
index 000000000..1162077f8
--- /dev/null
+++ b/sources/undocommand/alignselectioncommand.h
@@ -0,0 +1,53 @@
+/*
+ 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 ALIGNSELECTIONCOMMAND_H
+#define ALIGNSELECTIONCOMMAND_H
+
+#include
+#include
+
+class Diagram;
+
+/**
+ @brief The AlignSelectionCommand class
+ Moves each selected item by its own amount, as one undo step.
+ Symbols, pictures and free texts take part; locked items are left
+ where they are and counted, so the caller can say so.
+*/
+class AlignSelectionCommand : public QUndoCommand
+{
+ public:
+ enum Mode {
+ SnapToGrid ///< put each item where a drag would have left it
+ };
+
+ AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoCommand *parent = nullptr);
+
+ void undo() override;
+ void redo() override;
+
+ bool isValid() const;
+ int movedCount() const;
+ int lockedCount() const;
+
+ private:
+ QPointer m_diagram;
+ int m_locked_count = 0;
+};
+
+#endif // ALIGNSELECTIONCOMMAND_H
diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt
index 66074282d..dea8b4e7e 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)
+# alignment.h is header-only too (it builds on textgrid.h): the geometry
+# behind the align commands, tested without a scene.
+add_executable(tst_alignment tst_alignment.cpp)
+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)
+
add_executable(
tst_qetpalette
tst_qetpalette.cpp
diff --git a/tests/qttest/tst_alignment.cpp b/tests/qttest/tst_alignment.cpp
new file mode 100644
index 000000000..1aa70c239
--- /dev/null
+++ b/tests/qttest/tst_alignment.cpp
@@ -0,0 +1,55 @@
+#include
+
+#include "alignment.h"
+
+class tst_alignment : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void gridOffset_data()
+ {
+ QTest::addColumn("pos");
+ QTest::addColumn("x_grid");
+ QTest::addColumn("y_grid");
+ QTest::addColumn("divisor");
+ QTest::addColumn("expected");
+
+ QTest::newRow("on the grid") << QPointF(100, 50) << 10 << 10 << 1.0 << QPointF(0, 0);
+ QTest::newRow("5 px right, fine nudge") << QPointF(105, 50) << 10 << 10 << 1.0 << QPointF(5, 0);
+ QTest::newRow("either side") << QPointF(103, 97) << 10 << 10 << 1.0 << QPointF(-3, 3);
+ QTest::newRow("negative, fraction") << QPointF(-13.3, 13.7) << 10 << 10 << 1.0 << QPointF(3.3, -3.7);
+ QTest::newRow("uneven grid") << QPointF(12, 13) << 10 << 5 << 1.0 << QPointF(-2, 2);
+ QTest::newRow("text grid 1:2") << QPointF(103, 97) << 10 << 10 << 2.0 << QPointF(2, -2);
+ QTest::newRow("text grid off") << QPointF(103.4, 96.6) << 10 << 10 << 0.0 << QPointF(-0.4, 0.4);
+ }
+
+ void gridOffset()
+ {
+ QFETCH(QPointF, pos);
+ QFETCH(int, x_grid);
+ QFETCH(int, y_grid);
+ QFETCH(qreal, divisor);
+ QFETCH(QPointF, expected);
+
+ const QPointF offset = Alignment::gridOffset(pos, x_grid, y_grid, divisor);
+ QVERIFY2(qAbs(offset.x() - expected.x()) < 1e-9, qPrintable(QString::number(offset.x())));
+ QVERIFY2(qAbs(offset.y() - expected.y()) < 1e-9, qPrintable(QString::number(offset.y())));
+ QVERIFY(qAbs(pos.x() + offset.x() - TextGrid::snap(pos, x_grid, y_grid, divisor).x()) < 1e-9);
+ }
+
+ // An item already on the grid must give an exactly null offset, not a
+ // rounding residue: the command pushes no undo step only when every
+ // offset is null.
+ void onGridIsExactlyNull()
+ {
+ for (int k = -50; k <= 50; ++k) {
+ const QPointF on_grid(k * 10, -k * 10);
+ QVERIFY(Alignment::gridOffset(on_grid, 10, 10).isNull());
+ QVERIFY(Alignment::gridOffset(on_grid + QPointF(1e-10, -1e-10), 10, 10).isNull());
+ }
+ }
+};
+
+QTEST_APPLESS_MAIN(tst_alignment)
+#include "tst_alignment.moc"