Merge pull request #1435 from bhangart/fix/line-pivot-follows-resize

Keep a line's pivot at its centre when one of its ends is dragged
This commit is contained in:
ispyisail
2026-10-13 16:29:20 +13:00
committed by GitHub
6 changed files with 481 additions and 17 deletions
+30 -17
View File
@@ -146,6 +146,25 @@ endif()
# als laatse
include(cmake/define_definitions.cmake)
# The application's code, compiled once, as an object library: the
# executable is main.cpp linked with it, and tests that need the real
# classes (a folio, its view, the editor window) link it too.
set(QET_MAIN_FILE ${QET_DIR}/sources/main.cpp)
list(REMOVE_ITEM QET_SRC_FILES ${QET_MAIN_FILE})
add_library(
${PROJECT_NAME}_objects
OBJECT
${QET_RES_FILES}
${QET_SRC_FILES}
${QET_DIR}/qelectrotech.qrc
${QET_DIR}/ico/icon-themes.qrc
)
# The definitions above are set for this whole directory; a test target
# lives in tests/ and gets them from here, through the library.
get_directory_property(QET_DIRECTORY_DEFINITIONS COMPILE_DEFINITIONS)
target_compile_definitions(${PROJECT_NAME}_objects
INTERFACE ${QET_DIRECTORY_DEFINITIONS})
# On Windows, WIN32 sets /SUBSYSTEM:WINDOWS to suppress the console window.
# Qt automatically links qtmain.lib which provides the WinMain entry point,
# so no source code change is needed.
@@ -153,11 +172,8 @@ if(WIN32)
add_executable(
${PROJECT_NAME}
WIN32
${QET_RES_FILES}
${QET_SRC_FILES}
${QET_MAIN_FILE}
${QM_FILES}
${QET_DIR}/qelectrotech.qrc
${QET_DIR}/ico/icon-themes.qrc
)
# Our own manifest (it declares longPathAware). MSVC merges a .manifest
# listed as a source; MinGW needs it embedded through a resource file,
@@ -174,13 +190,11 @@ if(WIN32)
else()
add_executable(
${PROJECT_NAME}
${QET_RES_FILES}
${QET_SRC_FILES}
${QET_MAIN_FILE}
${QM_FILES}
${QET_DIR}/qelectrotech.qrc
${QET_DIR}/ico/icon-themes.qrc
)
endif()
target_link_libraries(${PROJECT_NAME} PRIVATE ${PROJECT_NAME}_objects)
if(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED)
list(APPEND QET_SPACEMOUSE_LIBRARIES PkgConfig::SPNAV)
@@ -249,7 +263,7 @@ elseif(Qt6_VERSION VERSION_LESS "6.7")
# Automatically creates the update_translations umbrella target.
qt_add_lupdate(
${PROJECT_NAME}
${PROJECT_NAME}_objects
TS_FILES ${TS_FILES}
)
@@ -262,7 +276,7 @@ else()
)
qt_add_lupdate(
SOURCE_TARGETS ${PROJECT_NAME}
SOURCE_TARGETS ${PROJECT_NAME}_objects
TS_FILES ${TS_FILES}
LUPDATE_TARGET update_translations
NO_GLOBAL_TARGET
@@ -288,7 +302,7 @@ if(QET_ENABLE_PCH)
"QET_ENABLE_PCH needs CMake 3.16 or newer (found ${CMAKE_VERSION}); "
"building without precompiled headers.")
else()
target_precompile_headers(${PROJECT_NAME} PRIVATE
target_precompile_headers(${PROJECT_NAME}_objects PRIVATE
"$<$<COMPILE_LANGUAGE:CXX>:<QtCore/QtCore$<ANGLE-R>>"
"$<$<COMPILE_LANGUAGE:CXX>:<QtGui/QtGui$<ANGLE-R>>"
"$<$<COMPILE_LANGUAGE:CXX>:<QtWidgets/QtWidgets$<ANGLE-R>>"
@@ -299,9 +313,8 @@ if(QET_ENABLE_PCH)
endif()
target_link_libraries(
${PROJECT_NAME}
${PROJECT_NAME}_objects
PUBLIC
PRIVATE
pugixml::pugixml
SingleApplication::SingleApplication
SQLite3::SQLite3
@@ -311,8 +324,8 @@ target_link_libraries(
)
target_include_directories(
${PROJECT_NAME}
PRIVATE
${PROJECT_NAME}_objects
PUBLIC
${QET_DIR}/sources/titleblock
${QET_DIR}/sources/ui
${QET_DIR}/sources/qetgraphicsitem
@@ -350,8 +363,8 @@ target_include_directories(
if(NOT BUILD_WITH_KF)
target_include_directories(
${PROJECT_NAME}
PRIVATE
${PROJECT_NAME}_objects
PUBLIC
${QET_DIR}/sources/ui/nokde
)
endif()
+4
View File
@@ -2465,6 +2465,10 @@ void QetShapeItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardMo
{
prepareGeometryChange();
(index == 0 ? m_P1 : m_P2) = localPos;
// The same rule as setLine() and setRect(): a pivot nobody placed
// by hand follows the centre.
if (!m_pivotIsCustom)
resetPivotToBoundingRectCenter();
repositionHandles();
emit geometryChanged();
return;
+20
View File
@@ -244,6 +244,22 @@ set_tests_properties(tst_handlersize PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=off
target_include_directories(tst_handlersize PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_handlersize PRIVATE Qt::Test Qt::Widgets)
# Tests that need the application around the class they test -- a folio,
# its view, the editor window and its actions -- link the application's own
# code (the qelectrotech_objects library) and run a real QETApp offscreen,
# with settings and folders of their own (qetapptest.h).
function(qet_add_app_test name)
add_executable(${name} ${ARGN} qetapptest.h)
add_test(NAME ${name} COMMAND ${name})
set_tests_properties(${name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
target_include_directories(${name} PRIVATE ${QET_DIR}/sources)
target_link_libraries(${name} PRIVATE qelectrotech_objects Qt::Test)
endfunction()
# Space on a picture turns its own transform, which the file saves (#1413),
# alone and with a group.
qet_add_app_test(tst_rotateselectionimage tst_rotateselectionimage.cpp)
# TextResizeHandles: the corner handles that change the width of a text box,
# on a plain QGraphicsTextItem with the "textWidth" property QET texts have.
add_executable(
@@ -256,6 +272,10 @@ set_tests_properties(tst_textresizehandles PROPERTIES ENVIRONMENT "QT_QPA_PLATFO
target_include_directories(tst_textresizehandles PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_textresizehandles PRIVATE Qt::Test Qt::Widgets)
# A line's pivot follows its centre when one of its ends is dragged, as a
# rectangle's does; a pivot placed by hand stays.
qet_add_app_test(tst_linepivotresize tst_linepivotresize.cpp)
# legacyelementtypes.h is header-only: the rewrite of element type names in
# nomenclature queries saved before June 2022.
add_executable(tst_legacyelementtypes tst_legacyelementtypes.cpp)
+112
View File
@@ -0,0 +1,112 @@
/*
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 QETAPPTEST_H
#define QETAPPTEST_H
/**
For tests that need the application around the class they test: a
folio, the view showing it, the editor window and its actions. They
link the application's own code (see qet_add_app_test() in
CMakeLists.txt) and run a real QETApp, offscreen, with its settings,
configuration and data in a folder of their own.
*/
#include "diagram.h"
#include "diagramview.h"
#include "qetapp.h"
#include "qetarguments.h"
#include "qetdiagrameditor.h"
#include <QAction>
#include <QApplication>
#include <QCoreApplication>
#include <QSettings>
#include <QTemporaryDir>
#include <QtTest>
namespace QetAppTest {
/// The editor window QETApp opened at start.
inline QETDiagramEditor *editor()
{
const QList<QETDiagramEditor *> editors = QETApp::diagramEditors();
return editors.isEmpty() ? nullptr : editors.first();
}
/// The view of the folio shown in the editor, after a new project of one
/// folio if the editor has none open.
inline DiagramView *view()
{
QETDiagramEditor *e = editor();
if (!e)
return nullptr;
if (!e->findChild<DiagramView *>())
e->newProject();
return e->findChild<DiagramView *>();
}
inline Diagram *diagram()
{
DiagramView *v = view();
return v ? v->diagram() : nullptr;
}
/// The editor's action whose data() is @p name, as the menus, the
/// toolbar and the keyboard shortcut trigger it ("rotate_selection" is
/// Space).
inline QAction *action(const QString &name)
{
QETDiagramEditor *e = editor();
if (!e)
return nullptr;
for (QAction *a : e->findChildren<QAction *>())
if (a->data().toString() == name)
return a;
return nullptr;
}
/// Run @p test inside a QETApp whose settings, configuration and data
/// live in a temporary folder, so that no test reads or changes the
/// user's own.
template <typename Test>
int run(int argc, char **argv)
{
QApplication app(argc, argv);
QCoreApplication::setOrganizationName("QElectroTech");
QCoreApplication::setApplicationName("QElectroTech");
QTemporaryDir home;
if (!home.isValid())
return 1;
QSettings::setDefaultFormat(QSettings::IniFormat);
QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, home.filePath("settings"));
QSettings::setPath(QSettings::IniFormat, QSettings::SystemScope, home.filePath("settings"));
QETApp::applyDirectoryArguments(QETArguments(QList<QString>{
"--config-dir=" + home.filePath("config"),
"--data-dir=" + home.filePath("data")}));
QETApp qetapp;
QCoreApplication::processEvents();
Test test;
return QTest::qExec(&test, argc, argv);
}
} // namespace QetAppTest
#define QET_APP_TEST_MAIN(Test) \
int main(int argc, char **argv) { return QetAppTest::run<Test>(argc, argv); }
#endif
+163
View File
@@ -0,0 +1,163 @@
/*
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 "qetapptest.h"
#include "QetGraphicsItemModeler/qetgraphicshandleritem.h"
#include "qetgraphicsitem/qetshapeitem.h"
#include <QGraphicsSceneMouseEvent>
/**
A shape's pivot follows its centre when it is resized, unless it was
placed by hand. Rectangles and ellipses did; a line did not: dragging
one of its ends moved the end and left the pivot where it was, so the
next rotation turned the line around a point beside it.
The handles are pressed and dragged with scene mouse events, as the
view sends them.
*/
class tst_linepivotresize : public QObject
{
Q_OBJECT
Diagram *m_diagram = nullptr;
QetShapeItem *m_shape = nullptr;
void send(QGraphicsItem *item, QEvent::Type type, const QPointF &scenePos)
{
QGraphicsSceneMouseEvent event(type);
event.setButton(Qt::LeftButton);
event.setButtons(type == QEvent::GraphicsSceneMouseRelease ? Qt::NoButton : Qt::LeftButton);
event.setScenePos(scenePos);
event.setPos(item->mapFromScene(scenePos));
event.setButtonDownScenePos(Qt::LeftButton, scenePos);
m_diagram->sendEvent(item, &event);
}
QGraphicsItem *handleAt(const QPointF &scenePos)
{
QGraphicsItem *best = nullptr;
qreal bestDistance = 0;
for (QGraphicsItem *item : m_diagram->items())
{
if (item->type() != QetGraphicsHandlerItem::Type)
continue;
const qreal d = QLineF(item->scenePos(), scenePos).length();
if (!best || d < bestDistance)
{
best = item;
bestDistance = d;
}
}
return best;
}
void drag(QGraphicsItem *handle, const QPointF &delta)
{
QVERIFY(handle);
const QPointF from = handle->scenePos();
send(handle, QEvent::GraphicsSceneMousePress, from);
send(handle, QEvent::GraphicsSceneMouseMove, from + delta / 2);
send(handle, QEvent::GraphicsSceneMouseMove, from + delta);
send(handle, QEvent::GraphicsSceneMouseRelease, from + delta);
}
/// A click on the selected shape: the next handle mode.
void click(const QPointF &local)
{
const QPointF at = m_shape->mapToScene(local);
send(m_shape, QEvent::GraphicsSceneMousePress, at);
send(m_shape, QEvent::GraphicsSceneMouseRelease, at);
}
QetShapeItem *add(QetShapeItem::ShapeType type, const QPointF &p1, const QPointF &p2)
{
auto *shape = new QetShapeItem(p1, p2, type);
m_diagram->addItem(shape);
shape->setPos(200, 200);
shape->setSelected(true);
return shape;
}
private slots:
void initTestCase()
{
QVERIFY(QetAppTest::editor());
m_diagram = QetAppTest::diagram();
QVERIFY(m_diagram);
}
void cleanup()
{
m_diagram->clearSelection();
delete m_shape;
m_shape = nullptr;
m_diagram->undoStack().clear();
}
void lineEndDragKeepsThePivotAtTheCentre()
{
m_shape = add(QetShapeItem::Line, QPointF(0, 0), QPointF(100, 0));
QCOMPARE(m_shape->pivot(), QPointF(50, 0));
drag(handleAt(m_shape->mapToScene(QPointF(100, 0))), QPointF(40, 30));
QCOMPARE(m_shape->line().p2(), QPointF(140, 30));
QCOMPARE(m_shape->pivot(), m_shape->line().center());
QVERIFY(!m_shape->pivotIsCustom());
}
void undoAndRedoKeepItAtTheCentre()
{
m_shape = add(QetShapeItem::Line, QPointF(0, 0), QPointF(100, 0));
drag(handleAt(m_shape->mapToScene(QPointF(100, 0))), QPointF(40, 30));
for (int round = 0; round < 2; ++round)
{
m_diagram->undoStack().undo();
QCOMPARE(m_shape->line().p2(), QPointF(100, 0));
QCOMPARE(m_shape->pivot(), QPointF(50, 0));
m_diagram->undoStack().redo();
QCOMPARE(m_shape->pivot(), m_shape->line().center());
}
}
void handPlacedPivotStays()
{
m_shape = add(QetShapeItem::Line, QPointF(0, 0), QPointF(100, 0));
click(QPointF(25, 0)); // rotate/skew mode
drag(handleAt(m_shape->mapToScene(m_shape->pivot())), QPointF(20, 10));
QVERIFY(m_shape->pivotIsCustom());
const QPointF pivot = m_shape->pivot();
click(QPointF(25, 0)); // back to resize mode
drag(handleAt(m_shape->mapToScene(QPointF(100, 0))), QPointF(40, 30));
QCOMPARE(m_shape->line().p2(), QPointF(140, 30));
QCOMPARE(m_shape->pivot(), pivot);
}
// What a line now does, as a rectangle always did.
void rectangleResizeKeepsThePivotAtTheCentre()
{
m_shape = add(QetShapeItem::Rectangle, QPointF(0, 0), QPointF(100, 60));
drag(handleAt(m_shape->mapToScene(QPointF(100, 60))), QPointF(40, 30));
QCOMPARE(m_shape->pivot(), QRectF(QPointF(0, 0), QPointF(140, 90)).center());
}
};
QET_APP_TEST_MAIN(tst_linepivotresize)
#include "tst_linepivotresize.moc"
+152
View File
@@ -0,0 +1,152 @@
/*
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 "qetapptest.h"
#include "qetgraphicsitem/diagramimageitem.h"
#include <QDomDocument>
/**
Space -- the editor's "rotate_selection" action -- on a picture turns
the picture's own transform (rotationAngle, around its pivot), the
rotation its file saves. Before #1413 it turned QGraphicsItem's plain
rotation, which toXml() does not save, so the picture came back
unrotated once the project was reopened.
*/
class tst_rotateselectionimage : public QObject
{
Q_OBJECT
Diagram *m_diagram = nullptr;
DiagramImageItem *addPicture(const QPointF &pos)
{
QPixmap pixmap(100, 60);
pixmap.fill(Qt::darkGreen);
auto *picture = new DiagramImageItem(pixmap);
m_diagram->addItem(picture);
picture->setPos(pos);
return picture;
}
static QPointF pivotInScene(DiagramImageItem *picture)
{
return picture->mapToScene(picture->pivot());
}
/// The angle and position a file saves, read back by a new picture.
static DiagramImageItem *reloaded(DiagramImageItem *picture)
{
QDomDocument document;
auto *copy = new DiagramImageItem();
copy->fromXml(picture->toXml(document));
return copy;
}
static void trigger(const char *name)
{
QAction *a = QetAppTest::action(QString::fromLatin1(name));
QVERIFY2(a, name);
a->trigger();
}
private slots:
void initTestCase()
{
QVERIFY(QetAppTest::editor());
m_diagram = QetAppTest::diagram();
QVERIFY(m_diagram);
}
void cleanup()
{
m_diagram->clearSelection();
for (QGraphicsItem *item : m_diagram->items())
if (item->type() == DiagramImageItem::Type)
delete item;
m_diagram->undoStack().clear();
}
void spaceTurnsThePictureAndTheFileKeepsIt()
{
DiagramImageItem *picture = addPicture(QPointF(200, 200));
const QPointF pivot = pivotInScene(picture);
picture->setSelected(true);
trigger("rotate_selection");
trigger("rotate_selection");
QCOMPARE(picture->rotationAngle(), 180.0);
QCOMPARE(picture->rotation(), 0.0); // not QGraphicsItem's own rotation
QCOMPARE(pivotInScene(picture), pivot); // turned in place
std::unique_ptr<DiagramImageItem> copy(reloaded(picture));
QCOMPARE(copy->rotationAngle(), 180.0);
QCOMPARE(copy->pos(), picture->pos());
}
void undoAndRedo()
{
DiagramImageItem *picture = addPicture(QPointF(200, 200));
const QPointF pos = picture->pos();
picture->setSelected(true);
trigger("rotate_selection");
// RotateSelectionCommand animates undo and redo after the first
// time: the values arrive a moment later.
for (int round = 0; round < 2; ++round)
{
m_diagram->undoStack().undo();
QTRY_COMPARE(picture->rotationAngle(), 0.0);
QCOMPARE(picture->pos(), pos);
m_diagram->undoStack().redo();
QTRY_COMPARE(picture->rotationAngle(), 90.0);
QCOMPARE(picture->pos(), pos);
}
}
// Turned as a group, each picture turns on itself and its pivot moves
// a quarter turn around the group's centre (the centre of the
// selection's bounding box, snapped to the grid).
void groupTurnMovesEachPivotAroundTheCentre()
{
DiagramImageItem *a = addPicture(QPointF(100, 100));
DiagramImageItem *b = addPicture(QPointF(400, 160));
a->setSelected(true);
b->setSelected(true);
const QPointF centre = Diagram::snapToGrid(
(a->sceneBoundingRect() | b->sceneBoundingRect()).center());
const QTransform quarter = QTransform().translate(centre.x(), centre.y())
.rotate(90).translate(-centre.x(), -centre.y());
const QPointF expectedA = quarter.map(pivotInScene(a));
const QPointF expectedB = quarter.map(pivotInScene(b));
trigger("rotate_group_selection");
QCOMPARE(a->rotationAngle(), 90.0);
QCOMPARE(b->rotationAngle(), 90.0);
QCOMPARE(pivotInScene(a), expectedA);
QCOMPARE(pivotInScene(b), expectedB);
std::unique_ptr<DiagramImageItem> copy(reloaded(a));
QCOMPARE(copy->rotationAngle(), 90.0);
QCOMPARE(pivotInScene(copy.get()) - copy->pos(), pivotInScene(a) - a->pos());
}
};
QET_APP_TEST_MAIN(tst_rotateselectionimage)
#include "tst_rotateselectionimage.moc"