diff --git a/sources/qetgraphicsitem/dynamicelementtextitem.cpp b/sources/qetgraphicsitem/dynamicelementtextitem.cpp
index bb393c35a..e07d72294 100644
--- a/sources/qetgraphicsitem/dynamicelementtextitem.cpp
+++ b/sources/qetgraphicsitem/dynamicelementtextitem.cpp
@@ -16,6 +16,9 @@
along with QElectroTech. If not, see .
*/
#include "dynamicelementtextitem.h"
+#include
+#include
+#include "../QetGraphicsItemModeler/textresizehandles.h"
#include "../shownkinds.h"
#include "../qetproject.h"
#include "../QPropertyUndoCommand/qpropertyundocommand.h"
@@ -1050,7 +1053,53 @@ void DynamicElementTextItem::handlerMousePressEvent(QetGraphicsHandlerItem *hand
//A concrete baseline for the live drag's delta math, which can't
//start from -1.
m_resize_baseline_width = (m_resize_original_width < 0) ? frameRect().width() : m_resize_original_width;
- m_resize_start_local_x = mapFromScene(event->scenePos()).x();
+ //The text moves while it is dragged, to keep its alignment point
+ //in place: read the mouse in the text's coordinates as they were
+ //at the press, otherwise the delta grows with every step
+ m_resize_scene_to_local = sceneTransform().inverted();
+ m_resize_start_local_x = m_resize_scene_to_local.map(event->scenePos()).x();
+
+ //The point the text is aligned on stays where it is: remember it
+ //in the parent's coordinates, which take the text's rotation
+ //into account
+ m_resize_start_pos = pos();
+ m_resize_fixed_parent = mapToParent(resizeAnchor());
+}
+
+/**
+ @brief DynamicElementTextItem::minimumTextWidth
+ @return the narrowest width the text can be dragged to: that of its
+ widest word. Narrower, a long word runs out of the text's box while
+ the short lines are still aligned inside it, and a centred or right
+ aligned text falls apart (a lone "1" in the middle under its words).
+*/
+qreal DynamicElementTextItem::minimumTextWidth() const
+{
+ QScopedPointer narrowest(document()->clone());
+ narrowest->setTextWidth(0);
+ return qMax(narrowest->idealWidth(), qreal(10));
+}
+
+/**
+ @brief DynamicElementTextItem::resizeAnchor
+ @return the point of the text, in its own coordinates, that a drag on
+ a width handle keeps in place: the point it is aligned on, one of the
+ nine of its alignment (left, centre or right; top, middle or bottom).
+ A text aligned top right keeps its top right corner whichever handle
+ is dragged, and a text that wraps onto more lines while it narrows
+ grows away from that point instead of moving into its neighbours.
+*/
+QPointF DynamicElementTextItem::resizeAnchor() const
+{
+ const QRectF br = boundingRect();
+ const Qt::Alignment a = alignment();
+ const qreal x = (a & Qt::AlignRight) ? br.right()
+ : (a & Qt::AlignHCenter) ? br.center().x()
+ : br.left();
+ const qreal y = (a & Qt::AlignBottom) ? br.bottom()
+ : (a & Qt::AlignVCenter) ? br.center().y()
+ : br.top();
+ return QPointF(x, y);
}
/**
@@ -1065,21 +1114,26 @@ void DynamicElementTextItem::handlerMousePressEvent(QetGraphicsHandlerItem *hand
*/
void DynamicElementTextItem::handlerMouseMoveEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event)
{
- qreal local_x = mapFromScene(event->scenePos()).x();
+ qreal local_x = m_resize_scene_to_local.map(event->scenePos()).x();
qreal delta = local_x - m_resize_start_local_x;
if (handle == m_left_resize_handle)
delta = -delta;
- qreal new_width = qMax(m_resize_baseline_width + delta, qreal(10));
+ qreal new_width = qMax(m_resize_baseline_width + delta, minimumTextWidth());
setTextWidth(new_width);
+
+ //Keep the point the text is aligned on in place, whatever the new
+ //width and number of lines
+ setPos(pos() + m_resize_fixed_parent - mapToParent(resizeAnchor()));
updateResizeHandlesPos();
}
/**
@brief DynamicElementTextItem::handlerMouseReleaseEvent
- Push the same QPropertyUndoCommand the properties-panel width spinbox
- already pushes (sources/ui/dynamicelementtextmodel.cpp) -- the value is
- already applied live from the drag, so this only makes it undoable.
+ Make the drag undoable with a TextResizeCommand, as the corner handles
+ of a free text do: the width and, after a drag on the left handle, the
+ position that kept the right edge in place. Both are already applied,
+ live from the drag.
@param handle
@param event
*/
@@ -1089,10 +1143,13 @@ void DynamicElementTextItem::handlerMouseReleaseEvent(QetGraphicsHandlerItem *ha
Q_UNUSED(event)
qreal new_width = textWidth();
- if (!qFuzzyCompare(m_resize_original_width, new_width) && m_parent_element && m_parent_element->diagram())
+ if ((!qFuzzyCompare(m_resize_original_width, new_width) || pos() != m_resize_start_pos)
+ && m_parent_element && m_parent_element->diagram())
{
- auto *undo = new QPropertyUndoCommand(this, "textWidth", QVariant(m_resize_original_width), QVariant(new_width));
- undo->setAnimated(true, false);
+ //Width and position together: undoing a drag on the left
+ //handle puts the text back where it was
+ auto *undo = new TextResizeCommand(this, m_resize_original_width, new_width,
+ m_resize_start_pos, pos());
undo->setText(tr("Resize an element text"));
m_parent_element->diagram()->undoStack().push(undo);
}
diff --git a/sources/qetgraphicsitem/dynamicelementtextitem.h b/sources/qetgraphicsitem/dynamicelementtextitem.h
index eb7cd8f09..3bc2a0afc 100644
--- a/sources/qetgraphicsitem/dynamicelementtextitem.h
+++ b/sources/qetgraphicsitem/dynamicelementtextitem.h
@@ -170,6 +170,8 @@ class DynamicElementTextItem : public DiagramTextItem
void addResizeHandles();
void removeResizeHandles();
void updateResizeHandlesPos();
+ QPointF resizeAnchor() const;
+ qreal minimumTextWidth() const;
void fitAutoTextWidth();
void handlerMousePressEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event);
void handlerMouseMoveEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event);
@@ -213,6 +215,9 @@ class DynamicElementTextItem : public DiagramTextItem
qreal m_resize_original_width = -1;
qreal m_resize_baseline_width = -1;
qreal m_resize_start_local_x = 0;
+ QPointF m_resize_start_pos,
+ m_resize_fixed_parent;
+ QTransform m_resize_scene_to_local;
QMetaObject::Connection m_resize_handles_con;
};
diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt
index ce15d0f30..4ca44ded7 100644
--- a/tests/qttest/CMakeLists.txt
+++ b/tests/qttest/CMakeLists.txt
@@ -130,6 +130,15 @@ if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja")
--source ${CMAKE_CURRENT_SOURCE_DIR}/image_dark_integration_probe.cpp
--fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet)
set_tests_properties(tst_darkimageintegration PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE)
+ # Dragging the left width handle of a symbol's text keeps its right
+ # edge in place, the right handle its left edge; undo restores both.
+ add_test(NAME tst_elementtextresizeintegration
+ COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_element_text_resize_probe.py
+ --build ${CMAKE_BINARY_DIR} --binary $
+ --source ${CMAKE_CURRENT_SOURCE_DIR}/element_text_resize_probe.cpp
+ --fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet)
+ set_tests_properties(tst_elementtextresizeintegration PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE
+ ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
endif()
add_executable(${PROJECT_NAME} tst_mytest.cpp)
diff --git a/tests/qttest/element_text_resize_probe.cpp b/tests/qttest/element_text_resize_probe.cpp
new file mode 100644
index 000000000..965efd5ad
--- /dev/null
+++ b/tests/qttest/element_text_resize_probe.cpp
@@ -0,0 +1,204 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Width handles of a symbol's text, linked against the application's objects.
+//
+// Dragging a width handle of a symbol's text changes its width and keeps
+// the point the text is aligned on in place, one of the nine of its
+// alignment: a text aligned top right keeps its top right corner, one
+// centred its middle. Before, the text always kept its left edge, so a
+// drag on the left handle moved the right edge and the text jumped. A
+// text narrowed until it wraps grows away from that point. Undo puts back
+// the width and the position together. Checked for four alignments, on
+// an upright and on a rotated text.
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "../../sources/diagram.h"
+#include "../../sources/qetproject.h"
+#include "../../sources/qetmessagebox.h"
+#include "../../sources/qetgraphicsitem/element.h"
+#include "../../sources/qetgraphicsitem/dynamicelementtextitem.h"
+#include "../../sources/QetGraphicsItemModeler/qetgraphicshandleritem.h"
+
+static void check(bool value, const char *message)
+{
+ if (!value) throw std::runtime_error(message);
+}
+
+static bool near(const QPointF &a, const QPointF &b)
+{
+ return qAbs(a.x() - b.x()) < 0.01 && qAbs(a.y() - b.y()) < 0.01;
+}
+
+static void mouse(Diagram *scene, QGraphicsItem *item, QEvent::Type type, const QPointF &point)
+{
+ QGraphicsSceneMouseEvent event(type);
+ event.setButton(Qt::LeftButton);
+ event.setButtons(type == QEvent::GraphicsSceneMouseRelease ? Qt::NoButton : Qt::LeftButton);
+ event.setScenePos(point);
+ event.setPos(item->mapFromScene(point));
+ scene->sendEvent(item, &event);
+}
+
+/// The left or right width handle of text, by where it sits on the text.
+static QetGraphicsHandlerItem *handle(DynamicElementTextItem *text, bool left)
+{
+ QetGraphicsHandlerItem *found = nullptr;
+ for (QGraphicsItem *child : text->childItems()) {
+ if (child->type() != QetGraphicsHandlerItem::Type) continue;
+ const qreal x = child->pos().x();
+ if (!found || (left ? x < found->pos().x() : x > found->pos().x()))
+ found = static_cast(child);
+ }
+ check(found, "the selected symbol's text shows its width handles");
+ return found;
+}
+
+/// The point the text is aligned on, one of its nine, in the folio.
+static QPointF anchor(DynamicElementTextItem *text)
+{
+ const QRectF br = text->boundingRect();
+ const Qt::Alignment a = text->alignment();
+ const qreal x = (a & Qt::AlignRight) ? br.right() : (a & Qt::AlignHCenter) ? br.center().x() : br.left();
+ const qreal y = (a & Qt::AlignBottom) ? br.bottom() : (a & Qt::AlignVCenter) ? br.center().y() : br.top();
+ return text->mapToScene(QPointF(x, y));
+}
+
+/// Drag one handle by dx along the text's own baseline, in 30 steps as a
+/// mouse does: the text moves on every step to keep its alignment point,
+/// so each step must be read the same way as the first.
+static void drag(Diagram *scene, DynamicElementTextItem *text, bool left, qreal dx)
+{
+ QetGraphicsHandlerItem *h = handle(text, left);
+ const QPointF start = h->scenePos();
+ const QPointF end = start + text->mapToScene(QPointF(dx, 0)) - text->mapToScene(QPointF(0, 0));
+ mouse(scene, h, QEvent::GraphicsSceneMousePress, start);
+ for (int i = 1; i <= 30; ++i)
+ mouse(scene, h, QEvent::GraphicsSceneMouseMove, start + (end - start) * i / 30);
+ mouse(scene, h, QEvent::GraphicsSceneMouseRelease, end);
+}
+
+/// Every line lies inside the text's width: none runs out of it.
+static bool linesInside(DynamicElementTextItem *text)
+{
+ QTextDocument *doc = text->document();
+ const qreal inner = doc->textWidth() - 2 * doc->documentMargin();
+ for (QTextBlock b = doc->begin(); b.isValid(); b = b.next())
+ for (int i = 0; i < b.layout()->lineCount(); ++i) {
+ const QTextLine line = b.layout()->lineAt(i);
+ if (line.x() < -0.01 || line.x() + line.naturalTextWidth() > inner + 0.01)
+ return false;
+ }
+ return true;
+}
+
+/// The width changed by exactly as much as the mouse moved.
+static void checkGrowth(DynamicElementTextItem *text, qreal before, qreal moved, const char *message)
+{
+ check(qAbs(text->textWidth() - before - moved) < 0.5, message);
+}
+
+static void checkText(Diagram *scene, Element *element, DynamicElementTextItem *text, Qt::Alignment alignment)
+{
+ text->setTextWidth(-1);
+ text->setAlignment(alignment);
+ element->setSelected(true);
+ const QPointF pos = text->pos();
+ const qreal width = text->boundingRect().width();
+ const QPointF fixed = anchor(text);
+ const qreal frame = text->frameRect().width();
+
+ // Left handle 30 outwards: wider, the alignment point where it was.
+ drag(scene, text, true, -30);
+ checkGrowth(text, frame, 30, "the left handle widens the text by as much as the mouse moved");
+ check(near(anchor(text), fixed), "dragging the left handle keeps the alignment point in place");
+ scene->undoStack().undo();
+ check(near(text->pos(), pos), "undo puts the text back where it was");
+ check(qAbs(text->boundingRect().width() - width) < 0.01, "undo puts the width back");
+ scene->undoStack().redo();
+ check(near(anchor(text), fixed), "redo keeps the alignment point in place");
+ scene->undoStack().undo();
+
+ // Right handle 30 outwards: the same point stays.
+ element->setSelected(true);
+ drag(scene, text, false, 30);
+ checkGrowth(text, frame, 30, "the right handle widens the text by as much as the mouse moved");
+ check(near(anchor(text), fixed), "dragging the right handle keeps the alignment point in place");
+ scene->undoStack().undo();
+ check(near(text->pos(), pos), "undo of the right handle puts the text back");
+
+ // Narrowed until it wraps onto more lines: it grows away from the
+ // alignment point, a text aligned at the top downwards, so that it
+ // does not move into what stands above it.
+ element->setSelected(true);
+ const qreal height = text->boundingRect().height();
+ drag(scene, text, false, 25 - width);
+ check(text->boundingRect().height() > height + 5, "narrowing the text wraps it onto more lines");
+ check(linesInside(text), "a text is not narrower than its widest word: no line runs out of it");
+ check(near(anchor(text), fixed), "a text that wraps keeps its alignment point in place");
+ scene->undoStack().undo();
+ check(near(text->pos(), pos), "undo puts the wrapped text back");
+ element->setSelected(false);
+}
+
+static void checkAll(Diagram *scene, Element *element, DynamicElementTextItem *text)
+{
+ checkText(scene, element, text, Qt::AlignLeft | Qt::AlignTop);
+ checkText(scene, element, text, Qt::AlignRight | Qt::AlignTop);
+ checkText(scene, element, text, Qt::AlignHCenter | Qt::AlignVCenter);
+ checkText(scene, element, text, Qt::AlignRight | Qt::AlignBottom);
+ checkText(scene, element, text, Qt::AlignLeft | Qt::AlignBottom);
+}
+
+int main(int argc, char **argv)
+{
+ QApplication app(argc, argv);
+ std::freopen(argv[2], "w", stdout);
+
+ QTemporaryDir settings;
+ QSettings::setDefaultFormat(QSettings::IniFormat);
+ QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, settings.path());
+ QCoreApplication::setOrganizationName("QETElementTextResizeRegression");
+ QETProject::setBackupEnabled(false);
+ QET::QetMessageBox::setNonInteractive(true);
+ QFontDatabase::addApplicationFont(":/fonts/LiberationSans-Regular.ttf");
+
+ try
+ {
+ QETProject project(QString::fromLocal8Bit(argv[1]));
+ check(project.state() == QETProject::Ok, "fixture opens");
+ project.buildFolios(project.diagrams());
+ Diagram *scene = project.diagrams().first();
+
+ DynamicElementTextItem *text = nullptr;
+ Element *element = nullptr;
+ for (Element *e : scene->elements())
+ for (DynamicElementTextItem *t : e->dynamicTextItems())
+ if (!text) { text = t; element = e; }
+ check(text, "the fixture has a symbol with a text");
+ // Several words, so that narrowing it makes it wrap
+ text->setTextFrom(DynamicElementTextItem::UserText);
+ text->setText(QStringLiteral("Sockets bathroom first floor, 5"));
+
+ checkAll(scene, element, text);
+ // The same on a text turned by 90 degrees: the fixed point is
+ // found along the text's own baseline, not the folio's x axis.
+ text->setRotation(90);
+ checkAll(scene, element, text);
+
+ std::puts("PASS: a symbol text's width handles keep its alignment point in place, undo restores both");
+ }
+ catch (const std::exception &error)
+ {
+ std::printf("FAIL: %s\n", error.what());
+ return 1;
+ }
+ return 0;
+}
diff --git a/tests/qttest/run_element_text_resize_probe.py b/tests/qttest/run_element_text_resize_probe.py
new file mode 100755
index 000000000..e4920c28d
--- /dev/null
+++ b/tests/qttest/run_element_text_resize_probe.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+"""Link the element text resize integration probe using an existing Ninja app build.
+
+Reuses app objects without duplicating the app or introducing a core-library
+refactor. No application source or existing executable is replaced. The
+generated manifest, probe binary, reports and export fixtures stay in build/.
+"""
+import argparse
+import pathlib
+import re
+import shutil
+import subprocess
+import sys
+
+parser = argparse.ArgumentParser()
+parser.add_argument('--build', required=True)
+parser.add_argument('--binary', required=True)
+parser.add_argument('--source', required=True)
+parser.add_argument('--fixture', required=True)
+args = parser.parse_args()
+build = pathlib.Path(args.build).resolve()
+binary = pathlib.Path(args.binary).resolve()
+source = pathlib.Path(args.source).resolve()
+fixture = pathlib.Path(args.fixture).resolve()
+manifest = (build / 'build.ninja').read_text(encoding='utf-8')
+match = re.search(r'^build (\S*?/sources/main\.cpp\.(?:obj|o)):', manifest, re.MULTILINE)
+if not match:
+ sys.exit('Cannot find the application main object in the Ninja build.')
+main_object = match.group(1)
+probe_object = main_object.replace('main.cpp.', 'element_text_resize_probe.cpp.')
+probe_binary = binary.with_name('element_text_resize_probe' + binary.suffix)
+def escape(path):
+ return pathlib.Path(path).as_posix().replace('$', '$$').replace(':', '$:').replace(' ', '$ ')
+main_source = source.parents[2] / 'sources' / 'main.cpp'
+manifest = manifest.replace(main_object, probe_object)
+manifest = manifest.replace(escape(main_source), escape(source))
+if binary.suffix:
+ manifest = manifest.replace(binary.name, probe_binary.name)
+else:
+ old_target = escape(binary.name if binary.parent == build else binary)
+ new_target = escape(probe_binary.name if probe_binary.parent == build else probe_binary)
+ manifest = manifest.replace('build ' + old_target + ':', 'build ' + new_target + ':')
+ old_file = binary.name if binary.parent == build else binary.as_posix()
+ new_file = probe_binary.name if probe_binary.parent == build else probe_binary.as_posix()
+ manifest = re.sub(r'^ TARGET_FILE = ' + re.escape(old_file) + r'$',
+ ' TARGET_FILE = ' + new_file, manifest, flags=re.MULTILINE)
+probe_manifest = build / 'element-text-resize-probe.ninja'
+probe_manifest.write_text(manifest, encoding='utf-8')
+ninja = shutil.which('ninja')
+if not ninja:
+ sys.exit('Ninja is required for the integration probe.')
+target = probe_binary.name if probe_binary.parent == build else str(probe_binary)
+subprocess.run([ninja, '-C', str(build), '-f', probe_manifest.name, target], check=True)
+output = build / 'element-text-resize'
+output.mkdir(exist_ok=True)
+report = output / 'results.txt'
+result = subprocess.run([str(probe_binary), str(fixture), str(report), str(output)], timeout=90)
+if report.exists():
+ print(report.read_text(encoding='utf-8', errors='replace'))
+sys.exit(result.returncode)