From 3c4d23bb4e25a5b3f1e9127e2e18852a9892bca2 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 06:55:57 +1300 Subject: [PATCH] Add Align left/centre/right/top/middle/bottom for the selected items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit > Aligner gains six commands, also in the selection's context menu and the command search: Aligner à gauche, Centrer horizontalement, Aligner à droite, Aligner en haut, Centrer verticalement, Aligner en bas. One undo step; wires follow their symbols. Second stage of discussion #1069, on top of "Aligner sur la grille". Left/right/top/bottom line the edges up on the outermost one. The two centre commands line the items up on the mean of their centres, and a symbol's centre is its origin point, not the middle of its drawing: in the collection, vertical two-terminal symbols almost always have their terminals on the origin's axis, so this puts their wires on one line. A picture is aligned by the picture itself, without its caption (imageRect() becomes public for this). A group (#1070) lines up as one piece: its edges are its members' together, and every member moves by the same amount, so the group keeps its shape; shapes inside a group come along. Each item moves only across the line it is aligned on, and lands on the grid its drag uses, so aligning never takes a symbol off the grid. Two symbols whose edges sit at different distances from their origins cannot both be exactly on the line and on the grid; they end up within half a grid step of it. The commands need two items (Aligner sur la grille still needs one). Locked items stay put and the status bar says so. If nothing moves, no undo step is pushed and the status bar says the selection is already aligned as far as the grid allows. The geometry is in alignment.h, tested by tst_alignment. Co-Authored-By: Claude Opus 5.5 --- sources/alignment.h | 101 +++++++++++++ sources/qetdiagrameditor.cpp | 70 +++++++-- sources/qetgraphicsitem/diagramimageitem.h | 2 +- sources/undocommand/alignselectioncommand.cpp | 142 ++++++++++++++++-- sources/undocommand/alignselectioncommand.h | 10 +- tests/qttest/tst_alignment.cpp | 110 ++++++++++++++ 6 files changed, 411 insertions(+), 24 deletions(-) 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)