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"