Merge pull request #1073 from ispyisail/feat/1069-snap-to-grid

Add "Snap to grid" for selected symbols, pictures and texts
This commit is contained in:
Laurent Trinques
2026-09-27 11:55:49 +02:00
committed by GitHub
9 changed files with 347 additions and 0 deletions
+2
View File
@@ -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
+49
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef ALIGNMENT_H
#define ALIGNMENT_H
#include "textgrid.h"
#include <QPointF>
/**
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
+1
View File
@@ -1782,6 +1782,7 @@ QList<QAction *> 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();
}
+58
View File
@@ -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)));
}
/**
+3
View File
@@ -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:
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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 <QSettings>
/**
@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;
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef ALIGNSELECTIONCOMMAND_H
#define ALIGNSELECTIONCOMMAND_H
#include <QPointer>
#include <QUndoCommand>
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<Diagram> m_diagram;
int m_locked_count = 0;
};
#endif // ALIGNSELECTIONCOMMAND_H
+7
View File
@@ -104,6 +104,13 @@ add_test(NAME tst_textgrid COMMAND tst_textgrid)
target_include_directories(tst_textgrid PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_textgrid PRIVATE Qt::Test)
# 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
+55
View File
@@ -0,0 +1,55 @@
#include <QtTest>
#include "alignment.h"
class tst_alignment : public QObject
{
Q_OBJECT
private slots:
void gridOffset_data()
{
QTest::addColumn<QPointF>("pos");
QTest::addColumn<int>("x_grid");
QTest::addColumn<int>("y_grid");
QTest::addColumn<qreal>("divisor");
QTest::addColumn<QPointF>("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"