Let free texts keep a width they wrap to

A free text (IndependentTextItem) gets a textWidth property: -1 is the
automatic width (one line, or lines broken by hand, as today); a
positive width makes the text wrap between words. Its top-left corner
stays in place. The property is what a spin box and resize handles will
set in follow-up changes.

File format: the width is saved as text_width on <input>, only when one
is set, so a project without widths is saved byte-identical
(tst_resaveunchanged). An older QElectroTech ignores the attribute and
shows the text on one line, with no load error.

DiagramTextItem::setHtml() no longer widens a centred or right-aligned
rich text to idealWidth() + 40 when the text has a width of its own;
with the automatic width it still does.

Test: tst_freetextwidth (--resave keeps the width only where set,
--export-svg draws the text wrapped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Beat Hangartner
2026-10-05 17:52:27 +02:00
parent 2e4b761602
commit 1e3c618495
7 changed files with 231 additions and 1 deletions
+3 -1
View File
@@ -269,7 +269,9 @@ void DiagramTextItem::setHtml(const QString &text)
block = block.next();
}
if (m_non_left_alignment) {
//Room for centred or right-aligned lines, unless the user has
//given this text a width of its own
if (m_non_left_alignment && !hasUserTextWidth()) {
document()->setTextWidth(document()->idealWidth() + 40.0);
}
}
@@ -110,6 +110,8 @@ class DiagramTextItem : public QGraphicsTextItem
void prepareAlignment();
void finishAlignment();
virtual bool hasUserTextWidth() const {return false;}
protected:
bool
@@ -26,7 +26,9 @@
#include "../utils/qetutils.h"
#include <QDomElement>
#include <QScopedPointer>
#include <QSettings>
#include <QtCore/qnumeric.h>
/**
Constructeur
@@ -36,6 +38,7 @@ IndependentTextItem::IndependentTextItem() :
DiagramTextItem(nullptr)
{
ShownKinds::tag(this, ShownKinds::FreeTexts);
wrapAtWords();
//Start from the font last applied to a text item this session,
//falling back to the app-wide Preferences default otherwise.
setFont(LastUsedStyle::hasTextFont() ? LastUsedStyle::textFont()
@@ -53,6 +56,19 @@ IndependentTextItem::IndependentTextItem(const QString &text) :
DiagramTextItem(text, nullptr)
{
ShownKinds::tag(this, ShownKinds::FreeTexts);
wrapAtWords();
}
/**
@brief IndependentTextItem::wrapAtWords
A text with a width wraps between words only: a word longer than the
width goes past it rather than being cut, as for the texts of symbols.
*/
void IndependentTextItem::wrapAtWords()
{
QTextOption option = document()->defaultTextOption();
option.setWrapMode(QTextOption::WordWrap);
document()->setDefaultTextOption(option);
}
/// Destructeur
@@ -78,6 +94,10 @@ void IndependentTextItem::fromXml(const QDomElement &e) {
QETUtils::fontFromString(font, e.attribute("font"));
setFont(font);
}
//Optional: absent for a text with the automatic width, the only
//kind older versions know (they show such a text unwrapped).
//Read after the text, setHtml() sets a width of its own.
setTextWidth(e.attribute(QStringLiteral("text_width"), QStringLiteral("-1")).toDouble());
}
/**
@@ -93,10 +113,39 @@ QDomElement IndependentTextItem::toXml(QDomDocument &document) const
result.setAttribute("text", toHtml());
result.setAttribute("rotation", QString::number(QET::correctAngle(rotation())));
result.setAttribute("font", QETUtils::fontToString(font()));
//Only when set, so a text with the automatic width is saved as before
if (m_text_width > 0)
result.setAttribute("text_width", QString::number(m_text_width));
return(result);
}
/**
@brief IndependentTextItem::setTextWidth
Set the width of this text (-1 = automatic width): the text wraps to
it, its top-left corner stays in place.
@param width
*/
void IndependentTextItem::setTextWidth(qreal width)
{
if (!qIsFinite(width) || width <= 0)
width = -1;
qreal document_width = width;
//The automatic width of a text with centred or right-aligned
//lines, as setHtml() gives it
if (width < 0 && m_non_left_alignment)
{
QScopedPointer<QTextDocument> natural(document()->clone());
natural->setTextWidth(-1);
document_width = natural->idealWidth() + 40.0;
}
document()->setTextWidth(document_width);
m_text_width = width;
emit textWidthChanged(width);
}
void IndependentTextItem::focusOutEvent(QFocusEvent *event)
{
DiagramTextItem::focusOutEvent(event);
@@ -29,10 +29,13 @@
class IndependentTextItem : public DiagramTextItem
{
Q_OBJECT
Q_PROPERTY(qreal textWidth READ textWidth WRITE setTextWidth NOTIFY textWidthChanged)
// constructors, destructor
signals:
void uuidChanged();
void textWidthChanged(qreal width);
public:
IndependentTextItem();
@@ -49,11 +52,18 @@ class IndependentTextItem : public DiagramTextItem
QUuid uuid() const {return m_uuid;}
void setUuid(const QUuid &uuid) {m_uuid = uuid; emit uuidChanged();}
void newUuid() {setUuid(QUuid::createUuid());} //create new uuid for this item
qreal textWidth() const {return m_text_width;}
void setTextWidth(qreal width);
protected:
void focusOutEvent(QFocusEvent *event) override;
bool hasUserTextWidth() const override {return m_text_width > 0;}
private:
void wrapAtWords();
QUuid m_uuid = QUuid::createUuid();
qreal m_text_width = -1;
};
#endif
+12
View File
@@ -389,6 +389,18 @@ target_link_libraries(tst_conductorselfretrace PRIVATE Qt::Test)
target_compile_definitions(tst_conductorselfretrace PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
# A free text with a width keeps it when the project is saved again (the
# attribute is written only when set) and is drawn wrapped to it.
add_executable(
tst_freetextwidth
tst_freetextwidth.cpp)
add_test(NAME tst_freetextwidth COMMAND tst_freetextwidth)
set_tests_properties(tst_freetextwidth PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
add_dependencies(tst_freetextwidth qelectrotech)
target_link_libraries(tst_freetextwidth PRIVATE Qt::Test Qt::Gui Qt::Xml)
target_compile_definitions(tst_freetextwidth PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
# The wiring list export (--export-cables) keeps every wire drawn to a folio
# report when a report has several, joins only one-to-one report pairs, and
# prints the folio number rather than its template. fixtures/wiring_list_arrows.qet
+42
View File
@@ -0,0 +1,42 @@
<project title="Free text widths" uuid="{5b0f5e1c-8a43-4c1e-9e57-3f0d0f0a7a00}" version="0.200.1">
<properties>
<property name="saveddate" show="1">10/2/26</property>
<property name="saveddate-eu" show="1">02-10-2026</property>
<property name="saveddate-us" show="1">2026-10-02</property>
<property name="savedfilename" show="1">skeleton</property>
<property name="savedfilepath" show="1">/tmp/qet-mcp-new-k41dmnyo/skeleton.qet</property>
<property name="savedtime" show="1">18:39</property>
</properties>
<usage enabled="true" time_spent="0"/>
<newdiagrams>
<border cols="17" colsize="60" displaycols="true" displayrows="true" rows="8" rowsize="80"/>
<inset author="" auto_page_num="" date="null" displayAt="bottom" filename="" folio="" indexrev="" locmach="" plant="" title="" version=""/>
<conductors bicolor="false" bus="" cable="" color2="#000000" condsize="1" conductor_color="" conductor_section="" dash-size="1" displaytext="1" formula="" function="" horizontal-alignment="AlignBottom" horizrotatetext="0" num="_" numsize="7" onetextperfolio="0" tension_protocol="" text_color="#000000" type="multi" vertical-alignment="AlignRight" vertirotatetext="270"/>
<report label="%f-%l%c"/>
<xrefs>
<xref delayprefix="" displayhas="cross" master_label="%f-%l%c" offset="0" powerprefix="" showallconfiguredslaves="false" showpowerctc="true" showterminalname="true" slave_label="(%f-%l%c)" slave_offset="0" snapto="label" switchprefix="" type="coil" xrefpos="AlignBottom"/>
<xref delayprefix="" displayhas="cross" master_label="%f-%l%c" offset="0" powerprefix="" showallconfiguredslaves="false" showpowerctc="true" showterminalname="true" slave_label="(%f-%l%c)" slave_offset="0" snapto="label" switchprefix="" type="commutator" xrefpos="AlignBottom"/>
<xref delayprefix="" displayhas="cross" master_label="%f-%l%c" offset="0" powerprefix="" showallconfiguredslaves="false" showpowerctc="true" showterminalname="true" slave_label="(%f-%l%c)" slave_offset="0" snapto="label" switchprefix="" type="plc" xrefpos="AlignBottom"/>
<xref delayprefix="" displayhas="cross" master_label="%f-%l%c" offset="0" powerprefix="" showallconfiguredslaves="false" showpowerctc="true" showterminalname="true" slave_label="(%f-%l%c)" slave_offset="0" snapto="label" switchprefix="" type="protection" xrefpos="AlignBottom"/>
</xrefs>
<conductors_autonums auto_break_conductors="false" current_autonum="" freeze_new_conductors="false"/>
<folio_autonums/>
<element_autonums current_autonum="" freeze_new_elements="false"/>
<guides/>
</newdiagrams>
<diagram author="" auto_page_num="" cols="17" colsize="60" date="null" displayAt="bottom" displaycols="true" displayrows="true" filename="" folio="" freezeNewConductor="false" freezeNewElement="false" height="660" indexrev="" locmach="" order="1" plant="" rows="8" rowsize="80" title="" uuid="{5b0f5e1c-8a43-4c1e-9e57-3f0d0f0a7a01}" version="0.200.1-dev">
<defaultconductor bicolor="false" bus="" cable="" color2="#000000" condsize="1" conductor_color="" conductor_section="" dash-size="1" displaytext="1" formula="" function="" horizontal-alignment="AlignBottom" horizrotatetext="0" num="_" numsize="7" onetextperfolio="0" tension_protocol="" text_color="#000000" type="multi" vertical-alignment="AlignRight" vertirotatetext="270"/>
<inputs>
<input font="Liberation Sans,9,-1,5,50,0,0,0,0,0,Regular" rotation="0" text="FreeAlpha FreeBeta FreeGamma FreeDelta FreeEpsilon" text_width="70" uuid="{5b0f5e1c-8a43-4c1e-9e57-3f0d0f0a7a03}" x="100" y="100"/>
<input font="Liberation Sans,9,-1,5,50,0,0,0,0,0,Regular" rotation="0" text="OpenAlpha OpenBeta OpenGamma OpenDelta OpenEpsilon" uuid="{5b0f5e1c-8a43-4c1e-9e57-3f0d0f0a7a04}" x="100" y="400"/>
<input font="Liberation Sans,9,-1,5,50,0,0,0,0,0,Regular" rotation="0" text="&lt;p align=&quot;center&quot;&gt;CentAlpha CentBeta CentGamma&lt;/p&gt;" text_width="120" uuid="{5b0f5e1c-8a43-4c1e-9e57-3f0d0f0a7a08}" x="400" y="100"/>
</inputs>
</diagram>
<collection>
<category name="import">
<names>
<name lang="en">Imported elements</name>
</names>
</category>
</collection>
</project>
+113
View File
@@ -0,0 +1,113 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include <QtTest>
#include <QDomDocument>
#include <QProcess>
#include <QRegularExpression>
#include <QTemporaryDir>
#include <QTextDocument>
/// Run the qelectrotech binary with arguments, without a display.
static bool runQet(const QStringList &arguments)
{
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
QProcess proc;
proc.setProcessEnvironment(env);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH), arguments);
return proc.waitForFinished(60000)
&& proc.exitStatus() == QProcess::NormalExit
&& proc.exitCode() == 0;
}
/// The number of lines the words starting with prefix are drawn on.
static int drawnLines(const QString &svg, const QString &prefix)
{
const QRegularExpression text_re(
QStringLiteral("<text[^>]*\\by=\"([^\"]+)\"[^>]*>\\s*%1").arg(prefix));
QSet<QString> lines;
auto it = text_re.globalMatch(svg);
while (it.hasNext())
lines.insert(it.next().captured(1));
return lines.size();
}
/**
A free text with a width (text_width on its <input>) keeps it when the
project is saved again, and wraps to it. A text without a width is saved
without the attribute, as before, so older versions read it unchanged.
*/
class tst_freetextwidth : public QObject
{
Q_OBJECT
private slots:
void initTestCase()
{
QVERIFY2(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)), "qelectrotech binary not found");
m_fixture = QFINDTESTDATA("fixtures/free_text_width.qet");
QVERIFY2(!m_fixture.isEmpty(), "fixture project not found");
QVERIFY(m_dir.isValid());
}
void widthIsSavedOnlyWhenSet()
{
const QString out = m_dir.filePath(QStringLiteral("resaved.qet"));
QVERIFY2(runQet({QStringLiteral("--resave"), m_fixture, out}), "--resave failed");
QFile file(out);
QVERIFY(file.open(QIODevice::ReadOnly));
QDomDocument document;
QVERIFY(document.setContent(&file));
QMap<QString, QString> widths; // first word -> text_width
const QDomNodeList inputs = document.elementsByTagName(QStringLiteral("input"));
for (int i = 0 ; i < inputs.size() ; ++i) {
const QDomElement input = inputs.at(i).toElement();
QTextDocument text;
text.setHtml(input.attribute(QStringLiteral("text")));
const QString first_word = text.toPlainText().section(QLatin1Char(' '), 0, 0);
widths.insert(first_word, input.hasAttribute(QStringLiteral("text_width"))
? input.attribute(QStringLiteral("text_width"))
: QStringLiteral("none"));
}
QCOMPARE(widths.value(QStringLiteral("FreeAlpha")), QStringLiteral("70"));
QCOMPARE(widths.value(QStringLiteral("OpenAlpha")), QStringLiteral("none"));
//Centred lines: the width of the user, not the one setHtml() gives
QCOMPARE(widths.value(QStringLiteral("CentAlpha")), QStringLiteral("120"));
}
void textWrapsToItsWidth()
{
QVERIFY2(runQet({QStringLiteral("--export-svg"), m_fixture, m_dir.path()}), "--export-svg failed");
QFile file(m_dir.filePath(QStringLiteral("01_diagram.svg")));
QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text));
const QString svg = QString::fromUtf8(file.readAll());
QVERIFY(drawnLines(svg, QStringLiteral("Free")) > 1);
QCOMPARE(drawnLines(svg, QStringLiteral("Open")), 1);
}
private:
QString m_fixture;
QTemporaryDir m_dir;
};
QTEST_MAIN(tst_freetextwidth)
#include "tst_freetextwidth.moc"