Merge pull request #1087 from ispyisail/feat/1069-align

Add Align left/centre/right/top/middle/bottom for the selection
This commit is contained in:
Laurent Trinques
2026-09-27 21:52:56 +02:00
committed by GitHub
6 changed files with 411 additions and 24 deletions
+101
View File
@@ -20,7 +20,11 @@
#include "textgrid.h"
#include <QList>
#include <QPointF>
#include <QRectF>
#include <algorithm>
/**
The geometry behind the align commands, kept free of any scene so it
@@ -28,6 +32,84 @@
*/
namespace Alignment
{
/**
What the items of a selection are lined up on.
*/
enum Edge {
Left, ///< left edges, on the left-most one
HCenter, ///< reference points, on the mean of their x
Right, ///< right edges, on the right-most one
Top, ///< top edges, on the top-most one
VCenter, ///< reference points, on the mean of their y
Bottom ///< bottom edges, on the bottom-most one
};
/**
One item to align: its edges, and the point that counts as its
centre. For a symbol that point is its origin, where its wires
usually leave, not the middle of its drawn shape.
*/
struct Item {
QRectF edges;
QPointF ref;
};
/**
@return true if aligning on @a edge moves items along x
*/
inline bool isHorizontal(Edge edge)
{
return edge == Left || edge == HCenter || edge == Right;
}
/**
@return the movement of each of @a items that lines them up on
@a edge, in the same order. Items only move across the line they
are aligned on: aligning left never moves anything up or down.
*/
inline QList<QPointF> alignOffsets(const QList<Item> &items, Edge edge)
{
QList<qreal> values;
for (const Item &item : items)
{
switch (edge) {
case Left: values << item.edges.left(); break;
case HCenter: values << item.ref.x(); break;
case Right: values << item.edges.right(); break;
case Top: values << item.edges.top(); break;
case VCenter: values << item.ref.y(); break;
case Bottom: values << item.edges.bottom(); break;
}
}
QList<QPointF> offsets;
if (values.isEmpty())
return offsets;
qreal target = 0;
switch (edge) {
case Left:
case Top:
target = *std::min_element(values.cbegin(), values.cend());
break;
case Right:
case Bottom:
target = *std::max_element(values.cbegin(), values.cend());
break;
case HCenter:
case VCenter:
for (qreal v : std::as_const(values))
target += v;
target /= values.size();
break;
}
for (qreal v : std::as_const(values))
offsets << (isHorizontal(edge) ? QPointF(target - v, 0)
: QPointF(0, target - v));
return offsets;
}
/**
@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
@@ -44,6 +126,25 @@ namespace Alignment
auto clean = [](qreal v) { return qAbs(v) < 1e-6 ? 0.0 : v; };
return QPointF(clean(offset.x()), clean(offset.y()));
}
/**
@return @a offset, the movement alignOffsets() gave an item at
@a pos, rounded so the item lands on the grid along the line it is
aligned on. The other coordinate is left as it is, even off the
grid: aligning left must not also move items up or down.
Edges of items whose width is not a whole number of grid steps
apart from their origin cannot all land on one line; they end up as
close to it as the grid allows.
*/
inline QPointF snappedOffset(const QPointF &pos, const QPointF &offset, Edge edge,
int x_grid, int y_grid, qreal divisor = 1)
{
const QPointF snap = gridOffset(pos + offset, x_grid, y_grid, divisor);
QPointF result = offset + (isHorizontal(edge) ? QPointF(snap.x(), 0)
: QPointF(0, snap.y()));
auto clean = [](qreal v) { return qAbs(v) < 1e-6 ? 0.0 : v; };
return QPointF(clean(result.x()), clean(result.y()));
}
}
#endif // ALIGNMENT_H
+61 -9
View File
@@ -941,6 +941,26 @@ void QETDiagramEditor::setUpActions()
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");
const struct {
const char *id;
QString text;
QString tip;
} align_actions[] = {
{"align_left", tr("Aligner à gauche"), tr("Aligne les bords gauches des objets sélectionnés", "status bar tip")},
{"align_hcenter", tr("Centrer horizontalement"), tr("Aligne les objets sélectionnés sur une même verticale, par leur point d'origine pour les éléments", "status bar tip")},
{"align_right", tr("Aligner à droite"), tr("Aligne les bords droits des objets sélectionnés", "status bar tip")},
{"align_top", tr("Aligner en haut"), tr("Aligne les bords supérieurs des objets sélectionnés", "status bar tip")},
{"align_vcenter", tr("Centrer verticalement"), tr("Aligne les objets sélectionnés sur une même horizontale, par leur point d'origine pour les éléments", "status bar tip")},
{"align_bottom", tr("Aligner en bas"), tr("Aligne les bords inférieurs des objets sélectionnés", "status bar tip")}
};
for (const auto &a : align_actions)
{
QAction *action = m_align_actions_group.addAction(a.text);
ShortcutManager::instance().registerAction(action, QStringLiteral("diagrameditor.") + QLatin1String(a.id), tr("Éditeur de schémas"), QKeySequence());
action->setStatusTip(a.tip);
action->setData(QString::fromLatin1(a.id));
}
connect(&m_align_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::alignGroupTriggered);
//Select Action
@@ -1238,7 +1258,13 @@ void QETDiagramEditor::setUpMenu()
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());
//Snap to grid, then the horizontal three, then the vertical three,
//in the order setUpActions() adds them
m_align_menu -> addAction(m_align_actions_group.actions().first());
m_align_menu -> addSeparator();
m_align_menu -> addActions(m_align_actions_group.actions().mid(1, 3));
m_align_menu -> addSeparator();
m_align_menu -> addActions(m_align_actions_group.actions().mid(4));
menu_edition -> addSeparator();
menu_edition -> addAction(m_conductor_reset);
menu_edition -> addSeparator();
@@ -2108,22 +2134,43 @@ void QETDiagramEditor::selectionGroupTriggered(QAction *action)
void QETDiagramEditor::alignGroupTriggered(QAction *action)
{
DiagramView *dv = currentDiagramView();
if (!dv || action->data().toString() != QLatin1String("snap_selection_to_grid"))
if (!dv)
return;
static const QHash<QString, AlignSelectionCommand::Mode> modes {
{QStringLiteral("snap_selection_to_grid"), AlignSelectionCommand::SnapToGrid},
{QStringLiteral("align_left"), AlignSelectionCommand::AlignLeft},
{QStringLiteral("align_hcenter"), AlignSelectionCommand::AlignHCenter},
{QStringLiteral("align_right"), AlignSelectionCommand::AlignRight},
{QStringLiteral("align_top"), AlignSelectionCommand::AlignTop},
{QStringLiteral("align_vcenter"), AlignSelectionCommand::AlignVCenter},
{QStringLiteral("align_bottom"), AlignSelectionCommand::AlignBottom}
};
const QString id = action->data().toString();
if (!modes.contains(id))
return;
const AlignSelectionCommand::Mode mode = modes.value(id);
Diagram *diagram = dv->diagram();
auto *command = new AlignSelectionCommand(diagram, AlignSelectionCommand::SnapToGrid);
auto *command = new AlignSelectionCommand(diagram, mode);
const int locked = command->lockedCount();
QString message;
if (command->isValid())
{
message = tr("%n objet(s) remis sur la grille", "", command->movedCount());
message = mode == AlignSelectionCommand::SnapToGrid
? tr("%n objet(s) remis sur la grille", "", command->movedCount())
: tr("%n objet(s) aligné(s)", "", command->movedCount());
diagram->undoStack().push(command);
}
else
{
message = tr("La sélection est déjà sur la grille");
if (mode == AlignSelectionCommand::SnapToGrid)
message = tr("La sélection est déjà sur la grille");
else if (command->itemCount() < 2)
message = tr("Sélectionnez au moins deux éléments, images, textes ou groupes non verrouillés");
else
message = tr("La sélection est déjà alignée, à la grille près");
delete command;
}
if (locked)
@@ -2401,10 +2448,15 @@ void QETDiagramEditor::slot_updateComplexActions()
| 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)));
//Align actions: symbols, pictures and free texts take part.
//Snapping needs one of them, lining them up needs two.
const int alignable = selected_elements_count
+ selected_image
+ dc.count(DiagramContent::TextFields);
m_align_actions_group.setEnabled(!ro && alignable);
const QList<QAction *> align_actions = m_align_actions_group.actions();
for (QAction *action : align_actions.mid(1))
action->setEnabled(!ro && alignable >= 2);
}
/**
+1 -1
View File
@@ -97,6 +97,7 @@ class DiagramImageItem : public QetGraphicsItem {
QUuid uuid() const {return m_uuid;}
void setUuid(const QUuid &uuid) {m_uuid = uuid; emit uuidChanged();}
void newUuid() {setUuid(QUuid::createUuid());} //create new uuid for this item
QRectF imageRect() const;
void editProperty() override;
void setPixmap(const QPixmap &pixmap);
QPixmap pixmap() const { return pixmap_; }
@@ -186,7 +187,6 @@ class DiagramImageItem : public QetGraphicsItem {
static QString hintForHandleRole(HandleRole role);
void showStatusHint(const QString &text) const;
void clearStatusHint() const;
QRectF imageRect() const;
QRectF labelRect() const;
void updateLabelScale();
+129 -13
View File
@@ -21,10 +21,13 @@
#include "../alignment.h"
#include "../diagram.h"
#include "../diagramcontent.h"
#include "../itemgroups.h"
#include "../qetgraphicsitem/diagramimageitem.h"
#include "../qetgraphicsitem/element.h"
#include "../qetgraphicsitem/independenttextitem.h"
#include "../qetgraphicsitem/qetshapeitem.h"
#include <QHash>
#include <QSettings>
/**
@@ -42,8 +45,6 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC
QUndoCommand(parent),
m_diagram(diagram)
{
Q_UNUSED(mode)
DiagramContent dc(diagram);
m_locked_count = dc.removeNonMovableItems();
@@ -52,24 +53,130 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC
const int y_grid = settings.value(QStringLiteral("diagrameditor/Ygrid"), Diagram::yGrid).toInt();
const qreal text_divisor = settings.value(TextGrid::settings_key, 1).toReal();
//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.
struct Entry {
QGraphicsObject *item;
Alignment::Item geometry;
qreal divisor;
};
QList<Entry> entries;
//A symbol's edges are its own outline, without its texts, and its
//centre is its origin point: that is where its wires leave.
for (Element *element : std::as_const(dc.m_elements))
entries << Entry{element, {element->sceneBoundingRect(), element->pos()}, 1};
//A picture's edges are the picture's, without its caption
for (DiagramImageItem *image : std::as_const(dc.m_images))
{
const QRectF rect = image->mapRectToScene(image->imageRect());
entries << Entry{image, {rect, rect.center()}, 1};
}
for (IndependentTextItem *text : std::as_const(dc.m_text_fields))
entries << Entry{text, {text->sceneBoundingRect(), text->sceneBoundingRect().center()}, text_divisor};
m_item_count = entries.size();
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));
if (mode == SnapToGrid)
{
for (const Entry &entry : std::as_const(entries))
move(entry.item, Alignment::gridOffset(entry.item->pos(), x_grid, y_grid, entry.divisor));
setText(QObject::tr("Aligner %n objet(s) sur la grille", "", childCount()));
return;
}
setText(QObject::tr("Aligner %n objet(s) sur la grille", "", childCount()));
//A group (#1070) lines up as one piece: its edges are its members'
//together, its centre the middle of that box, and all its members
//move by the same amount, so the group keeps its shape. Shapes in a
//group come along; shapes outside one are left out, as above.
struct Unit {
QList<QGraphicsObject *> members;
Alignment::Item geometry;
QGraphicsObject *snap_item = nullptr; ///< lands on its grid
qreal divisor = 1;
};
QList<Unit> units;
QHash<QUuid, int> group_units;
auto unitFor = [&](QGraphicsObject *item) -> Unit &
{
const QUuid group = ItemGroups::groupOf(item);
if (group.isNull()) {
units << Unit();
return units.last();
}
if (!group_units.contains(group)) {
group_units.insert(group, units.size());
units << Unit();
}
return units[group_units.value(group)];
};
for (const Entry &entry : std::as_const(entries))
{
Unit &unit = unitFor(entry.item);
unit.members << entry.item;
unit.geometry.edges = unit.geometry.edges.isNull()
? entry.geometry.edges
: unit.geometry.edges.united(entry.geometry.edges);
unit.geometry.ref = entry.geometry.ref;
//Symbols come first in entries, so a group with one snaps on it
if (!unit.snap_item) {
unit.snap_item = entry.item;
unit.divisor = entry.divisor;
}
}
for (QetShapeItem *shape : std::as_const(dc.m_shapes))
{
if (ItemGroups::groupOf(shape).isNull())
continue;
Unit &unit = unitFor(shape);
unit.members << shape;
unit.geometry.edges = unit.geometry.edges.isNull()
? shape->sceneBoundingRect()
: unit.geometry.edges.united(shape->sceneBoundingRect());
if (!unit.snap_item)
unit.snap_item = shape;
}
for (Unit &unit : units) {
if (unit.members.size() > 1)
unit.geometry.ref = unit.geometry.edges.center();
}
m_item_count = units.size();
//Lining up a single item on itself would only snap it
if (units.size() < 2)
return;
Alignment::Edge edge = Alignment::Left;
switch (mode) {
case AlignLeft: edge = Alignment::Left; break;
case AlignHCenter: edge = Alignment::HCenter; break;
case AlignRight: edge = Alignment::Right; break;
case AlignTop: edge = Alignment::Top; break;
case AlignVCenter: edge = Alignment::VCenter; break;
case AlignBottom: edge = Alignment::Bottom; break;
case SnapToGrid: break;
}
QList<Alignment::Item> geometry;
for (const Unit &unit : std::as_const(units))
geometry << unit.geometry;
const QList<QPointF> offsets = Alignment::alignOffsets(geometry, edge);
for (int i = 0 ; i < units.size() ; ++i)
{
const Unit &unit = units.at(i);
const QPointF offset = Alignment::snappedOffset(unit.snap_item->pos(), offsets.at(i), edge,
x_grid, y_grid, unit.divisor);
for (QGraphicsObject *member : unit.members)
move(member, offset);
}
setText(QObject::tr("Aligner %n objet(s)", "", childCount()));
}
/**
@@ -117,3 +224,12 @@ int AlignSelectionCommand::lockedCount() const
{
return m_locked_count;
}
/**
@return the number of things that took part: symbols, pictures and
free texts whose position is not locked, a group counting as one.
*/
int AlignSelectionCommand::itemCount() const
{
return m_item_count;
}
+9 -1
View File
@@ -33,7 +33,13 @@ class AlignSelectionCommand : public QUndoCommand
{
public:
enum Mode {
SnapToGrid ///< put each item where a drag would have left it
SnapToGrid, ///< put each item where a drag would have left it
AlignLeft,
AlignHCenter,
AlignRight,
AlignTop,
AlignVCenter,
AlignBottom
};
AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoCommand *parent = nullptr);
@@ -44,10 +50,12 @@ class AlignSelectionCommand : public QUndoCommand
bool isValid() const;
int movedCount() const;
int lockedCount() const;
int itemCount() const;
private:
QPointer<Diagram> m_diagram;
int m_locked_count = 0;
int m_item_count = 0;
};
#endif // ALIGNSELECTIONCOMMAND_H
+110
View File
@@ -49,6 +49,116 @@ private slots:
QVERIFY(Alignment::gridOffset(on_grid + QPointF(1e-10, -1e-10), 10, 10).isNull());
}
}
// Three items: a 40x20 symbol with its origin 10 px in from its left
// edge, a 20x40 one with its origin at its centre, and a 60x10 picture
// whose reference point is its centre.
static QList<Alignment::Item> threeItems()
{
return {
{QRectF(100, 200, 40, 20), QPointF(110, 210)},
{QRectF(150, 100, 20, 40), QPointF(160, 120)},
{QRectF( 70, 300, 60, 10), QPointF(100, 305)}
};
}
void alignOffsets_data()
{
QTest::addColumn<int>("edge");
QTest::addColumn<QList<QPointF>>("expected");
QTest::newRow("left, on the left-most edge")
<< int(Alignment::Left) << QList<QPointF>{{-30, 0}, {-80, 0}, {0, 0}};
QTest::newRow("centre, on the mean origin x (123.33)")
<< int(Alignment::HCenter) << QList<QPointF>{{370.0/3 - 110, 0}, {370.0/3 - 160, 0}, {370.0/3 - 100, 0}};
QTest::newRow("right, on the right-most edge")
<< int(Alignment::Right) << QList<QPointF>{{30, 0}, {0, 0}, {40, 0}};
QTest::newRow("top, on the top-most edge")
<< int(Alignment::Top) << QList<QPointF>{{0, -100}, {0, 0}, {0, -200}};
QTest::newRow("middle, on the mean origin y (211.67)")
<< int(Alignment::VCenter) << QList<QPointF>{{0, 635.0/3 - 210}, {0, 635.0/3 - 120}, {0, 635.0/3 - 305}};
QTest::newRow("bottom, on the bottom-most edge")
<< int(Alignment::Bottom) << QList<QPointF>{{0, 90}, {0, 170}, {0, 0}};
}
void alignOffsets()
{
QFETCH(int, edge);
QFETCH(QList<QPointF>, expected);
const QList<QPointF> offsets = Alignment::alignOffsets(threeItems(), Alignment::Edge(edge));
QCOMPARE(offsets.size(), expected.size());
for (int i = 0 ; i < offsets.size() ; ++i) {
QVERIFY2(qAbs(offsets.at(i).x() - expected.at(i).x()) < 1e-9, qPrintable(QString("item %1 x %2").arg(i).arg(offsets.at(i).x())));
QVERIFY2(qAbs(offsets.at(i).y() - expected.at(i).y()) < 1e-9, qPrintable(QString("item %1 y %2").arg(i).arg(offsets.at(i).y())));
}
}
// Aligning items that are already lined up, and on the grid, moves
// nothing: the command then pushes no undo step.
void alreadyAligned()
{
const QList<Alignment::Item> items {
{QRectF(100, 200, 40, 20), QPointF(110, 210)},
{QRectF(100, 300, 40, 20), QPointF(110, 310)}
};
for (int e = Alignment::Left ; e <= Alignment::Bottom ; ++e)
{
const auto edge = Alignment::Edge(e);
if (edge == Alignment::Top || edge == Alignment::VCenter || edge == Alignment::Bottom)
continue; // they sit one above the other, so these do move
const QList<QPointF> offsets = Alignment::alignOffsets(items, edge);
for (int i = 0 ; i < items.size() ; ++i)
QVERIFY(Alignment::snappedOffset(items.at(i).ref, offsets.at(i), edge, 10, 10).isNull());
}
}
// The result lands on the grid along the aligned line, and the other
// coordinate is left alone even when it is off the grid.
void snapsOnlyAlongTheLine()
{
// raw offset puts x at 123.33: rounded to 120; y 207 stays 207
const QPointF pos(110, 207);
const QPointF offset = Alignment::snappedOffset(pos, QPointF(370.0/3 - 110, 0), Alignment::HCenter, 10, 10);
QVERIFY(qAbs(offset.x() - 10) < 1e-9);
QCOMPARE(offset.y(), 0.0);
const QPointF v = Alignment::snappedOffset(QPointF(113, 207), QPointF(0, 7), Alignment::Bottom, 10, 10);
QCOMPARE(v.x(), 0.0);
QVERIFY(qAbs(v.y() - 3) < 1e-9); // 207 + 7 = 214, rounded to 210
}
// Two symbols on the grid whose left edges are 5 px apart relative to
// their origins cannot both have their origin on the grid and their
// left edges on one line. The origins win: nothing leaves the grid.
void mixedWidthsStayOnTheGrid()
{
const QList<Alignment::Item> items {
{QRectF(95, 200, 30, 20), QPointF(110, 210)}, // origin 15 px in
{QRectF(200, 300, 20, 20), QPointF(210, 310)} // origin 10 px in
};
const QList<QPointF> offsets = Alignment::alignOffsets(items, Alignment::Left);
for (int i = 0 ; i < items.size() ; ++i)
{
const QPointF moved = items.at(i).ref + Alignment::snappedOffset(items.at(i).ref, offsets.at(i), Alignment::Left, 10, 10);
QVERIFY(Alignment::gridOffset(moved, 10, 10).isNull());
}
// the second one moves 105 px left to 105, a tie, rounded up to 110
QVERIFY(qAbs(Alignment::snappedOffset(items.at(1).ref, offsets.at(1), Alignment::Left, 10, 10).x() + 100) < 1e-9);
}
// Free texts go to the text grid, which can be finer than the folio's.
void textGridDivisor()
{
const QPointF offset = Alignment::snappedOffset(QPointF(103, 50), QPointF(0, 0), Alignment::Left, 10, 10, 2.0);
QVERIFY(qAbs(offset.x() - 2) < 1e-9); // 103 -> 105 on a 5 px text grid
QCOMPARE(offset.y(), 0.0);
}
void emptySelection()
{
QVERIFY(Alignment::alignOffsets({}, Alignment::Left).isEmpty());
}
};
QTEST_APPLESS_MAIN(tst_alignment)