mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-25 07:24:21 +01:00
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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+60
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user