diff --git a/sources/alignment.h b/sources/alignment.h index fba24c56b..adc533877 100644 --- a/sources/alignment.h +++ b/sources/alignment.h @@ -20,7 +20,11 @@ #include "textgrid.h" +#include #include +#include + +#include /** 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 alignOffsets(const QList &items, Edge edge) + { + QList 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 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 diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 0f47f63a3..d65d94b95 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -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 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 align_actions = m_align_actions_group.actions(); + for (QAction *action : align_actions.mid(1)) + action->setEnabled(!ro && alignable >= 2); } /** diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h index 7509fa137..570d06770 100644 --- a/sources/qetgraphicsitem/diagramimageitem.h +++ b/sources/qetgraphicsitem/diagramimageitem.h @@ -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(); diff --git a/sources/undocommand/alignselectioncommand.cpp b/sources/undocommand/alignselectioncommand.cpp index 28eb62a93..1efa08dfd 100644 --- a/sources/undocommand/alignselectioncommand.cpp +++ b/sources/undocommand/alignselectioncommand.cpp @@ -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 #include /** @@ -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 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 members; + Alignment::Item geometry; + QGraphicsObject *snap_item = nullptr; ///< lands on its grid + qreal divisor = 1; + }; + QList units; + QHash 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 geometry; + for (const Unit &unit : std::as_const(units)) + geometry << unit.geometry; + const QList 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; +} diff --git a/sources/undocommand/alignselectioncommand.h b/sources/undocommand/alignselectioncommand.h index 1162077f8..de782ee4e 100644 --- a/sources/undocommand/alignselectioncommand.h +++ b/sources/undocommand/alignselectioncommand.h @@ -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 m_diagram; int m_locked_count = 0; + int m_item_count = 0; }; #endif // ALIGNSELECTIONCOMMAND_H diff --git a/tests/qttest/tst_alignment.cpp b/tests/qttest/tst_alignment.cpp index 1aa70c239..f491ec439 100644 --- a/tests/qttest/tst_alignment.cpp +++ b/tests/qttest/tst_alignment.cpp @@ -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 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("edge"); + QTest::addColumn>("expected"); + + QTest::newRow("left, on the left-most edge") + << int(Alignment::Left) << QList{{-30, 0}, {-80, 0}, {0, 0}}; + QTest::newRow("centre, on the mean origin x (123.33)") + << int(Alignment::HCenter) << QList{{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{{30, 0}, {0, 0}, {40, 0}}; + QTest::newRow("top, on the top-most edge") + << int(Alignment::Top) << QList{{0, -100}, {0, 0}, {0, -200}}; + QTest::newRow("middle, on the mean origin y (211.67)") + << int(Alignment::VCenter) << QList{{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{{0, 90}, {0, 170}, {0, 0}}; + } + + void alignOffsets() + { + QFETCH(int, edge); + QFETCH(QList, expected); + + const QList 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 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 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 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 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)