diff --git a/sources/dataBase/projectdatabase.cpp b/sources/dataBase/projectdatabase.cpp index 653bcf172..e93d53061 100644 --- a/sources/dataBase/projectdatabase.cpp +++ b/sources/dataBase/projectdatabase.cpp @@ -1248,6 +1248,8 @@ void projectDataBase::addDrawingItem(QGraphicsItem *item) } else if (auto text = qobject_cast(object)) { connect(text, &IndependentTextItem::uuidChanged, this, &projectDataBase::drawingItemChanged, unique); + //Also changes the height of the text, kept in the row + connect(text, &IndependentTextItem::textWidthChanged, this, &projectDataBase::drawingItemChanged, unique); //Sent by the document, not the item: drawingItemChanged() walks //back up to the item. It is the one signal that catches every way //the text changes -- typing, undo, a script's setTextContent(). @@ -1424,6 +1426,9 @@ bool projectDataBase::writeDrawingItem(QObject *object) query = &m_insert_independent_text_query; query->bindValue(QStringLiteral(":text"), text->toPlainText()); query->bindValue(QStringLiteral(":rotation"), text->rotation()); + //NULL for the automatic width + query->bindValue(QStringLiteral(":text_width"), text->textWidth() > 0 + ? QVariant(text->textWidth()) : QVariant()); } else if (auto image = qobject_cast(object)) { @@ -1667,7 +1672,7 @@ bool projectDataBase::createDataBase() "type VARCHAR(20), color VARCHAR(20), fill VARCHAR(20), " "FOREIGN KEY (diagram_uuid) REFERENCES diagram (uuid))", QStringLiteral("CREATE TABLE independent_text (") + drawing_columns + - "text TEXT, rotation REAL, " + "text TEXT, rotation REAL, text_width REAL, " "FOREIGN KEY (diagram_uuid) REFERENCES diagram (uuid))", QStringLiteral("CREATE TABLE image (") + drawing_columns + "pixel_width INTEGER, pixel_height INTEGER, " @@ -2123,8 +2128,8 @@ void projectDataBase::prepareQuery() ", :type, :color, :fill)"); m_insert_independent_text_query = QSqlQuery(m_data_base); m_insert_independent_text_query.prepare("INSERT OR REPLACE INTO independent_text (" + drawing_columns + - ", text, rotation) VALUES (" + drawing_values + - ", :text, :rotation)"); + ", text, rotation, text_width) VALUES (" + drawing_values + + ", :text, :rotation, :text_width)"); m_insert_image_query = QSqlQuery(m_data_base); m_insert_image_query.prepare("INSERT OR REPLACE INTO image (" + drawing_columns + ", pixel_width, pixel_height) VALUES (" + drawing_values + diff --git a/sources/qetgraphicsitem/diagramtextitem.cpp b/sources/qetgraphicsitem/diagramtextitem.cpp index c94695926..cc44c6b0c 100644 --- a/sources/qetgraphicsitem/diagramtextitem.cpp +++ b/sources/qetgraphicsitem/diagramtextitem.cpp @@ -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); } } diff --git a/sources/qetgraphicsitem/diagramtextitem.h b/sources/qetgraphicsitem/diagramtextitem.h index e0c807cca..323c0db56 100644 --- a/sources/qetgraphicsitem/diagramtextitem.h +++ b/sources/qetgraphicsitem/diagramtextitem.h @@ -110,6 +110,8 @@ class DiagramTextItem : public QGraphicsTextItem void prepareAlignment(); void finishAlignment(); + virtual bool hasUserTextWidth() const {return false;} + protected: bool diff --git a/sources/qetgraphicsitem/independenttextitem.cpp b/sources/qetgraphicsitem/independenttextitem.cpp index bb7af5fa8..8e5d03ef4 100644 --- a/sources/qetgraphicsitem/independenttextitem.cpp +++ b/sources/qetgraphicsitem/independenttextitem.cpp @@ -26,7 +26,9 @@ #include "../utils/qetutils.h" #include +#include #include +#include /** 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,41 @@ 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; + if (qFuzzyCompare(width, m_text_width)) + return; + + 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 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); diff --git a/sources/qetgraphicsitem/independenttextitem.h b/sources/qetgraphicsitem/independenttextitem.h index 85b51ffd7..571c9eeea 100644 --- a/sources/qetgraphicsitem/independenttextitem.h +++ b/sources/qetgraphicsitem/independenttextitem.h @@ -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,23 @@ 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 + + //Hide QGraphicsTextItem::textWidth()/setTextWidth(), which are not + //virtual: called through a QGraphicsTextItem or DiagramTextItem + //pointer they would change the document only, and the width would + //be neither saved nor shown in the properties. Use these, or the + //"textWidth" property. + 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 diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index ec5c53a8b..50d93e01a 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -389,6 +389,18 @@ target_link_libraries(tst_conductorselfretrace PRIVATE Qt::Test) target_compile_definitions(tst_conductorselfretrace PRIVATE "QET_TEST_BINARY_PATH=\"$\"") +# 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=\"$\"") + # 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 @@ -531,6 +543,17 @@ if(QET_HAS_SCRIPTING) target_compile_definitions(tst_scriptlayoutreads PRIVATE "QET_TEST_BINARY_PATH=\"$\"") + # The project database keeps the width of a free text (independent_text), + # read with qet.query() on fixtures/free_text_width.qet. + add_executable( + tst_scriptfreetextwidth + tst_scriptfreetextwidth.cpp) + add_test(NAME tst_scriptfreetextwidth COMMAND tst_scriptfreetextwidth) + add_dependencies(tst_scriptfreetextwidth qelectrotech) + target_link_libraries(tst_scriptfreetextwidth PRIVATE Qt::Test) + target_compile_definitions(tst_scriptfreetextwidth PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") + # QET_SETTINGS_DIR moves the settings into an INI file there (#1178): a # script places a symbol only the folder's settings file can resolve. add_executable( diff --git a/tests/qttest/fixtures/free_text_width.qet b/tests/qttest/fixtures/free_text_width.qet new file mode 100644 index 000000000..cd6eb53ee --- /dev/null +++ b/tests/qttest/fixtures/free_text_width.qet @@ -0,0 +1,41 @@ + + + 10/2/26 + 02-10-2026 + 2026-10-02 + free_text_width + 18:39 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Imported elements + + + + diff --git a/tests/qttest/tst_freetextwidth.cpp b/tests/qttest/tst_freetextwidth.cpp new file mode 100644 index 000000000..5d6c3e801 --- /dev/null +++ b/tests/qttest/tst_freetextwidth.cpp @@ -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 . +*/ +#include +#include +#include +#include +#include +#include + +/// 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("]*\\by=\"([^\"]+)\"[^>]*>\\s*%1").arg(prefix)); + QSet 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 ) 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 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" diff --git a/tests/qttest/tst_scriptfreetextwidth.cpp b/tests/qttest/tst_scriptfreetextwidth.cpp new file mode 100644 index 000000000..7d9b2b396 --- /dev/null +++ b/tests/qttest/tst_scriptfreetextwidth.cpp @@ -0,0 +1,100 @@ +/* + 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 . +*/ +#include +#include +#include +#include +#include +#include + +/** + The project database keeps the width of a free text (text_width in the + independent_text table, NULL for the automatic width), read here with + qet.query() from a script run on fixtures/free_text_width.qet. +*/ +class tst_scriptfreetextwidth : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + /// The rows of the independent_text table, by the first word of the text. + QHash rows() + { + const QString path = m_dir.filePath(QStringLiteral("probe.js")); + const QString home = m_dir.filePath(QStringLiteral("home")); + QDir().mkpath(home); + QFile f(path); + if (!f.open(QIODevice::WriteOnly)) return {}; + f.write("qet.log('PROBE ' + JSON.stringify(qet.query(" + "'SELECT text, text_width, width, height FROM independent_text')));\n"); + f.close(); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), + {QStringLiteral("--run"), path, QFINDTESTDATA("fixtures/free_text_width.qet")}); + if (!proc.waitForFinished(60000)) return {}; + + const QString out = QString::fromUtf8(proc.readAllStandardOutput() + proc.readAllStandardError()); + const QString mark = QStringLiteral("PROBE "); + QHash result; + for (const QString &line : out.split(QLatin1Char('\n'))) { + const int i = line.indexOf(mark); + if (i < 0) continue; + const QJsonArray array = QJsonDocument::fromJson(line.mid(i + mark.size()).toUtf8()).array(); + for (const QJsonValue &row : array) + result.insert(row.toObject().value(QStringLiteral("text")).toString().section(QLatin1Char(' '), 0, 0), + row.toObject()); + } + return result; + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + QVERIFY(!QFINDTESTDATA("fixtures/free_text_width.qet").isEmpty()); + } + + void widthIsInTheDatabase() + { + const QHash r = rows(); + QCOMPARE(r.size(), 3); + + QCOMPARE(r.value(QStringLiteral("FreeAlpha")).value(QStringLiteral("text_width")).toDouble(), 70.0); + QCOMPARE(r.value(QStringLiteral("CentAlpha")).value(QStringLiteral("text_width")).toDouble(), 120.0); + //The automatic width is NULL + const QJsonValue open = r.value(QStringLiteral("OpenAlpha")).value(QStringLiteral("text_width")); + QVERIFY2(open.isNull() || open.toString().isEmpty(), qPrintable(open.toVariant().toString())); + + //The box of the row is the wrapped one: taller than one line + QVERIFY(r.value(QStringLiteral("FreeAlpha")).value(QStringLiteral("height")).toDouble() + > 2 * r.value(QStringLiteral("OpenAlpha")).value(QStringLiteral("height")).toDouble()); + } +}; + +QTEST_APPLESS_MAIN(tst_scriptfreetextwidth) +#include "tst_scriptfreetextwidth.moc"