diff --git a/sources/qetgraphicsitem/dynamicelementtextitem.cpp b/sources/qetgraphicsitem/dynamicelementtextitem.cpp index 7fc536a45..0a04a63b9 100644 --- a/sources/qetgraphicsitem/dynamicelementtextitem.cpp +++ b/sources/qetgraphicsitem/dynamicelementtextitem.cpp @@ -16,6 +16,7 @@ along with QElectroTech. If not, see . */ #include "dynamicelementtextitem.h" +#include "../QetGraphicsItemModeler/textresizehandles.h" #include "../shownkinds.h" #include "../qetproject.h" #include "../QPropertyUndoCommand/qpropertyundocommand.h" @@ -1040,8 +1041,6 @@ void DynamicElementTextItem::updateResizeHandlesPos() */ void DynamicElementTextItem::handlerMousePressEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event) { - Q_UNUSED(handle) - //The actual property value, kept as-is (possibly -1, meaning "auto") //so a later undo restores the exact original state rather than a //synthesized fixed width. @@ -1050,6 +1049,14 @@ void DynamicElementTextItem::handlerMousePressEvent(QetGraphicsHandlerItem *hand //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 edge opposite the dragged handle stays where it is, as in a + //drawing program: remember it in the parent's coordinates, which + //take the text's rotation into account + m_resize_start_pos = pos(); + const QRectF br = boundingRect(); + m_resize_fixed_parent = mapToParent(QPointF(handle == m_left_resize_handle ? br.right() : br.left(), + br.center().y())); } /** @@ -1071,14 +1078,23 @@ void DynamicElementTextItem::handlerMouseMoveEvent(QetGraphicsHandlerItem *handl qreal new_width = qMax(m_resize_baseline_width + delta, qreal(10)); setTextWidth(new_width); + + //The text grows and shrinks on its right side: dragging the left + //handle moves the text so that its right edge stays in place + if (handle == m_left_resize_handle) + { + const QRectF br = boundingRect(); + setPos(pos() + m_resize_fixed_parent - mapToParent(QPointF(br.right(), br.center().y()))); + } 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 */ @@ -1088,10 +1104,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..4fc22b5a8 100644 --- a/sources/qetgraphicsitem/dynamicelementtextitem.h +++ b/sources/qetgraphicsitem/dynamicelementtextitem.h @@ -213,6 +213,8 @@ 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; QMetaObject::Connection m_resize_handles_con; }; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 83249d7a1..becbecceb 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..199c66e8f --- /dev/null +++ b/tests/qttest/element_text_resize_probe.cpp @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Width handles of a symbol's text, linked against the application's objects. +// +// Dragging the left handle of a symbol's text changes its width and keeps +// its right edge in place; dragging the right handle keeps its left edge, +// as in a drawing program. Before, the text always kept its left edge, so +// a drag on the left handle moved the right edge and the text jumped. +// Undo puts back the width and the position together. Checked for an +// upright and a rotated text. +#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; +} + +static QPointF edge(DynamicElementTextItem *text, bool left) +{ + const QRectF br = text->boundingRect(); + return text->mapToScene(QPointF(left ? br.left() : br.right(), br.center().y())); +} + +/// Drag one handle by dx along the text's own baseline. +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); + mouse(scene, h, QEvent::GraphicsSceneMouseMove, (start + end) / 2); + mouse(scene, h, QEvent::GraphicsSceneMouseMove, end); + mouse(scene, h, QEvent::GraphicsSceneMouseRelease, end); +} + +static void checkText(Diagram *scene, Element *element, DynamicElementTextItem *text) +{ + element->setSelected(true); + const QPointF pos = text->pos(); + const qreal width = text->boundingRect().width(); + const QPointF right = edge(text, false); + + // Left handle 30 to the left: wider, right edge where it was. + drag(scene, text, true, -30); + check(text->boundingRect().width() > width + 10, "the left handle widens the text"); + check(near(edge(text, false), right), "dragging the left handle keeps the right edge 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(edge(text, false), right), "redo keeps the right edge in place"); + scene->undoStack().undo(); + + // Right handle 30 to the right: wider, left edge where it was. + element->setSelected(true); + const QPointF left = edge(text, true); + drag(scene, text, false, 30); + check(text->boundingRect().width() > width + 10, "the right handle widens the text"); + check(near(edge(text, true), left), "dragging the right handle keeps the left edge in place"); + scene->undoStack().undo(); + check(near(text->pos(), pos), "undo of the right handle leaves the position alone"); + element->setSelected(false); +} + +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"); + + checkText(scene, element, text); + // The same on a text turned by 90 degrees: the fixed edge is + // found along the text's own baseline, not the folio's x axis. + text->setRotation(90); + checkText(scene, element, text); + + std::puts("PASS: a symbol text's width handles keep the opposite edge 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)