Texts are outlined and picked where they are written

A text kept at a fixed width was outlined, hovered and picked over its
whole box. A short label such as "-XS6", aligned to the right in a box
120 wide, took the clicks meant for whatever lay in the empty part of
the box, and its selection frame was six times wider than the text.

The selection outline, the hover outline and the click area (shape()
and contains()) are now the lines actually written, placed as the text
is aligned. While editing, the whole box still takes the clicks; a
text with nothing written keeps its box, so it can still be picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DaTweety
2026-10-19 12:24:49 +02:00
parent 5df19f6672
commit bf59f963c1
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;
}