diff --git a/sources/QetGraphicsItemModeler/qetgraphicshandleritem.cpp b/sources/QetGraphicsItemModeler/qetgraphicshandleritem.cpp index ffd3dfeed..fa16eee57 100644 --- a/sources/QetGraphicsItemModeler/qetgraphicshandleritem.cpp +++ b/sources/QetGraphicsItemModeler/qetgraphicshandleritem.cpp @@ -34,10 +34,16 @@ QetGraphicsHandlerItem::QetGraphicsHandlerItem(qreal size) void QetGraphicsHandlerItem::setSize(qreal size) { + if (m_size_animation) { + m_size_animation->stop(); + m_size_animation->deleteLater(); + m_size_animation.clear(); + } prepareGeometryChange(); m_current_size = m_original_size = size; m_handler_rect.setRect(0-m_current_size/2, 0-m_current_size/2, m_current_size, m_current_size); m_br.setRect(-1-m_current_size/2, -1-m_current_size/2, m_current_size+2, m_current_size+2); + update(); } /** @@ -90,7 +96,12 @@ void QetGraphicsHandlerItem::hoverEnterEvent(QGraphicsSceneHoverEvent *event) { Q_UNUSED(event); - auto animation_ = new QPropertyAnimation(this, "currentSize"); + if (m_size_animation) { + m_size_animation->stop(); + m_size_animation->deleteLater(); + } + auto animation_ = new QPropertyAnimation(this, "currentSize", this); + m_size_animation = animation_; animation_->setStartValue(m_original_size); animation_->setEndValue(m_original_size*1.5); animation_->setDuration(200); @@ -102,7 +113,12 @@ void QetGraphicsHandlerItem::hoverLeaveEvent(QGraphicsSceneHoverEvent *event) { Q_UNUSED(event); - auto animation_ = new QPropertyAnimation(this, "currentSize"); + if (m_size_animation) { + m_size_animation->stop(); + m_size_animation->deleteLater(); + } + auto animation_ = new QPropertyAnimation(this, "currentSize", this); + m_size_animation = animation_; animation_->setStartValue(m_current_size); animation_->setEndValue(m_original_size); animation_->setDuration(200); @@ -126,7 +142,7 @@ void QetGraphicsHandlerItem::setCurrentSize(qreal size) */ QVector QetGraphicsHandlerItem::handlerForPoint( const QVector &points, - int size) + qreal size) { QVector list_; for (QPointF point : points) diff --git a/sources/QetGraphicsItemModeler/qetgraphicshandleritem.h b/sources/QetGraphicsItemModeler/qetgraphicshandleritem.h index f14fc8910..0853af35b 100644 --- a/sources/QetGraphicsItemModeler/qetgraphicshandleritem.h +++ b/sources/QetGraphicsItemModeler/qetgraphicshandleritem.h @@ -20,6 +20,8 @@ #include #include +#include +class QPropertyAnimation; /** @brief The QetGraphicsHandlerItem class @@ -69,10 +71,11 @@ class QetGraphicsHandlerItem : public QGraphicsObject qreal m_original_size; QColor m_color; QPen m_pen; + QPointer m_size_animation; public: static QVector handlerForPoint( - const QVector &points, int size = 10); + const QVector &points, qreal size = 10); }; #endif // QETGRAPHICSHANDLERITEM_H diff --git a/sources/ui/diagrameditorhandlersizewidget.cpp b/sources/ui/diagrameditorhandlersizewidget.cpp index 37a8f78e3..bb22cac90 100644 --- a/sources/ui/diagrameditorhandlersizewidget.cpp +++ b/sources/ui/diagrameditorhandlersizewidget.cpp @@ -22,24 +22,27 @@ #include "../projectview.h" #include "../diagramview.h" #include "../diagram.h" -#include "../../QetGraphicsItemModeler/qetgraphicshandleritem.h" +#include "../qetproject.h" +#include +#include "../QetGraphicsItemModeler/qetgraphicshandleritem.h" DiagramEditorHandlerSizeWidget::DiagramEditorHandlerSizeWidget(QWidget *parent) : QWidget(parent), ui(new Ui::DiagramEditorHandlerSizeWidget) { ui->setupUi(this); + const QSignalBlocker blocker(ui->comboBox); + const qreal sizes[] = {2.5, 5.0, 7.5, 10.0, 20.0, 30.0}; + for (int i = 0; i < ui->comboBox->count(); ++i) + ui->comboBox->setItemData(i, sizes[i]); + ui->comboBox->setCurrentIndex(3); // x1 remains the default. if (auto editor = QETApp::instance()->diagramEditorAncestorOf(this)) { - const auto size = editor->property("graphics_handler_size").toInt(); - - if (size == 10 || size < 10) - ui->comboBox->setCurrentIndex(0); - else if (size == 20) - ui->comboBox->setCurrentIndex(1); - else if (size == 30 || size > 30) - ui->comboBox->setCurrentIndex(2); + const auto size = editor->property("graphics_handler_size").toReal(); + const int index = ui->comboBox->findData(size); + if (index >= 0) + ui->comboBox->setCurrentIndex(index); } } @@ -50,20 +53,18 @@ DiagramEditorHandlerSizeWidget::~DiagramEditorHandlerSizeWidget() void DiagramEditorHandlerSizeWidget::on_comboBox_currentIndexChanged(int index) { + if (index < 0 || !ui->comboBox->itemData(index).isValid()) + return; + const qreal size = ui->comboBox->itemData(index).toReal(); if (auto editor_ = QETApp::instance()->diagramEditorAncestorOf(this)) { - editor_->setProperty("graphics_handler_size", (index+1) * 10); - if (auto project_view = editor_->currentProjectView()) { - if (auto diagram_view = project_view->currentDiagram()) { - if (auto diagram = diagram_view->diagram()) - { - for (const auto item : diagram->items()) - { - if (item->type() == QetGraphicsHandlerItem::Type) - { - auto handler = qgraphicsitem_cast(item); - handler->setSize((index+1) * 10); - } + editor_->setProperty("graphics_handler_size", size); + for (auto project_view : editor_->openedProjects()) { + for (auto diagram : project_view->project()->diagrams()) { + for (const auto item : diagram->items()) { + if (item->type() == QetGraphicsHandlerItem::Type) { + auto handler = qgraphicsitem_cast(item); + handler->setSize(size); } } } diff --git a/sources/ui/diagrameditorhandlersizewidget.ui b/sources/ui/diagrameditorhandlersizewidget.ui index 9d5a12c4d..956edb2bb 100644 --- a/sources/ui/diagrameditorhandlersizewidget.ui +++ b/sources/ui/diagrameditorhandlersizewidget.ui @@ -23,6 +23,24 @@ + + 3 + + + + x 0,25 + + + + + x 0,5 + + + + + x 0,75 + + x 1 diff --git a/tests/performance/handles/CMakeLists.txt b/tests/performance/handles/CMakeLists.txt new file mode 100644 index 000000000..298e26c36 --- /dev/null +++ b/tests/performance/handles/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.16) +project(handle_refresh_probe LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_AUTOMOC ON) +find_package(Qt6 REQUIRED COMPONENTS Widgets) +add_executable(handle_refresh_probe main.cpp + ${QET_SOURCE}/sources/QetGraphicsItemModeler/qetgraphicshandleritem.cpp + ${QET_SOURCE}/sources/QetGraphicsItemModeler/qetgraphicshandleritem.h) +target_include_directories(handle_refresh_probe PRIVATE ${QET_SOURCE}/sources) +target_link_libraries(handle_refresh_probe PRIVATE Qt6::Widgets) +if(WIN32) + target_link_libraries(handle_refresh_probe PRIVATE psapi) +endif() diff --git a/tests/performance/handles/main.cpp b/tests/performance/handles/main.cpp new file mode 100644 index 000000000..c58fe0972 --- /dev/null +++ b/tests/performance/handles/main.cpp @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Synthetic raster viewport workload, not a full editor latency benchmark. +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_WIN +#include +#include +#endif +#include "QetGraphicsItemModeler/qetgraphicshandleritem.h" + +static double cpuMilliseconds() { +#ifdef Q_OS_WIN + FILETIME created,exited,kernel,user; + if(GetProcessTimes(GetCurrentProcess(),&created,&exited,&kernel,&user)) { + ULARGE_INTEGER k,u; k.LowPart=kernel.dwLowDateTime;k.HighPart=kernel.dwHighDateTime; + u.LowPart=user.dwLowDateTime;u.HighPart=user.dwHighDateTime; + return double(k.QuadPart+u.QuadPart)/10000; + } + return 0; +#else + return double(std::clock())*1000/CLOCKS_PER_SEC; +#endif +} + +int main(int argc,char **argv) { + QApplication app(argc,argv); + const qreal size=QString::fromLocal8Bit(argv[1]).toDouble(); + const int count=QString::fromLocal8Bit(argv[2]).toInt(); + const QString operation=QString::fromLocal8Bit(argv[3]); + QGraphicsScene scene; scene.setSceneRect(-2000,-2000,4000,4000); + QList objects; + for(int i=0;isetPos((i%32)*36-576,(i/32)*28-224); objects<setParentItem(object); handle->setColor(Qt::darkGreen); + } + } + QGraphicsView view(&scene); view.resize(1280,800); view.setSceneRect(scene.sceneRect()); + view.show(); QApplication::processEvents(); + QImage image(1280,800,QImage::Format_ARGB32_Premultiplied); + auto frame=[&](int i) { + if(operation=="zoom") {view.resetTransform();view.scale(0.75+0.005*(i%60),0.75+0.005*(i%60));} + if(operation=="pan") view.centerOn((i%60)*2,0); + if(operation=="manipulate") for(auto *object:objects) { + object->setRotation(i%30); object->setRect(0,0,32+(i%8),24); object->moveBy(i%2?1:-1,0); + } + image.fill(Qt::white); QPainter painter(&image); view.render(&painter); + }; + for(int i=0;i<20;++i) frame(i); + std::puts("size,handles,operation,repeat,ms_per_frame,cpu_ms_per_frame,working_set_bytes"); + for(int repeat=0;repeat<7;++repeat) { + const auto cpu=cpuMilliseconds(); QElapsedTimer timer; timer.start(); + for(int i=0;i<120;++i) frame(i); + const double elapsed=timer.nsecsElapsed()/1e6/120; + const double cpuElapsed=(cpuMilliseconds()-cpu)/120; + unsigned long long memory=0; +#ifdef Q_OS_WIN + PROCESS_MEMORY_COUNTERS counters{}; counters.cb=sizeof(counters); + if(GetProcessMemoryInfo(GetCurrentProcess(),&counters,sizeof(counters))) memory=counters.WorkingSetSize; +#endif + std::printf("%.2f,%d,%s,%d,%.6f,%.6f,%llu\n",size,count,qPrintable(operation),repeat,elapsed,cpuElapsed,memory); + } +} diff --git a/tests/performance/handles/run.py b/tests/performance/handles/run.py new file mode 100644 index 000000000..d4a23cf0d --- /dev/null +++ b/tests/performance/handles/run.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Run identical Release component workloads; preserve samples and variability.""" +import argparse +import csv +import io +import os +import pathlib +import statistics +import subprocess + +p = argparse.ArgumentParser() +p.add_argument('--reference', required=True) +p.add_argument('--modified', required=True) +p.add_argument('--output', required=True) +a = p.parse_args() +env = os.environ.copy() +env['QT_QPA_PLATFORM'] = 'offscreen' +rows = [] +# Compare x1 first, then exercise every remaining size on the modified code. +for size in [10, 2.5, 5, 7.5, 20, 30]: + for count in [16, 2048]: + for operation in ['zoom', 'pan', 'manipulate']: + variants = [('reference', a.reference), ('modified', a.modified)] if size == 10 else [('modified', a.modified)] + for variant, binary in variants: + result = subprocess.run([binary, str(size), str(count), operation], env=env, + capture_output=True, text=True, check=True, timeout=120) + samples = list(csv.DictReader(io.StringIO(result.stdout))) + assert len(samples) == 7 + rows.extend(dict(variant=variant, **sample) for sample in samples) +output = pathlib.Path(a.output) +output.mkdir(parents=True, exist_ok=True) +with (output / 'samples.csv').open('w', newline='', encoding='utf-8') as stream: + writer = csv.DictWriter(stream, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) +groups = {} +for row in rows: + key = (row['variant'], row['size'], row['handles'], row['operation']) + groups.setdefault(key, []).append(row) +lines = ['# Release handle refresh measurements', '', + 'Synthetic QGraphicsView raster rendering at 1280×800, offscreen, on the same PC.', + 'Each sample comprises 120 frames; 20 warm-up frames precede seven samples.', + '16 or 2048 handles; same scenes and workload code for both builds.', + 'Manipulate changes position, rectangle size and rotation of all scene objects.', + 'This is not full-editor mouse latency, GPU frame presentation or human usability.', '', + '| Code | Factor | Handles | Workload | Mean ms/frame ± SD | Min–max | CPU ms/frame | Working set MiB |', + '|---|---:|---:|---|---:|---:|---:|---:|'] +for (variant, size, count, operation), samples in groups.items(): + wall = [float(s['ms_per_frame']) for s in samples] + cpu = [float(s['cpu_ms_per_frame']) for s in samples] + memory = [int(s['working_set_bytes']) / 1048576 for s in samples] + lines.append(f'| {variant} | {float(size)/10:g} | {count} | {operation} | ' + f'{statistics.mean(wall):.3f} ± {statistics.stdev(wall):.3f} | ' + f'{min(wall):.3f}–{max(wall):.3f} | {statistics.mean(cpu):.3f} | ' + f'{statistics.median(memory):.1f} |') +(output / 'measurements.md').write_text('\n'.join(lines)+'\n', encoding='utf-8') +print(f'{len(rows)} samples saved in {output}') diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 7e10b46fa..d6aa7dab8 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -74,6 +74,13 @@ target_include_directories(tst_darkimagelayers PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_darkimagelayers PRIVATE Qt::Test Qt::Widgets) find_package(Python3 QUIET COMPONENTS Interpreter) if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja") + add_test(NAME tst_handlersizeintegration + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_handle_size_probe.py + --build ${CMAKE_BINARY_DIR} --binary $ + --source ${CMAKE_CURRENT_SOURCE_DIR}/handle_size_probe.cpp + --fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet) + set_tests_properties(tst_handlersizeintegration PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") add_test(NAME tst_attachedplainpaste COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_attached_plain_paste_probe.py --build ${CMAKE_BINARY_DIR} --binary $ @@ -194,6 +201,14 @@ set_tests_properties(tst_textanchor PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offs target_include_directories(tst_textanchor PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_textanchor PRIVATE Qt::Test Qt::Widgets) +# Shared handle geometry and picking, including fractional sizes. +add_executable(tst_handlersize tst_handlersize.cpp + ${QET_DIR}/sources/QetGraphicsItemModeler/qetgraphicshandleritem.cpp) +add_test(NAME tst_handlersize COMMAND tst_handlersize) +set_tests_properties(tst_handlersize PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_handlersize PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_handlersize PRIVATE Qt::Test Qt::Widgets) + # 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( diff --git a/tests/qttest/handle_size_probe.cpp b/tests/qttest/handle_size_probe.cpp new file mode 100644 index 000000000..f342fb737 --- /dev/null +++ b/tests/qttest/handle_size_probe.cpp @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../../sources/qetapp.h" +#include "../../sources/qetdiagrameditor.h" +#include "../../sources/qetproject.h" +#include "../../sources/qetresult.h" +#include "../../sources/qetmessagebox.h" +#include "../../sources/projectview.h" +#include "../../sources/diagram.h" +#include "../../sources/utils/qetutils.h" +#include "../../sources/ui/diagrameditorhandlersizewidget.h" +#include "../../sources/QetGraphicsItemModeler/qetgraphicshandleritem.h" +#include "../../sources/qetgraphicsitem/independenttextitem.h" +#include "../../sources/qetgraphicsitem/dynamicelementtextitem.h" +#include "../../sources/qetgraphicsitem/diagramimageitem.h" +#include "../../sources/qetgraphicsitem/qetshapeitem.h" +#include "../../sources/editor/ui/qetelementeditor.h" +#include "../../sources/editor/elementscene.h" +#include "../../sources/editor/graphicspart/partrectangle.h" +#include "../../sources/editor/graphicspart/partdynamictextfield.h" +#include "../../sources/qetgraphicsitem/ViewItem/qetgraphicstableitem.h" +#include "../../sources/qetgraphicsitem/conductor.h" +static void check(bool value,const char *why){if(!value)throw std::runtime_error(why);} +static QString canonicalProjectXml(QDomDocument document) { + // Scene index updates can reorder equal-z conductors. Compare their + // complete serialized content in UUID order, rather than scene order. + const auto groups=document.elementsByTagName("conductors"); + for(int i=0;i wires; + for(auto wire=group.firstChildElement("conductor");!wire.isNull();wire=wire.nextSiblingElement("conductor"))wires<mapFromScene(point)); + event.setButtonDownScenePos(Qt::LeftButton,press?*press:point);scene->sendEvent(item,&event); +} +static QetGraphicsHandlerItem *bottomRightHandle(Diagram *scene) { + QetGraphicsHandlerItem *found=nullptr; + for(auto *item:scene->items())if(item->type()==QetGraphicsHandlerItem::Type&&item->isVisible()) { + auto *handle=qgraphicsitem_cast(item); + if(!found||handle->scenePos().x()+handle->scenePos().y()>found->scenePos().x()+found->scenePos().y())found=handle; + } + check(found,"mouse gesture handle present");return found; +} +static void drag(Diagram *scene,QGraphicsItem *item,const QPointF &end) { + const auto start=item->scenePos(); + mouse(scene,item,QEvent::GraphicsSceneMousePress,start); + mouse(scene,item,QEvent::GraphicsSceneMouseMove,end,&start); + mouse(scene,item,QEvent::GraphicsSceneMouseRelease,end,&start); +} +int main(int argc,char **argv) { + const QString fixture=QString::fromLocal8Bit(argv[1]); + const QString report=QString::fromLocal8Bit(argv[2]); + QTemporaryDir isolated; + QByteArray config="--config-dir="+isolated.path().toUtf8(); + QByteArray data="--data-dir="+isolated.path().toUtf8(); + QByteArray collection="--common-elements-dir="+isolated.path().toUtf8(); + char *args[]={argv[0],config.data(),data.data(),collection.data()};int count=4; + QApplication app(count,args); + std::freopen(report.toLocal8Bit().constData(),"w",stdout); + std::setbuf(stdout,nullptr); + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat,QSettings::UserScope,isolated.path()); + QETProject::setBackupEnabled(false);QET::QetMessageBox::setNonInteractive(true); + try { + auto *qet=new QETApp; Q_UNUSED(qet); + auto editors=QETApp::diagramEditors();check(!editors.isEmpty(),"editor created"); + auto *editor=editors.first(); + auto *project=new QETProject(fixture);check(project->state()==QETProject::Ok,"fixture loaded"); + editor->addProjectView(new ProjectView(project)); + auto *scene=project->diagrams().first(); + auto widgets=editor->findChildren();check(!widgets.isEmpty(),"toolbar widget present"); + auto *combo=widgets.first()->findChild();check(combo&&combo->count()==6,"six choices"); + check(combo->currentIndex()==3&&editor->property("graphics_handler_size").toReal()==10,"default x1"); + auto *free=new IndependentTextItem;free->setPlainText("Mouse placement");scene->addItem(free);free->setSelected(true); + QPixmap pixmap(30,20);pixmap.fill(Qt::red);auto *image=new DiagramImageItem(pixmap);scene->addItem(image);image->setSelected(true); + auto *shape=new QetShapeItem({100,100},{150,140},QetShapeItem::Rectangle);scene->addItem(shape);shape->setSelected(true); + auto *attached=scene->elements().first()->dynamicTextItems().first();attached->setSelected(true); + auto *background=project->addNewDiagram(); + auto *backgroundHandle=new QetGraphicsHandlerItem; + background->addItem(backgroundHandle); + QApplication::processEvents(); + const QString xml=project->toXml().toString(); + const int undoCount=scene->undoStack().count();const int undoIndex=scene->undoStack().index(); + const bool dirty=project->projectWasModified(); + const qreal sizes[]={2.5,5,7.5,10,20,30}; + for(int i=0;i<6;++i) { + combo->setCurrentIndex(i);QApplication::processEvents(); + check(editor->property("graphics_handler_size").toReal()==sizes[i],"fractional editor property"); + check(QETUtils::graphicsHandlerSize(image)==sizes[i],"new handle size lookup"); + check(backgroundHandle->property("currentSize").toReal()==sizes[i],"inactive folio handles updated"); + int handles=0;for(auto *item:scene->items())if(item->type()==QetGraphicsHandlerItem::Type){ + ++handles;auto *handle=qgraphicsitem_cast(item); + check(handle->property("currentSize").toReal()==sizes[i],"existing handles updated immediately"); + } + check(handles>=8,"multiple selected object handles tested"); + image->setSelected(false);image->setSelected(true); + for(auto *item:scene->items())if(item->type()==QetGraphicsHandlerItem::Type) + check(qgraphicsitem_cast(item)->property("currentSize").toReal()==sizes[i],"recreated handles retain fractions"); + DiagramEditorHandlerSizeWidget recreated(editor); + check(recreated.findChild()->currentIndex()==i,"toolbar recreation preserves chosen size"); + check(project->toXml().toString()==xml,"handle size leaves project XML unchanged"); + check(scene->undoStack().count()==undoCount&&scene->undoStack().index()==undoIndex,"handle size leaves undo stack unchanged"); + check(project->projectWasModified()==dirty,"handle size leaves dirty state unchanged"); + } + for(int i=0;i<6;++i) { + combo->setCurrentIndex(i); + scene->clearSelection(); + auto *picture=new DiagramImageItem(pixmap);scene->addItem(picture);picture->setPos(400,400);picture->setSelected(true); + auto *handle=bottomRightHandle(scene); + check(handle->property("currentSize").toReal()==sizes[i],"image mouse handle size"); + drag(scene,handle,handle->scenePos()+QPointF(20,20)); + check(picture->scaleFactorX()!=1,"image resized by mouse events"); + const qreal changed=picture->scaleFactorX();scene->undoStack().undo(); + check(picture->scaleFactorX()==1,"image resize undo");scene->undoStack().redo(); + check(picture->scaleFactorX()==changed,"image resize redo");scene->undoStack().undo(); + const auto centre=picture->mapToScene(picture->imageRect().center()); + mouse(scene,picture,QEvent::GraphicsSceneMousePress,centre); + mouse(scene,picture,QEvent::GraphicsSceneMouseRelease,centre); + handle=bottomRightHandle(scene); + const auto pivot=picture->mapToScene(picture->pivot());QTransform turn;turn.rotate(30); + drag(scene,handle,pivot+turn.map(handle->scenePos()-pivot)); + check(qAbs(picture->rotationAngle())>1,"image rotated by mouse events"); + const qreal angle=picture->rotationAngle();scene->undoStack().undo(); + check(qAbs(picture->rotationAngle())<0.001,"image rotation undo");scene->undoStack().redo(); + check(qAbs(picture->rotationAngle()-angle)<0.001,"image rotation redo");scene->undoStack().undo(); + scene->undoStack().clear();delete picture; + auto *rectangle=new QetShapeItem({0,0},{50,40},QetShapeItem::Rectangle);scene->addItem(rectangle);rectangle->setPos(400,400);rectangle->setSelected(true); + const auto rect=rectangle->rect();handle=bottomRightHandle(scene); + drag(scene,handle,handle->scenePos()+QPointF(20,20));check(rectangle->rect()!=rect,"shape resized by mouse events"); + const auto resized=rectangle->rect();scene->undoStack().undo();check(rectangle->rect()==rect,"shape resize undo"); + scene->undoStack().redo();check(rectangle->rect()==resized,"shape resize redo");scene->undoStack().undo(); + for(int click=0;click<2;++click) { + const auto middle=rectangle->mapToScene(rect.center()); + mouse(scene,rectangle,QEvent::GraphicsSceneMousePress,middle);mouse(scene,rectangle,QEvent::GraphicsSceneMouseRelease,middle); + } + handle=bottomRightHandle(scene);const auto shapePivot=rectangle->mapToScene(rectangle->pivot()); + drag(scene,handle,shapePivot+turn.map(handle->scenePos()-shapePivot)); + check(qAbs(rectangle->rotation())>1,"shape rotated by mouse events"); + const qreal shapeAngle=rectangle->rotation();scene->undoStack().undo();check(qAbs(rectangle->rotation())<0.001,"shape rotation undo"); + scene->undoStack().redo();check(qAbs(rectangle->rotation()-shapeAngle)<0.001,"shape rotation redo");scene->undoStack().undo(); + scene->undoStack().clear(); + auto *companion=new DiagramImageItem(pixmap);scene->addItem(companion);companion->setPos(600,400);companion->setSelected(true); + rectangle->setSelected(true); + const auto rectanglePos=rectangle->pos();const auto companionPos=companion->pos(); + const auto start=companion->mapToScene(companion->imageRect().center());const auto end=start+QPointF(20,20); + mouse(scene,companion,QEvent::GraphicsSceneMousePress,start); + mouse(scene,companion,QEvent::GraphicsSceneMouseMove,end,&start); + mouse(scene,companion,QEvent::GraphicsSceneMouseRelease,end,&start); + check(companion->pos()==companionPos+QPointF(20,20)&&rectangle->pos()==rectanglePos+QPointF(20,20),"multiple objects moved by mouse events"); + scene->undoStack().undo();check(companion->pos()==companionPos&&rectangle->pos()==rectanglePos,"multiple object movement undo"); + scene->undoStack().redo();check(companion->pos()==companionPos+QPointF(20,20)&&rectangle->pos()==rectanglePos+QPointF(20,20),"multiple object movement redo"); + scene->undoStack().undo();scene->undoStack().clear();delete companion;delete rectangle; + } + std::puts("PASS: image and rectangle mouse resize/rotation, multiple object movement, and undo/redo at all six sizes"); + project->setFilePath(QString::fromLocal8Bit(argv[3])+"/handles.qet"); + check(project->write().isOk(),"save after handle size changes"); + QETProject reopened(project->filePath());check(reopened.state()==QETProject::Ok,"reopen after handle size changes"); + bool imageFound=false,textFound=false; + for(auto *item:reopened.diagrams().first()->items()) { + if(auto *loaded=qgraphicsitem_cast(item))imageFound|=loaded->pixmap().size()==pixmap.size(); + if(auto *loaded=qgraphicsitem_cast(item))textFound|=loaded->toPlainText()==free->toPlainText(); + } + check(imageFound&&textFound,"object content survives reopen"); + auto *elementEditor=new QETElementEditor;elementEditor->show(); + auto *elementScene=elementEditor->elementScene(); + auto *part=new PartRectangle(elementEditor);part->setRect({0,0,50,40});elementScene->addItems({part});part->setSelected(true); + QApplication::processEvents(); + auto checkElementHandles=[&] { + int count=0; + for(auto *item:elementScene->items())if(item->type()==QetGraphicsHandlerItem::Type&&item->isVisible()) { + ++count;check(qgraphicsitem_cast(item)->property("currentSize").toReal()==10,"element editor retains default handle size"); + } + check(count>=4,"element editor shared handles present"); + }; + for(int i=0;i<6;++i){combo->setCurrentIndex(i);checkElementHandles();check(part->rect()==QRectF(0,0,50,40),"element primitive geometry unchanged");} + elementScene->clearSelection(); + auto *field=new PartDynamicTextField(elementEditor);field->setPlainText("Element field");elementScene->addItems({field});field->setSelected(true); + QApplication::processEvents();checkElementHandles(); + std::puts("PASS: element editor rectangle and dynamic field keep their default shared handles"); + QStandardItemModel model(2,2);model.setData(model.index(0,0),"Table"); + auto *table=new QetGraphicsTableItem;table->setModel(&model);scene->addItem(table); + const auto tableSize=table->size(); + for(int i=0;i<6;++i) { + combo->setCurrentIndex(i); + QGraphicsSceneHoverEvent enter(QEvent::GraphicsSceneHoverEnter);scene->sendEvent(table,&enter); + check(bottomRightHandle(scene)->property("currentSize").toReal()==sizes[i],"table hover handle retains fraction"); + check(table->size()==tableSize,"table dimensions unchanged"); + QGraphicsSceneHoverEvent leave(QEvent::GraphicsSceneHoverLeave);scene->sendEvent(table,&leave); + } + delete table;std::puts("PASS: table hover handles retain all six sizes without changing the table"); + const auto wireFixture=QFileInfo(fixture).dir().absoluteFilePath("../../../examples/tremie_vibrante.qet"); + auto *wireProject=new QETProject(wireFixture);check(wireProject->state()==QETProject::Ok,"conductor fixture opens"); + editor->addProjectView(new ProjectView(wireProject)); + Diagram *wireScene=nullptr;for(auto *diagram:wireProject->diagrams())if(!diagram->conductors().isEmpty()){wireScene=diagram;break;} + check(wireScene,"conductor scene present");auto *wire=wireScene->conductors().first(); + wire->setSelected(true);QApplication::processEvents(); + const auto wireXml=canonicalProjectXml(wireProject->toXml()); + for(int i=0;i<6;++i) { + combo->setCurrentIndex(i);wire->setSelected(false);wire->setSelected(true); + int count=0;for(auto *item:wireScene->items())if(item->type()==QetGraphicsHandlerItem::Type) { + ++count;check(qgraphicsitem_cast(item)->property("currentSize").toReal()==sizes[i],"conductor handles retain fractions"); + } + check(count>0,"conductor handles present"); + const auto after=canonicalProjectXml(wireProject->toXml()); + check(after==wireXml,"conductor project content unchanged"); + } + std::puts("PASS: conductor handles retain all six sizes without changing their project"); + std::puts("PASS: six toolbar choices, default, fractions, existing/recreated handles, multi-selection, toolbar recreation, unchanged XML/undo/dirty state"); + }catch(const std::exception &error){std::printf("FAIL: %s\n",error.what());std::fflush(stdout);std::_Exit(1);} + std::fflush(stdout);std::_Exit(0); +} diff --git a/tests/qttest/run_handle_size_probe.py b/tests/qttest/run_handle_size_probe.py new file mode 100644 index 000000000..9b95cd5f7 --- /dev/null +++ b/tests/qttest/run_handle_size_probe.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Link the handle-size 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.', 'handle_size_probe.cpp.') +probe_binary = binary.with_name('handle_size_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 / 'handle-size-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 / 'handle-size' +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) diff --git a/tests/qttest/tst_handlersize.cpp b/tests/qttest/tst_handlersize.cpp new file mode 100644 index 000000000..e79fa63a2 --- /dev/null +++ b/tests/qttest/tst_handlersize.cpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include +#include +#include +#include +#include +#include "QetGraphicsItemModeler/qetgraphicshandleritem.h" + +class tst_handlersize : public QObject +{ + Q_OBJECT +private slots: + void sizeChangeCancelsOldHoverAnimation() { + QGraphicsScene scene; + auto *handle=new QetGraphicsHandlerItem(30);scene.addItem(handle); + QGraphicsSceneHoverEvent event(QEvent::GraphicsSceneHoverEnter); + scene.sendEvent(handle,&event); + handle->setSize(2.5); + QTest::qWait(250); + QCOMPARE(handle->property("currentSize").toReal(),qreal(2.5)); + } + void fractionalGeometryAndPicking() { + for (qreal size : {2.5, 5.0, 7.5, 10.0, 20.0, 30.0}) { + QGraphicsScene scene; + auto *object = scene.addRect(QRectF(0,0,100,60)); + object->setPos(80,40); object->setRotation(37); object->setSelected(true); + const QRectF geometry=object->rect(); const auto transform=object->sceneTransform(); + auto handles=QetGraphicsHandlerItem::handlerForPoint({QPointF(120,80)},size); + auto *handle=handles.first(); scene.addItem(handle); handle->setColor(Qt::darkGreen); + QCOMPARE(handle->property("currentSize").toReal(),size); + QCOMPARE(handle->boundingRect().width(),size+2); + const auto position=handle->pos(); + for (qreal next : {2.5,5.0,7.5,10.0,20.0,30.0}) { + handle->setSize(next); + QCOMPARE(handle->property("currentSize").toReal(),next); + QCOMPARE(handle->pos(),position); QVERIFY(handle->isVisible()); + QCOMPARE(object->rect(),geometry); QCOMPARE(object->sceneTransform(),transform); + for (qreal zoom : {0.25,1.0,4.0}) { + QGraphicsView view(&scene); view.scale(zoom,zoom); + const auto device=handle->deviceTransform(view.viewportTransform()); + QCOMPARE(device.mapRect(handle->boundingRect()).width(),next+2); + const QPointF centre=handle->scenePos(); + QVERIFY(scene.items(centre,Qt::IntersectsItemShape,Qt::DescendingOrder,view.viewportTransform()).contains(handle)); + const QPointF outside=centre+QPointF((next/2+2)/zoom,0); + QVERIFY(!scene.items(outside,Qt::IntersectsItemShape,Qt::DescendingOrder,view.viewportTransform()).contains(handle)); + } + } + handle->hide(); QVERIFY(!scene.items(position).contains(handle)); + } + } +}; +QTEST_MAIN(tst_handlersize) +#include "tst_handlersize.moc" diff --git a/tests/qttest/tst_textresizehandles.cpp b/tests/qttest/tst_textresizehandles.cpp index 99f4c4779..2bb82270b 100644 --- a/tests/qttest/tst_textresizehandles.cpp +++ b/tests/qttest/tst_textresizehandles.cpp @@ -176,6 +176,7 @@ private slots: // and reports the change once, on release. void dragKeepsTheOppositeCorner() { + for (qreal size : {2.5, 5.0, 7.5, 10.0, 20.0, 30.0}) for (int c = TextResize::TopLeft ; c <= TextResize::BottomLeft ; ++c) for (qreal angle : {0.0, 90.0, 37.0}) { QGraphicsScene scene; @@ -183,7 +184,7 @@ private slots: scene.addItem(text); text->setPos(100, 100); text->setRotation(angle); - auto *handles = new TextResizeHandles(text); + auto *handles = new TextResizeHandles(text, size); QSignalSpy finished(handles, &TextResizeHandles::resizeFinished); const Corner corner = Corner(c);