Merge pull request #1558 from DaTweety/feature/tight-text-outline
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m45s

Texts are outlined and picked where they are written
This commit is contained in:
ispyisail
2026-10-24 23:23:14 +13:00
committed by GitHub
5 changed files with 239 additions and 2 deletions
+78 -2
View File
@@ -25,6 +25,11 @@
#include "../utils/qetutils.h"
#include "../QetGraphicsItemModeler/textresizehandles.h"
#include <QAbstractTextDocumentLayout>
#include <QStyleOptionGraphicsItem>
#include <QTextBlock>
#include <QTextLayout>
/**
@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();
}
@@ -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);
+9
View File
@@ -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 $<TARGET_FILE:qelectrotech>
--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
+60
View File
@@ -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)
+89
View File
@@ -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 <QApplication>
#include <QFontDatabase>
#include <QSettings>
#include <QTemporaryDir>
#include <cstdio>
#include <stdexcept>
#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;
}