diff --git a/sources/qetgraphicsitem/diagramtextitem.cpp b/sources/qetgraphicsitem/diagramtextitem.cpp index a0df2d2ea..a9a912dcc 100644 --- a/sources/qetgraphicsitem/diagramtextitem.cpp +++ b/sources/qetgraphicsitem/diagramtextitem.cpp @@ -25,6 +25,11 @@ #include "../utils/qetutils.h" #include "../QetGraphicsItemModeler/textresizehandles.h" +#include +#include +#include +#include + /** @brief DiagramTextItem::DiagramTextItem @param parent : parent item @@ -255,6 +260,59 @@ QRectF DiagramTextItem::frameRect() const return QRectF(pos, size); } +/** + @brief DiagramTextItem::writtenRect + @return the rect around the lines actually written, in this item's + coordinates: narrower than boundingRect() for a text kept at a fixed + width, and placed as the text is aligned. A text with nothing written + gives its bounding rect, so that it can still be picked. +*/ +QRectF DiagramTextItem::writtenRect() const +{ + QRectF written; + QAbstractTextDocumentLayout *layout = document()->documentLayout(); + for (QTextBlock block = document()->begin(); block.isValid(); block = block.next()) + { + const QTextLayout *lines = block.layout(); + if (!lines) + continue; + const QPointF origin = layout->blockBoundingRect(block).topLeft(); + for (int i = 0; i < lines->lineCount(); ++i) + { + const QRectF line = lines->lineAt(i).naturalTextRect().translated(origin); + if (line.width() > 0) + written |= line; + } + } + if (written.isEmpty()) + return boundingRect(); + //A little room around the letters, as the frame keeps + return written.adjusted(-1, -1, 1, 1) & boundingRect(); +} + +/** + @brief DiagramTextItem::shape + @return the lines written as the place to click: the empty part of a + fixed-width text belongs to whatever lies beneath it. While editing, + the whole box, so that a click anywhere in it moves the cursor. +*/ +QPainterPath DiagramTextItem::shape() const +{ + QPainterPath path; + path.addRect(isEditing() ? boundingRect() : writtenRect()); + return path; +} + +/** + @brief DiagramTextItem::contains + QGraphicsTextItem answers from its whole box: answer from shape(), so + that hovering and picking at a point follow it too. +*/ +bool DiagramTextItem::contains(const QPointF &point) const +{ + return shape().contains(point); +} + void DiagramTextItem::setHtml(const QString &text) { QGraphicsTextItem::setHtml(text); @@ -301,7 +359,25 @@ void DiagramTextItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *o { painter -> setRenderHint(QPainter::Antialiasing, false); - QGraphicsTextItem::paint(painter, option, widget); + //Outside editing, the selection is drawn around the lines written, + //not around the whole width a fixed-width text keeps free + const bool outline = option && (option->state & QStyle::State_Selected) && !isEditing(); + if (outline) + { + QStyleOptionGraphicsItem plain(*option); + plain.state &= ~(QStyle::State_Selected | QStyle::State_HasFocus); + QGraphicsTextItem::paint(painter, &plain, widget); + + painter -> save(); + painter -> setBrush(Qt::NoBrush); + QPen pen(Qt::black, 0, Qt::DashLine); + pen.setCosmetic(true); + painter -> setPen(pen); + painter -> drawRect(writtenRect()); + painter -> restore(); + } + else + QGraphicsTextItem::paint(painter, option, widget); if (m_mouse_hover && !isSelected()) { @@ -318,7 +394,7 @@ void DiagramTextItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *o t.setStyle(Qt::DashDotLine); t.setCosmetic(true); painter -> setPen(t); - painter -> drawRoundedRect(boundingRect(), 10, 10); + painter -> drawRoundedRect(writtenRect(), 2, 2); painter -> restore(); } diff --git a/sources/qetgraphicsitem/diagramtextitem.h b/sources/qetgraphicsitem/diagramtextitem.h index 3a5d5ca4d..7f4bb0722 100644 --- a/sources/qetgraphicsitem/diagramtextitem.h +++ b/sources/qetgraphicsitem/diagramtextitem.h @@ -86,6 +86,9 @@ class DiagramTextItem : public QGraphicsTextItem void setAlignmentAtAnchor(const Qt::Alignment &alignment); QRectF frameRect() const; + QRectF writtenRect() const; + QPainterPath shape() const override; + bool contains(const QPointF &point) const override; void setHtml(const QString &text); void setPlainText(const QString &text); diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 930dda036..276982b83 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -131,6 +131,15 @@ if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja") --fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet) set_tests_properties(tst_symboltextplacementintegration PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + # The outline and the click area of a fixed-width text are the lines + # written, not the whole box. + add_test(NAME tst_textoutlineintegration + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_text_outline_probe.py + --build ${CMAKE_BINARY_DIR} --binary $ + --source ${CMAKE_CURRENT_SOURCE_DIR}/text_outline_probe.cpp + --fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet) + set_tests_properties(tst_textoutlineintegration PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") endif() if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja") add_test(NAME tst_darkimageintegration diff --git a/tests/qttest/run_text_outline_probe.py b/tests/qttest/run_text_outline_probe.py new file mode 100755 index 000000000..1ad3ee1ae --- /dev/null +++ b/tests/qttest/run_text_outline_probe.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Link the text outline 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.', 'text_outline_probe.cpp.') +probe_binary = binary.with_name('text_outline_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 / 'text-outline-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 / 'text-outline' +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/text_outline_probe.cpp b/tests/qttest/text_outline_probe.cpp new file mode 100644 index 000000000..eb37658f8 --- /dev/null +++ b/tests/qttest/text_outline_probe.cpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// The outline and the click area of a text hug the lines written, +// linked against the application's objects. +// +// A symbol's text kept at a fixed width and aligned to the right, "-XS6" +// in a box 120 wide, used to be outlined, hovered and picked over the +// whole box: a double click on the empty left part of it hit this text +// instead of whatever lay beneath. Now the outline and the click area +// are the written line, at the right of the box; while editing, the +// whole box still takes the clicks. +#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" + +static void check(bool value, const char *message) +{ + if (!value) throw std::runtime_error(message); + std::printf("ok: %s\n", message); +} + +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("QETTextOutlineRegression"); + 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; + for (Element *e : scene->elements()) + for (DynamicElementTextItem *t : e->dynamicTextItems()) + if (!text) text = t; + check(text, "the fixture has a symbol with a text"); + + text->setTextFrom(DynamicElementTextItem::UserText); + text->setText(QStringLiteral("-XS6")); + text->setTextWidth(120); + text->setAlignment(Qt::AlignRight | Qt::AlignTop); + + const QRectF box = text->boundingRect(); + const QRectF written = text->writtenRect(); + check(box.width() >= 120, "the box keeps its fixed width"); + check(written.width() < 40, "the outline is as wide as the text written"); + check(qAbs(written.right() - box.right()) < 8, "and sits at the right, as the text is aligned"); + + const QPointF empty(box.left() + 10, written.center().y()); + check(!text->shape().contains(empty), "the empty part of the box is not this text's to click"); + check(text->shape().contains(written.center()), "the written line is"); + check(text->contains(written.center()) && !text->contains(empty), "hover and click follow the shape"); + + text->setText(QStringLiteral("Sockets bathroom first floor, 5")); + text->setTextWidth(50); + check(text->writtenRect().height() > written.height() + 5, "a text on several lines is outlined whole"); + + text->setText(QString()); + check(text->writtenRect() == text->boundingRect(), "a text with nothing written can still be picked"); + + std::puts("PASS"); + } + catch (const std::exception &error) + { + std::printf("FAIL: %s\n", error.what()); + std::fflush(stdout); + return 1; + } + std::fflush(stdout); + return 0; +}