From 5cbd65c1d43d741bf703de3a454f450d1e80f9f9 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sun, 4 Oct 2026 19:45:03 +0200 Subject: [PATCH 01/20] Undo the crop, colours and original of a picture with its pixels Cropping, keying out a colour, mirroring and replacing a picture set its crop rectangle, transparent colours and original directly and put only the displayed pixmap into the undo command. After Ctrl+Z the picture looked right, but a save still wrote the undone crop and colours, and the picture came back cropped once reopened; a later crop or colour dialog also started from the undone values. The three are now one property, imageSource, changed in the same undo step as the pixmap. The crop itself moves out of the dialog into applyCrop(), so that it can be applied without one. Co-Authored-By: Claude Opus 5.5 --- sources/qetgraphicsitem/diagramimageitem.cpp | 69 +++++++++++++++++--- sources/qetgraphicsitem/diagramimageitem.h | 19 ++++++ 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp index ac82aa5bd..4632e267c 100644 --- a/sources/qetgraphicsitem/diagramimageitem.cpp +++ b/sources/qetgraphicsitem/diagramimageitem.cpp @@ -194,6 +194,30 @@ void DiagramImageItem::setPixmap(const QPixmap &pixmap) { emit pixmapChanged(); } +/** + @brief DiagramImageItem::setImageSource + Set the picture's source -- original, crop rectangle and transparent + colours. Only stores them: the displayed pixmap is a property of its + own, set by the same undo command. +*/ +void DiagramImageItem::setImageSource(const ImageSource &source) +{ + m_base_pixmap = source.base; + m_crop_rect = source.crop; + m_transparent_colors = source.colors; +} + +QVariant DiagramImageItem::imageSourceVariant() const +{ + return QVariant::fromValue(imageSource()); +} + +void DiagramImageItem::setImageSourceVariant(const QVariant &source) +{ + if (source.canConvert()) + setImageSource(source.value()); +} + /** @brief DiagramImageItem::setScaleFactorX / setScaleFactorY / setRotationAngle Matching QetShapeItem's own setters for the identical fields -- @@ -1814,12 +1838,13 @@ void DiagramImageItem::replaceImage() // or region was cropped for the old one, so this starts that // memory fresh rather than carrying over choices that would no // longer make sense. - m_base_pixmap = newPixmap; - m_crop_rect = newPixmap.rect(); - m_transparent_colors.clear(); + const ImageSource oldSource = imageSource(); + const ImageSource newSource{newPixmap, newPixmap.rect(), {}}; auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap); undo->setText(tr("Remplacer une image")); + new QPropertyUndoCommand(this, "imageSource", QVariant::fromValue(oldSource), + QVariant::fromValue(newSource), undo); // Every call here is a separate, deliberate menu action with no // compound child of its own (unlike crop(), which always chains a // pos/rawPivot change and is naturally immune) -- two of them in a @@ -1854,7 +1879,9 @@ void DiagramImageItem::mirror(bool horizontal) // colour values don't change when the image is mirrored, only their // positions, so whatever was already keyed transparent should stay // remembered and still apply correctly to the flipped version. - m_base_pixmap = m_base_pixmap.transformed(flip); + const ImageSource oldSource = imageSource(); + ImageSource newSource = oldSource; + newSource.base = m_base_pixmap.transformed(flip); // m_crop_rect, unlike the colour list, DOES need to change: it's // defined in terms of positions within the base, and those @@ -1864,14 +1891,16 @@ void DiagramImageItem::mirror(bool horizontal) // whether this reads m_base_pixmap's size from before or after the // assignment above). if (horizontal) - m_crop_rect = QRect(m_base_pixmap.width() - m_crop_rect.left() - m_crop_rect.width(), + newSource.crop = QRect(m_base_pixmap.width() - m_crop_rect.left() - m_crop_rect.width(), m_crop_rect.top(), m_crop_rect.width(), m_crop_rect.height()); else - m_crop_rect = QRect(m_crop_rect.left(), m_base_pixmap.height() - m_crop_rect.top() - m_crop_rect.height(), + newSource.crop = QRect(m_crop_rect.left(), m_base_pixmap.height() - m_crop_rect.top() - m_crop_rect.height(), m_crop_rect.width(), m_crop_rect.height()); auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap); undo->setText(horizontal ? tr("Miroir horizontal d'une image") : tr("Miroir vertical d'une image")); + new QPropertyUndoCommand(this, "imageSource", QVariant::fromValue(oldSource), + QVariant::fromValue(newSource), undo); // See replaceImage()'s identical comment: a separate, deliberate // action with no compound child of its own, so a dummy one is // needed to stop two consecutive same-direction mirrors (identical @@ -1919,13 +1948,17 @@ void DiagramImageItem::setTransparentColor() if (dialog.exec() != QDialog::Accepted) return; - m_transparent_colors = dialog.pickedColors(); + const ImageSource oldSource = imageSource(); + ImageSource newSource = oldSource; + newSource.colors = dialog.pickedColors(); const QPixmap oldPixmap = pixmap_; const QPixmap newPixmap = dialog.resultPixmap(); auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap); undo->setText(tr("Définir une couleur transparente")); + new QPropertyUndoCommand(this, "imageSource", QVariant::fromValue(oldSource), + QVariant::fromValue(newSource), undo); // See replaceImage()'s identical comment: a separate, deliberate // action with no compound child of its own, so a dummy one is // needed to stop two consecutive transparency edits (identical @@ -1994,7 +2027,21 @@ void DiagramImageItem::crop() if (dialog.exec() != QDialog::Accepted) return; - const QRect newCropRect = dialog.cropRect(); + applyCrop(dialog.cropRect()); +} + +/** + @brief DiagramImageItem::applyCrop + Show @a newCropRect of the original (in the original's own pixels), + keeping the centre of the kept region where it is on the folio. One + undo step, which restores the pixmap, the position, the pivot and the + crop rectangle together. +*/ +void DiagramImageItem::applyCrop(const QRect &cropRect) +{ + if (!diagram() || diagram()->isReadOnly()) + return; + const QRect newCropRect = cropRect.intersected(m_base_pixmap.rect()); if (newCropRect.isEmpty() || newCropRect == m_crop_rect) return; // nothing actually changed @@ -2009,7 +2056,9 @@ void DiagramImageItem::crop() const QPixmap oldPixmap = pixmap_; const QPixmap newPixmap = computeDisplayPixmap(m_base_pixmap, newCropRect, m_transparent_colors); - m_crop_rect = newCropRect; + const ImageSource oldSource = imageSource(); + ImageSource newSource = oldSource; + newSource.crop = newCropRect; // boundingRect() is exactly QRectF(pixmap_.rect()) (confirmed by // reading the actual implementation, not assumed) -- so the new @@ -2027,6 +2076,8 @@ void DiagramImageItem::crop() undo->setText(tr("Rogner une image")); new QPropertyUndoCommand(this, "pos", oldPos, newPos, undo); new QPropertyUndoCommand(this, "rawPivot", oldPivot, newOriginPoint, undo); + new QPropertyUndoCommand(this, "imageSource", QVariant::fromValue(oldSource), + QVariant::fromValue(newSource), undo); m_pivotIsCustom = false; diagram()->undoStack().push(undo); } diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h index 570d06770..b75389c64 100644 --- a/sources/qetgraphicsitem/diagramimageitem.h +++ b/sources/qetgraphicsitem/diagramimageitem.h @@ -49,6 +49,10 @@ class DiagramImageItem : public QetGraphicsItem { Q_PROPERTY(qreal skewY READ skewY WRITE setSkewY NOTIFY transformChanged) Q_PROPERTY(QPointF pivot READ pivot WRITE setPivot NOTIFY transformChanged) Q_PROPERTY(QString label READ label WRITE setLabel NOTIFY labelChanged) + // The picture's source -- original, crop rectangle, transparent + // colours -- as one value, so that every edit of it (crop, colour + // key, mirror, replace) is undone together with the displayed pixmap. + Q_PROPERTY(QVariant imageSource READ imageSourceVariant WRITE setImageSourceVariant) // A second, deliberately non-compensating property on the SAME // underlying value -- setPivot() (above) intentionally adjusts // pos() to keep the image visually in place, which is exactly @@ -65,6 +69,19 @@ class DiagramImageItem : public QetGraphicsItem { DiagramImageItem(QetGraphicsItem * = nullptr); DiagramImageItem(const QPixmap &pixmap, QetGraphicsItem * = nullptr); ~DiagramImageItem() override; + + struct ImageSource + { + QPixmap base; + QRect crop; + QList colors; + }; + ImageSource imageSource() const { return {m_base_pixmap, m_crop_rect, m_transparent_colors}; } + void setImageSource(const ImageSource &source); + QVariant imageSourceVariant() const; + void setImageSourceVariant(const QVariant &source); + QRect cropRect() const { return m_crop_rect; } + void applyCrop(const QRect &cropRect); // attributes public: @@ -246,4 +263,6 @@ class DiagramImageItem : public QetGraphicsItem { QPointF m_label_scale{1.0, 1.0}; // scale the label rect was last computed for -- see updateLabelScale() bool m_resizeCenterAnchored = false; // decided once, at press time -- see handlerMousePressEvent()'s comment for why, mirroring the identical fix already made for shape creation }; +Q_DECLARE_METATYPE(DiagramImageItem::ImageSource) + #endif From 05b71e07a08d13b45a32323214d388d099030070 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sun, 4 Oct 2026 19:45:03 +0200 Subject: [PATCH 02/20] Add qet.cropImage() and qet.imageCrop() to the script API Crop a picture as the crop tool does, in one undo step, and read its crop rectangle back. tst_imagecropundo uses them on the real binary: a crop that was undone is not saved, and redoing it saves it again. Co-Authored-By: Claude Opus 5.5 --- sources/scripting/qetscriptapi.cpp | 36 ++++++++ sources/scripting/qetscriptapi.h | 2 + tests/qttest/CMakeLists.txt | 12 +++ tests/qttest/tst_imagecropundo.cpp | 129 +++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 tests/qttest/tst_imagecropundo.cpp diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 0de2b7497..380463d93 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -3469,6 +3469,42 @@ bool QetScriptApi::setImageRotation(int folioIndex, int imageIndex, double angle return true; } +/** + @brief QetScriptApi::cropImage + Show only the rectangle (x, y, width, height) of the image's original, + in the original's own pixels, as the crop tool does: one undo step, + the kept region staying where it is on the folio. +*/ +bool QetScriptApi::cropImage(int folioIndex, int imageIndex, int x, int y, int width, int height) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.cropImage: project is read-only")); + return false; + } + const QList list = sortedImages(folioIndex); + if (imageIndex < 0 || imageIndex >= list.count()) { + log(QStringLiteral("qet.cropImage: folio %1 has %2 image(s), no index %3") + .arg(folioIndex).arg(list.count()).arg(imageIndex)); + return false; + } + list.at(imageIndex)->applyCrop(QRect(x, y, width, height)); + return true; +} + +/** + @brief QetScriptApi::imageCrop + @return the image's crop rectangle in its original's pixels, as + "x,y,width,height", or an empty string for no such image. +*/ +QString QetScriptApi::imageCrop(int folioIndex, int imageIndex) const +{ + const QList list = sortedImages(folioIndex); + if (imageIndex < 0 || imageIndex >= list.count()) return QString(); + const QRect r = list.at(imageIndex)->cropRect(); + return QStringLiteral("%1,%2,%3,%4").arg(r.x()).arg(r.y()).arg(r.width()).arg(r.height()); +} + bool QetScriptApi::deleteImage(int folioIndex, int imageIndex) { if (!m_project) return false; diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index 572168a0f..6a75b1b24 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -539,6 +539,8 @@ class QetScriptApi : public QObject Q_INVOKABLE int addImage(int folioIndex, const QString &filePath, double x, double y); Q_INVOKABLE bool setImageScale(int folioIndex, int imageIndex, double factor); Q_INVOKABLE bool setImageRotation(int folioIndex, int imageIndex, double angle); + Q_INVOKABLE bool cropImage(int folioIndex, int imageIndex, int x, int y, int width, int height); + Q_INVOKABLE QString imageCrop(int folioIndex, int imageIndex) const; Q_INVOKABLE bool deleteImage(int folioIndex, int imageIndex); Q_INVOKABLE int addPdfPage(int folioIndex, const QString &pdfPath, int pageNumber, int dpi, double x, double y); diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 3b7e8c2b5..ec5c53a8b 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -593,6 +593,18 @@ if(QET_HAS_SCRIPTING) "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # Undoing a crop of a picture restores its crop rectangle too, so a + # project saved after the undo does not record the undone crop. + add_executable( + tst_imagecropundo + tst_imagecropundo.cpp) + add_test(NAME tst_imagecropundo COMMAND tst_imagecropundo) + add_dependencies(tst_imagecropundo qelectrotech) + target_link_libraries(tst_imagecropundo PRIVATE Qt::Test Qt::Gui) + target_compile_definitions(tst_imagecropundo PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # The same symbol placed twice in a project whose collection does not # start with its "import" category (examples/lmdg.qet). add_executable( diff --git a/tests/qttest/tst_imagecropundo.cpp b/tests/qttest/tst_imagecropundo.cpp new file mode 100644 index 000000000..403c3dd59 --- /dev/null +++ b/tests/qttest/tst_imagecropundo.cpp @@ -0,0 +1,129 @@ +/* + 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 +#include +#include +#include +#include + +/** + Undoing a crop restores the crop rectangle too, not only the pixels + shown: before, a project saved after Ctrl+Z still recorded the undone + crop, and the picture came back cropped once reopened. Runs the real + binary on a script: add a picture, crop it, undo, save; redo, save. +*/ +class tst_imagecropundo : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + QJsonObject run(const QString &script, const QString &project) + { + 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(script.toUtf8()); + 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")); + env.insert(QStringLiteral("TMPDIR"), m_dir.path()); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), + {QStringLiteral("--run"), path, project}); + if (!proc.waitForFinished(120000)) return {}; + const QString out = QString::fromUtf8(proc.readAllStandardOutput() + + proc.readAllStandardError()); + const QString mark = QStringLiteral("PROBE "); + for (const QString &line : out.split(QLatin1Char('\n'))) { + const int i = line.indexOf(mark); + if (i >= 0) + return QJsonDocument::fromJson(line.mid(i + mark.size()).toUtf8()).object(); + } + return {}; + } + + static QByteArray read(const QString &path) + { + QFile f(path); + return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray(); + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + QImage img(40, 30, QImage::Format_RGB32); + img.fill(Qt::darkGreen); + QVERIFY(img.save(m_dir.filePath(QStringLiteral("pic.png")))); + } + + void undoneCropIsNotSaved() + { + const QString undone = m_dir.filePath(QStringLiteral("undone.qet")); + const QString redone = m_dir.filePath(QStringLiteral("redone.qet")); + const QString script = QStringLiteral(R"JS( +var i = qet.addImage(0, '%1', 100, 100); +var r = {full: qet.imageCrop(0, i)}; +r.cropped_ok = qet.cropImage(0, i, 10, 5, 20, 10); +r.cropped = qet.imageCrop(0, i); +qet.undo(); +r.undone = qet.imageCrop(0, i); +qet.save('%2'); +qet.redo(); +r.redone = qet.imageCrop(0, i); +qet.save('%3'); +qet.log('PROBE ' + JSON.stringify(r)); +)JS").arg(m_dir.filePath(QStringLiteral("pic.png")), undone, redone); + + const QJsonObject r = run(script, QStringLiteral(QET_EXAMPLES_DIR "/741.qet")); + QVERIFY2(!r.isEmpty(), "the script logged nothing"); + QCOMPARE(r.value("full").toString(), QStringLiteral("0,0,40,30")); + QVERIFY(r.value("cropped_ok").toBool()); + QCOMPARE(r.value("cropped").toString(), QStringLiteral("10,5,20,10")); + QCOMPARE(r.value("undone").toString(), QStringLiteral("0,0,40,30")); + QCOMPARE(r.value("redone").toString(), QStringLiteral("10,5,20,10")); + + const QByteArray undone_xml = read(undone); + QVERIFY(undone_xml.contains("]*)/>")).match(QString::fromUtf8(read(redone))); + QVERIFY2(crop.hasMatch(), "the redone crop was not saved"); + for (const char *attribute : {R"(x="10")", R"(y="5")", R"(w="20")", R"(h="10")"}) + QVERIFY2(crop.captured(1).contains(QLatin1String(attribute)), attribute); + } +}; + +QTEST_GUILESS_MAIN(tst_imagecropundo) +#include "tst_imagecropundo.moc" From 8e648f7332a61c4a3538b6d94b3fc8a906f6c70d Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 22:54:49 +1300 Subject: [PATCH 03/20] Add the missing folio editor commands to the Shortcuts page 44 commands in the folio editor's menus and toolbars had no ShortcutManager id, so they could not be given a key, found by the command search or put on the shortcut bar: Export to PDF, the grid and guide toggles, Selection/View mode, most of the Project menu, Tile and Cascade, the Help menu items, and others. Register each with no default key, as the add-item tools already are. What's This keeps the Shift+F1 that Qt gives it. No other command had a key before, so nothing changes for anyone who has not set one. Left out: the option lists inside submenus (text grid step, the Show menu's item kinds, background colours). They are choices, not commands. First stage of discussion #1319. Co-Authored-By: Claude Opus 5.5 --- sources/qetdiagrameditor.cpp | 37 ++++++++++++++++++++++++++++++++++++ sources/qetmainwindow.cpp | 10 ++++++++++ 2 files changed, 47 insertions(+) diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index c7faddfa6..bf6b01396 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -364,6 +364,7 @@ void QETDiagramEditor::setUpActions() //export to pdf m_export_to_pdf = new QAction(QET::Icons::PDF, tr("Exporter en pdf"), this); + ShortcutManager::instance().registerAction(m_export_to_pdf, "diagrameditor.export_to_pdf", tr("Éditeur de schémas"), QKeySequence()); m_export_to_pdf->setStatusTip(tr("Exporte un ou plusieurs folios du projet courant", "status bar tip")); connect(m_export_to_pdf, &QAction::triggered, [this] () { auto project = currentProject(); @@ -480,6 +481,7 @@ void QETDiagramEditor::setUpActions() //setting, so it does not need a diagram open or anything //selected the way m_duplicate itself does. m_configure_duplicate = new QAction(tr("Configurer la duplication..."), this); + ShortcutManager::instance().registerAction(m_configure_duplicate, "diagrameditor.configure_duplicate", tr("Éditeur de schémas"), QKeySequence()); m_configure_duplicate->setStatusTip(tr("Choisir l'espacement et la direction utilisés par Dupliquer", "status bar tip")); connect(m_configure_duplicate, &QAction::triggered, [this]() { DuplicateOffsetDialog dialog(this); @@ -515,6 +517,7 @@ void QETDiagramEditor::setUpActions() //AutoBreakConductor m_auto_break_conductor = new QAction (QET::Icons::Conductor, tr("Coupure automatique de conducteur(s)","Tool tip of auto break conductor"), this); + ShortcutManager::instance().registerAction(m_auto_break_conductor, "diagrameditor.auto_break_conductor", tr("Éditeur de schémas"), QKeySequence()); m_auto_break_conductor->setStatusTip (tr("Couper automatiquement les conducteurs existants lors du placement d'un élément", "Status tip of auto break conductor")); m_auto_break_conductor->setCheckable (true); { @@ -533,6 +536,7 @@ void QETDiagramEditor::setUpActions() //Draw or not the background grid m_draw_grid = new QAction ( QET::Icons::Grid, tr("Afficher la grille"), this); + ShortcutManager::instance().registerAction(m_draw_grid, "diagrameditor.draw_grid", tr("Éditeur de schémas"), QKeySequence()); m_draw_grid->setStatusTip(tr("Affiche ou masque la grille des folios")); QSettings settings; m_draw_grid->setCheckable(true); @@ -574,6 +578,7 @@ void QETDiagramEditor::setUpActions() // Draw or not the custom guides m_draw_guides = new QAction ( QIcon::fromTheme("guides"), tr("Afficher les guides"), this); + ShortcutManager::instance().registerAction(m_draw_guides, "diagrameditor.draw_guides", tr("Éditeur de schémas"), QKeySequence()); m_draw_guides->setStatusTip(tr("Affiche ou masque les guides")); m_draw_guides->setCheckable(true); m_draw_guides->setChecked(settings.value("diagrameditor/guides_display_startup", false).toBool()); @@ -586,6 +591,7 @@ void QETDiagramEditor::setUpActions() //Keep the column numbers and row letters of the folio in sight m_cell_rulers = new QAction(tr("Garder les en-têtes visibles"), this); + ShortcutManager::instance().registerAction(m_cell_rulers, "diagrameditor.cell_rulers", tr("Éditeur de schémas"), QKeySequence()); m_cell_rulers->setStatusTip(tr("Garde les numéros de colonne et les lettres de ligne du folio visibles au bord de la vue")); m_cell_rulers->setCheckable(true); m_cell_rulers->setChecked(settings.value("diagrameditor/cell_rulers", false).toBool()); @@ -598,6 +604,7 @@ void QETDiagramEditor::setUpActions() //Draw the limits of the folio columns and rows across the drawing m_cell_lines = new QAction(tr("Afficher les limites des cases"), this); + ShortcutManager::instance().registerAction(m_cell_lines, "diagrameditor.cell_lines", tr("Éditeur de schémas"), QKeySequence()); m_cell_lines->setStatusTip(tr("Trace les limites des colonnes et des lignes du folio sur le schéma, à l'écran seulement")); m_cell_lines->setCheckable(true); m_cell_lines->setChecked(settings.value("diagrameditor/cell_lines", false).toBool()); @@ -652,6 +659,7 @@ void QETDiagramEditor::setUpActions() //Edit current project properties m_project_edit_properties = new QAction(QET::Icons::ProjectProperties, tr("Propriétés du projet"), this); + ShortcutManager::instance().registerAction(m_project_edit_properties, "diagrameditor.project_edit_properties", tr("Éditeur de schémas"), QKeySequence()); connect(m_project_edit_properties, &QAction::triggered, [this]() { editProjectProperties(currentProjectView()); }); @@ -667,10 +675,12 @@ void QETDiagramEditor::setUpActions() //Remove current folio from current project m_remove_diagram_from_project = new QAction(QET::Icons::DiagramDelete, tr("Supprimer le folio"), this); + ShortcutManager::instance().registerAction(m_remove_diagram_from_project, "diagrameditor.remove_diagram_from_project", tr("Éditeur de schémas"), QKeySequence()); connect(m_remove_diagram_from_project, &QAction::triggered, this, &QETDiagramEditor::removeDiagramFromProject); //Clean the current project m_clean_project = new QAction(QET::Icons::EditClear, tr("Nettoyer le projet"), this); + ShortcutManager::instance().registerAction(m_clean_project, "diagrameditor.clean_project", tr("Éditeur de schémas"), QKeySequence()); connect(m_clean_project, &QAction::triggered, [this]() { if (ProjectView *current_project = currentProjectView()) { if (current_project->cleanProject()) { @@ -681,6 +691,7 @@ void QETDiagramEditor::setUpActions() //Export nomenclature to CSV m_csv_export = new QAction(QET::Icons::DocumentSpreadsheet, tr("Exporter au format CSV"), this); + ShortcutManager::instance().registerAction(m_csv_export, "diagrameditor.csv_export", tr("Éditeur de schémas"), QKeySequence()); connect(m_csv_export, &QAction::triggered, [this]() { BOMExportDialog bom(currentProjectView()->project(), this); bom.exec(); @@ -688,6 +699,7 @@ void QETDiagramEditor::setUpActions() //Add a nomenclature item m_add_nomenclature = new QAction(QET::Icons::TableOfContent, tr("Ajouter une nomenclature"), this); + ShortcutManager::instance().registerAction(m_add_nomenclature, "diagrameditor.add_nomenclature", tr("Éditeur de schémas"), QKeySequence()); connect(m_add_nomenclature, &QAction::triggered, this, [this]() { if(this->currentDiagramView()) { QetGraphicsTableFactory::createAndAddNomenclature(this->currentDiagramView()->diagram()); @@ -696,6 +708,7 @@ void QETDiagramEditor::setUpActions() //Add a summary item m_add_summary = new QAction(QET::Icons::TableOfContent, tr("Ajouter un sommaire"), this); + ShortcutManager::instance().registerAction(m_add_summary, "diagrameditor.add_summary", tr("Éditeur de schémas"), QKeySequence()); connect(m_add_summary, &QAction::triggered, this, [this]() { if(this->currentDiagramView()) { QetGraphicsTableFactory::createAndAddSummary(this->currentDiagramView()->diagram()); @@ -703,6 +716,7 @@ void QETDiagramEditor::setUpActions() }); m_terminal_strip_dialog = new QAction(QET::Icons::TerminalStrip, tr("Gestionnaire de borniers (DEV)"), this); + ShortcutManager::instance().registerAction(m_terminal_strip_dialog, "diagrameditor.terminal_strip_dialog", tr("Éditeur de schémas"), QKeySequence()); connect(m_terminal_strip_dialog, &QAction::triggered, this, [this]() { if (auto project = this->currentProject()) @@ -713,10 +727,12 @@ void QETDiagramEditor::setUpActions() //Launch the plugin of terminal generator m_project_terminalBloc = new QAction(QET::Icons::TerminalStrip, tr("Lancer le plugin de création de borniers"), this); + ShortcutManager::instance().registerAction(m_project_terminalBloc, "diagrameditor.terminal_strip_plugin", tr("Éditeur de schémas"), QKeySequence()); connect(m_project_terminalBloc, &QAction::triggered, this, &QETDiagramEditor::generateTerminalBlock); //Export conductor num to csv m_project_export_conductor_num = new QAction(QET::Icons::DocumentSpreadsheet, tr("Exporter la liste des noms de conducteurs"), this); + ShortcutManager::instance().registerAction(m_project_export_conductor_num, "diagrameditor.export_conductor_names", tr("Éditeur de schémas"), QKeySequence()); connect(m_project_export_conductor_num, &QAction::triggered, [this]() { QETProject *project = this->currentProject(); if (project) @@ -727,6 +743,7 @@ void QETDiagramEditor::setUpActions() }); // Export wiring list to CSV m_project_export_wiring_list = new QAction(QET::Icons::DocumentSpreadsheet, tr("Exporter le plan de câblage"), this); + ShortcutManager::instance().registerAction(m_project_export_wiring_list, "diagrameditor.export_wiring_list", tr("Éditeur de schémas"), QKeySequence()); connect(m_project_export_wiring_list, &QAction::triggered, [this]() { QETProject *project = this->currentProject(); if (project) @@ -738,6 +755,7 @@ void QETDiagramEditor::setUpActions() // Show the wiring list read from the project database m_project_wiring_list_view = new QAction(QET::Icons::DocumentSpreadsheet, tr("Liste de câblage (base de données)"), this); + ShortcutManager::instance().registerAction(m_project_wiring_list_view, "diagrameditor.wiring_list_view", tr("Éditeur de schémas"), QKeySequence()); connect(m_project_wiring_list_view, &QAction::triggered, [this]() { QETProject *project = this->currentProject(); if (project) @@ -749,10 +767,12 @@ void QETDiagramEditor::setUpActions() // Terminal Numbering m_terminal_numbering = new QAction(QET::Icons::TerminalStrip, tr("Numérotation automatique des bornes"), this); + ShortcutManager::instance().registerAction(m_terminal_numbering, "diagrameditor.terminal_numbering", tr("Éditeur de schémas"), QKeySequence()); connect(m_terminal_numbering, &QAction::triggered, this, &QETDiagramEditor::slot_terminalNumbering); // Reload element drawings from their current definition (bugtracker #802) m_reload_element_drawings = new QAction(QET::Icons::ViewRefresh, tr("Recharger les dessins des éléments"), this); + ShortcutManager::instance().registerAction(m_reload_element_drawings, "diagrameditor.reload_element_drawings", tr("Éditeur de schémas"), QKeySequence()); m_reload_element_drawings->setStatusTip( tr("Redessine chaque élément placé d'après sa définition actuelle," " sans avoir à fermer et rouvrir le projet (action non annulable)")); @@ -761,6 +781,7 @@ void QETDiagramEditor::setUpActions() #ifdef QET_HAS_SCRIPTING // Run a JavaScript macro against the current project (bugtracker #162). m_run_script = new QAction(tr("Exécuter un script..."), this); + ShortcutManager::instance().registerAction(m_run_script, "diagrameditor.run_script", tr("Éditeur de schémas"), QKeySequence()); m_run_script->setStatusTip( tr("Exécute un script JavaScript sur le projet courant (voir qet.*" " dans le script pour l'API disponible)")); @@ -811,6 +832,7 @@ void QETDiagramEditor::setUpActions() //Stored scripts are files in a folder, written by hand, by the //script manager or by an assistant: open it to add one. m_open_scripts_folder = new QAction(QET::Icons::FolderOpen, tr("Ouvrir le dossier des scripts"), this); + ShortcutManager::instance().registerAction(m_open_scripts_folder, "diagrameditor.open_scripts_folder", tr("Éditeur de schémas"), QKeySequence()); m_open_scripts_folder->setStatusTip( tr("Chaque fichier .js de ce dossier qui commence par un en-tête" " // ==QETScript== devient un bouton")); @@ -822,6 +844,7 @@ void QETDiagramEditor::setUpActions() #ifdef QET_EXPORT_PROJECT_DB m_export_project_db = new QAction(QET::Icons::DocumentSpreadsheet, tr("Exporter la base de donnée interne du projet"), this); + ShortcutManager::instance().registerAction(m_export_project_db, "diagrameditor.export_project_db", tr("Éditeur de schémas"), QKeySequence()); connect(m_export_project_db, &QAction::triggered, [this]() { projectDataBase::exportDb(this->currentProject()->dataBase(), this); }); @@ -829,11 +852,13 @@ void QETDiagramEditor::setUpActions() //MDI view style m_tabbed_view_mode = new QAction(tr("en utilisant des onglets"), this); + ShortcutManager::instance().registerAction(m_tabbed_view_mode, "diagrameditor.tabbed_view_mode", tr("Éditeur de schémas"), QKeySequence()); m_tabbed_view_mode->setStatusTip(tr("Présente les différents projets ouverts des onglets", "status bar tip")); m_tabbed_view_mode->setCheckable(true); connect(m_tabbed_view_mode, &QAction::triggered, this, &QETDiagramEditor::setTabbedMode); m_windowed_view_mode = new QAction(tr("en utilisant des fenêtres"), this); + ShortcutManager::instance().registerAction(m_windowed_view_mode, "diagrameditor.windowed_view_mode", tr("Éditeur de schémas"), QKeySequence()); m_windowed_view_mode->setStatusTip(tr("Présente les différents projets ouverts dans des sous-fenêtres", "status bar tip")); m_windowed_view_mode->setCheckable(true); connect(m_windowed_view_mode, &QAction::triggered, this, &QETDiagramEditor::setWindowedMode); @@ -844,15 +869,18 @@ void QETDiagramEditor::setUpActions() m_group_view_mode -> setExclusive(true); m_tile_window = new QAction(tr("&Mosaïque"), this); + ShortcutManager::instance().registerAction(m_tile_window, "diagrameditor.tile_windows", tr("Éditeur de schémas"), QKeySequence()); m_tile_window->setStatusTip(tr("Dispose les fenêtres en mosaïque", "status bar tip")); connect(m_tile_window, &QAction::triggered, &m_workspace, &QMdiArea::tileSubWindows); m_cascade_window = new QAction(tr("&Cascade"), this); + ShortcutManager::instance().registerAction(m_cascade_window, "diagrameditor.cascade_windows", tr("Éditeur de schémas"), QKeySequence()); m_cascade_window->setStatusTip(tr("Dispose les fenêtres en cascade", "status bar tip")); connect(m_cascade_window, &QAction::triggered, &m_workspace, &QMdiArea::cascadeSubWindows); //Switch selection/view mode m_mode_selection = new QAction(QET::Icons::PartSelect, tr("Mode Selection"), this); + ShortcutManager::instance().registerAction(m_mode_selection, "diagrameditor.mode_selection", tr("Éditeur de schémas"), QKeySequence()); m_mode_selection->setStatusTip(tr("Permet de sélectionner les éléments", "status bar tip")); m_mode_selection->setCheckable(true); m_mode_selection->setChecked(true); @@ -865,6 +893,7 @@ void QETDiagramEditor::setUpActions() }); m_mode_visualise = new QAction(QET::Icons::ViewMove, tr("Mode Visualisation"), this); + ShortcutManager::instance().registerAction(m_mode_visualise, "diagrameditor.mode_visualise", tr("Éditeur de schémas"), QKeySequence()); m_mode_visualise->setStatusTip(tr("Permet de visualiser le folio sans pouvoir le modifier", "status bar tip")); m_mode_visualise->setCheckable(true); connect(m_mode_visualise, &QAction::triggered, [this]() { @@ -936,6 +965,12 @@ void QETDiagramEditor::setUpActions() add_row ->setData("add_row"); remove_row ->setData("remove_row"); + for (QAction *action : m_row_column_actions_group.actions()) { + ShortcutManager::instance().registerAction( + action, "diagrameditor." + action->data().toString(), + tr("Éditeur de schémas"), QKeySequence()); + } + connect(&m_row_column_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::rowColumnGroupTriggered); //Selections Actions (related to a selected item) @@ -953,6 +988,8 @@ void QETDiagramEditor::setUpActions() ShortcutManager::instance().registerAction(m_rotate_selection, "diagrameditor.rotate_selection", tr("Éditeur de schémas"), Qt::Key_Space); ShortcutManager::instance().registerAction(m_rotate_group_selection, "diagrameditor.rotate_group_selection", tr("Éditeur de schémas"), Qt::SHIFT | Qt::Key_Space); ShortcutManager::instance().registerAction(m_rotate_texts, "diagrameditor.rotate_texts", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_Space); + ShortcutManager::instance().registerAction(m_find_element, "diagrameditor.find_element", tr("Éditeur de schémas"), QKeySequence()); + ShortcutManager::instance().registerAction(m_group_selected_texts, "diagrameditor.group_selected_texts", tr("Éditeur de schémas"), QKeySequence()); ShortcutManager::instance().registerAction(m_edit_selection, "diagrameditor.edit_selection", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_E); //Re-enter placement mode with the element placed last. Bare A rather diff --git a/sources/qetmainwindow.cpp b/sources/qetmainwindow.cpp index 36fccebf8..3ee58586a 100644 --- a/sources/qetmainwindow.cpp +++ b/sources/qetmainwindow.cpp @@ -69,6 +69,7 @@ void QETMainWindow::initCommonActions() QETApp *qet_app = QETApp::instance(); configure_action_ = new QAction(QET::Icons::Configure, tr("&Configurer QElectroTech"), this); + ShortcutManager::instance().registerAction(configure_action_, "mainwindow.configure", tr("Général"), QKeySequence()); configure_action_ -> setStatusTip(tr("Permet de régler différents paramètres de QElectroTech", "status bar tip")); connect(configure_action_, &QAction::triggered, [qet_app]() { @@ -107,8 +108,10 @@ void QETMainWindow::initCommonActions() connect(fullscreen_action_, &QAction::triggered, this, &QETMainWindow::toggleFullScreen); whatsthis_action_ = QWhatsThis::createAction(this); + ShortcutManager::instance().registerAction(whatsthis_action_, "mainwindow.whats_this", tr("Général"), Qt::SHIFT | Qt::Key_F1); about_qet_ = new QAction(QET::Icons::QETLogo, tr("À &propos de QElectroTech"), this); + ShortcutManager::instance().registerAction(about_qet_, "mainwindow.about_qet", tr("Général"), QKeySequence()); about_qet_ -> setStatusTip(tr("Affiche des informations sur QElectroTech", "status bar tip")); connect(about_qet_, &QAction::triggered, qet_app, &QETApp::aboutQET); @@ -123,6 +126,7 @@ void QETMainWindow::initCommonActions() ShortcutManager::instance().registerAction(manual_online_, "mainwindow.manual_online", tr("Général"), Qt::Key_F1); connect_ai_ = new QAction(tr("Connecter un assistant IA..."), this); + ShortcutManager::instance().registerAction(connect_ai_, "mainwindow.connect_ai", tr("Général"), QKeySequence()); connect_ai_ -> setStatusTip(tr("Affiche la configuration qui permet à un assistant IA d'utiliser QElectroTech", "status bar tip")); connect(connect_ai_, &QAction::triggered, this, [this]() { AiAssistantDialog dialog(this); @@ -130,6 +134,7 @@ void QETMainWindow::initCommonActions() }); youtube_ = new QAction(QET::Icons::QETVideo, tr("Chaine Youtube"), this); + ShortcutManager::instance().registerAction(youtube_, "mainwindow.youtube", tr("Général"), QKeySequence()); youtube_ -> setStatusTip(tr("Lance le navigateur par défaut vers la chaine Youtube de QElectroTech", "status bar tip")); connect(youtube_, &QAction::triggered, [](bool) { @@ -138,9 +143,11 @@ void QETMainWindow::initCommonActions() }); upgrade_ = new QAction(QET::Icons::QETDownload, tr("Télécharger une nouvelle version (dev)"), this); + ShortcutManager::instance().registerAction(upgrade_, "mainwindow.download_windows", tr("Général"), QKeySequence()); upgrade_ -> setStatusTip(tr("Lance le navigateur par défaut vers le dépot Nightly en ligne de QElectroTech", "status bar tip")); upgrade_M = new QAction(QET::Icons::QETDownload, tr("Télécharger une nouvelle version (dev)"), this); + ShortcutManager::instance().registerAction(upgrade_M, "mainwindow.download_mac", tr("Général"), QKeySequence()); upgrade_M -> setStatusTip(tr("Lance le navigateur par défaut vers le dépot Nightly en ligne de QElectroTech", "status bar tip")); connect(upgrade_, &QAction::triggered, [](bool) { @@ -154,6 +161,7 @@ void QETMainWindow::initCommonActions() }); donate_ = new QAction(QET::Icons::QETDonate, tr("Soutenir le projet par un don"), this); + ShortcutManager::instance().registerAction(donate_, "mainwindow.donate", tr("Général"), QKeySequence()); donate_ -> setStatusTip(tr("Soutenir le projet QElectroTech par un don", "status bar tip")); connect(donate_, &QAction::triggered, [](bool) { @@ -162,10 +170,12 @@ void QETMainWindow::initCommonActions() }); about_qt_ = new QAction(QET::Icons::QtLogo, tr("À propos de &Qt"), this); + ShortcutManager::instance().registerAction(about_qt_, "mainwindow.about_qt", tr("Général"), QKeySequence()); about_qt_ -> setStatusTip(tr("Affiche des informations sur la bibliothèque Qt", "status bar tip")); connect(about_qt_, &QAction::triggered, qApp, &QApplication::aboutQt); diagnostics_action_ = new QAction(QET::Icons::DialogInformation, tr("Enregistrer un rapport de diagnostic..."), this); + ShortcutManager::instance().registerAction(diagnostics_action_, "mainwindow.diagnostics_report", tr("Général"), QKeySequence()); diagnostics_action_ -> setStatusTip(tr("Génère un rapport avec les derniers messages de journalisation, pour l'inclure dans un rapport de bug", "status bar tip")); connect(diagnostics_action_, &QAction::triggered, this, []() { QETApp::instance()->showDiagnosticsReport(); From 0513344b92533fe5690978cfa9fdbbed946ef317 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Mon, 5 Oct 2026 13:01:30 +0200 Subject: [PATCH 04/20] Address review: drop dummy undo children, applyCrop() returns bool - replaceImage(), mirror() and setTransparentColor() no longer add a dummy QUndoCommand child: the imageSource child already keeps QPropertyUndoCommand::mergeWith() from merging two of them. - The explanation of the position and pivot maths moves from crop() to applyCrop(), where that code now lives; the stale older doc block of crop() goes. - applyCrop() returns false when nothing was cropped, and qet.cropImage() passes that on. tst_imagecropundo checks it for the current crop, an empty rectangle and one outside the picture. Co-Authored-By: Claude Opus 5.5 --- sources/qetgraphicsitem/diagramimageitem.cpp | 94 ++++++-------------- sources/qetgraphicsitem/diagramimageitem.h | 2 +- sources/scripting/qetscriptapi.cpp | 6 +- tests/qttest/tst_imagecropundo.cpp | 8 ++ 4 files changed, 40 insertions(+), 70 deletions(-) diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp index 4632e267c..00f8b030b 100644 --- a/sources/qetgraphicsitem/diagramimageitem.cpp +++ b/sources/qetgraphicsitem/diagramimageitem.cpp @@ -1845,15 +1845,6 @@ void DiagramImageItem::replaceImage() undo->setText(tr("Remplacer une image")); new QPropertyUndoCommand(this, "imageSource", QVariant::fromValue(oldSource), QVariant::fromValue(newSource), undo); - // Every call here is a separate, deliberate menu action with no - // compound child of its own (unlike crop(), which always chains a - // pos/rawPivot change and is naturally immune) -- two of them in a - // row would carry the exact same object, property, and text, which - // is indistinguishable from a legitimate merge to - // QPropertyUndoCommand::mergeWith(). A dummy child (already treated - // as "never merge" by that check) keeps each one its own, separate - // undo step regardless. - new QUndoCommand(undo); diagram()->undoStack().push(undo); } @@ -1901,11 +1892,6 @@ void DiagramImageItem::mirror(bool horizontal) undo->setText(horizontal ? tr("Miroir horizontal d'une image") : tr("Miroir vertical d'une image")); new QPropertyUndoCommand(this, "imageSource", QVariant::fromValue(oldSource), QVariant::fromValue(newSource), undo); - // See replaceImage()'s identical comment: a separate, deliberate - // action with no compound child of its own, so a dummy one is - // needed to stop two consecutive same-direction mirrors (identical - // object, property, and text) from silently merging into one. - new QUndoCommand(undo); diagram()->undoStack().push(undo); } @@ -1959,34 +1945,9 @@ void DiagramImageItem::setTransparentColor() undo->setText(tr("Définir une couleur transparente")); new QPropertyUndoCommand(this, "imageSource", QVariant::fromValue(oldSource), QVariant::fromValue(newSource), undo); - // See replaceImage()'s identical comment: a separate, deliberate - // action with no compound child of its own, so a dummy one is - // needed to stop two consecutive transparency edits (identical - // object, property, and text) from silently merging into one. - new QUndoCommand(undo); diagram()->undoStack().push(undo); } -/** - @brief DiagramImageItem::crop - Context-menu action: opens ImageCropDialog against pixmap_ (the - current, already colour-keyed display, so cropping is WYSIWYG - against whatever is actually visible), then applies the chosen - rectangle to both pixmap_ and m_base_pixmap together -- kept in - sync the same way mirror() keeps them in sync, since cropping is a - permanent, geometric change to the image's own content, unlike - setTransparentColor()'s non-destructive colour keying. - - pos() also needs adjusting, not just pixmap_: setPixmap() (called - via the "pixmap" undo command below) recomputes - transformOriginPoint() from the new, smaller boundingRect(), but - pos() itself is untouched by that -- without fixing it up here too, - the surviving content would visually jump to wherever local (0,0) - happens to land after shrinking, rather than staying exactly where - it already was. Chained into one undo step together with the pixmap - change, since undoing a crop has to restore both, or the restored - (larger) image ends up in the wrong place. -*/ /** @brief DiagramImageItem::crop Context-menu action: opens ImageCropDialog against m_base_pixmap @@ -1995,27 +1956,7 @@ void DiagramImageItem::setTransparentColor() destructive: nothing about the original content is ever discarded, only which region of it is currently being shown, exactly the same principle setTransparentColor() already follows for its own choices. - Recomputes pixmap_ via computeDisplayPixmap() so any already-picked - transparent colours are correctly re-applied to the newly-cropped - region, rather than lost (the crop dialog itself knows nothing - about them). - - pos() also needs adjusting, not just pixmap_: setPixmap() (called - via the "pixmap" undo command below) recomputes - transformOriginPoint() from the new boundingRect(), but pos() itself - is untouched by that -- without fixing it up here too, the - surviving content would visually jump to wherever local (0,0) ends - up after the crop rect changes, rather than staying exactly where - it already was. This has to work whether this is the first crop - ever applied or an adjustment of an existing one, so the position - math is always done relative to the CURRENT crop rect (m_crop_rect, - before it's updated below) -- when there's no previous crop, that's - simply the whole base, which is what the very first version of this - method assumed unconditionally. - - Chained into one undo step together with the pixmap change, since - undoing a crop has to restore both, or the restored (larger) image - ends up in the wrong place. + The crop itself is done by applyCrop(). */ void DiagramImageItem::crop() { @@ -2032,18 +1973,36 @@ void DiagramImageItem::crop() /** @brief DiagramImageItem::applyCrop - Show @a newCropRect of the original (in the original's own pixels), - keeping the centre of the kept region where it is on the folio. One - undo step, which restores the pixmap, the position, the pivot and the - crop rectangle together. + Show @a cropRect of the original (in the original's own pixels), + keeping the centre of the kept region where it is on the folio. + Recomputes pixmap_ via computeDisplayPixmap() so any already-picked + transparent colours are correctly re-applied to the newly-cropped + region, rather than lost. + + pos() also needs adjusting, not just pixmap_: setPixmap() (called + via the "pixmap" undo command below) recomputes + transformOriginPoint() from the new boundingRect(), but pos() itself + is untouched by that -- without fixing it up here too, the + surviving content would visually jump to wherever local (0,0) ends + up after the crop rect changes, rather than staying exactly where + it already was. This has to work whether this is the first crop + ever applied or an adjustment of an existing one, so the position + math is always done relative to the CURRENT crop rect (m_crop_rect, + before it's updated below) -- when there's no previous crop, that's + simply the whole base. + + One undo step, which restores the pixmap, the position, the pivot + and the crop rectangle together. + @return false if nothing was cropped: read-only folio, or a + rectangle that is empty, outside the original, or the current one. */ -void DiagramImageItem::applyCrop(const QRect &cropRect) +bool DiagramImageItem::applyCrop(const QRect &cropRect) { if (!diagram() || diagram()->isReadOnly()) - return; + return false; const QRect newCropRect = cropRect.intersected(m_base_pixmap.rect()); if (newCropRect.isEmpty() || newCropRect == m_crop_rect) - return; // nothing actually changed + return false; // nothing actually changed // newCropRect is in m_base_pixmap's own coordinates; converting its // center into the CURRENT local space (pixmap_'s own coordinates, @@ -2080,4 +2039,5 @@ void DiagramImageItem::applyCrop(const QRect &cropRect) QVariant::fromValue(newSource), undo); m_pivotIsCustom = false; diagram()->undoStack().push(undo); + return true; } diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h index b75389c64..b3263f9b3 100644 --- a/sources/qetgraphicsitem/diagramimageitem.h +++ b/sources/qetgraphicsitem/diagramimageitem.h @@ -81,7 +81,7 @@ class DiagramImageItem : public QetGraphicsItem { QVariant imageSourceVariant() const; void setImageSourceVariant(const QVariant &source); QRect cropRect() const { return m_crop_rect; } - void applyCrop(const QRect &cropRect); + bool applyCrop(const QRect &cropRect); // attributes public: diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 380463d93..7bfbaa344 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -3474,6 +3474,9 @@ bool QetScriptApi::setImageRotation(int folioIndex, int imageIndex, double angle Show only the rectangle (x, y, width, height) of the image's original, in the original's own pixels, as the crop tool does: one undo step, the kept region staying where it is on the folio. + @return false if nothing was cropped: no such image, read-only + project, or a rectangle that is empty, outside the original, or the + current crop. */ bool QetScriptApi::cropImage(int folioIndex, int imageIndex, int x, int y, int width, int height) { @@ -3488,8 +3491,7 @@ bool QetScriptApi::cropImage(int folioIndex, int imageIndex, int x, int y, int w .arg(folioIndex).arg(list.count()).arg(imageIndex)); return false; } - list.at(imageIndex)->applyCrop(QRect(x, y, width, height)); - return true; + return list.at(imageIndex)->applyCrop(QRect(x, y, width, height)); } /** diff --git a/tests/qttest/tst_imagecropundo.cpp b/tests/qttest/tst_imagecropundo.cpp index 403c3dd59..5ea009e82 100644 --- a/tests/qttest/tst_imagecropundo.cpp +++ b/tests/qttest/tst_imagecropundo.cpp @@ -97,6 +97,9 @@ var i = qet.addImage(0, '%1', 100, 100); var r = {full: qet.imageCrop(0, i)}; r.cropped_ok = qet.cropImage(0, i, 10, 5, 20, 10); r.cropped = qet.imageCrop(0, i); +r.same_ok = qet.cropImage(0, i, 10, 5, 20, 10); +r.empty_ok = qet.cropImage(0, i, 10, 5, 0, 10); +r.outside_ok = qet.cropImage(0, i, 100, 100, 20, 10); qet.undo(); r.undone = qet.imageCrop(0, i); qet.save('%2'); @@ -111,6 +114,11 @@ qet.log('PROBE ' + JSON.stringify(r)); QCOMPARE(r.value("full").toString(), QStringLiteral("0,0,40,30")); QVERIFY(r.value("cropped_ok").toBool()); QCOMPARE(r.value("cropped").toString(), QStringLiteral("10,5,20,10")); + // Crops that change nothing report it, and push no undo step: + // the undo below still undoes the real crop. + QCOMPARE(r.value("same_ok").toBool(true), false); + QCOMPARE(r.value("empty_ok").toBool(true), false); + QCOMPARE(r.value("outside_ok").toBool(true), false); QCOMPARE(r.value("undone").toString(), QStringLiteral("0,0,40,30")); QCOMPARE(r.value("redone").toString(), QStringLiteral("10,5,20,10")); From 1e3c61849565c74d95d482cf56790368c00eb642 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Mon, 5 Oct 2026 17:52:27 +0200 Subject: [PATCH 05/20] 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 , 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 --- sources/qetgraphicsitem/diagramtextitem.cpp | 4 +- sources/qetgraphicsitem/diagramtextitem.h | 2 + .../qetgraphicsitem/independenttextitem.cpp | 49 ++++++++ sources/qetgraphicsitem/independenttextitem.h | 10 ++ tests/qttest/CMakeLists.txt | 12 ++ tests/qttest/fixtures/free_text_width.qet | 42 +++++++ tests/qttest/tst_freetextwidth.cpp | 113 ++++++++++++++++++ 7 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 tests/qttest/fixtures/free_text_width.qet create mode 100644 tests/qttest/tst_freetextwidth.cpp 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..208906463 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,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 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..3ff526c5d 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,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 diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 3b7e8c2b5..33134d0da 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 diff --git a/tests/qttest/fixtures/free_text_width.qet b/tests/qttest/fixtures/free_text_width.qet new file mode 100644 index 000000000..f2f9f019b --- /dev/null +++ b/tests/qttest/fixtures/free_text_width.qet @@ -0,0 +1,42 @@ + + + 10/2/26 + 02-10-2026 + 2026-10-02 + skeleton + /tmp/qet-mcp-new-k41dmnyo/skeleton.qet + 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" From 010e44ab8a3169fe3916239350ee89f23b855a6b Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Mon, 5 Oct 2026 17:53:38 +0200 Subject: [PATCH 06/20] Add corner handles to change the width of a text box TextResizeHandles puts four handles at the corners of a selected text (any QGraphicsTextItem with a qreal "textWidth" property, -1 = automatic width). Dragging one changes the width: the opposite corner stays in place in scene coordinates, for any rotation and rotation point, the text wraps and the height follows it. Vertical mouse movements are ignored, the width never goes below the longest word and is rounded to whole pixels, and a click without a move changes nothing. Double-clicking a handle goes back to the automatic width. The handles are a child of the text, so they follow it when it moves, rotates or is zoomed (as for the symbol text handles, #1002). The width is changed live; the owner makes it undoable with TextResizeCommand, which sets the width then the position on both undo and redo -- a text may move itself when its width changes, so two property commands would restore them in the wrong order. The geometry is header-only (textresize.h). Nothing uses the handles yet: the follow-up changes add them to free texts and to text fields in the element editor. Test: tst_textresizehandles, on a plain QGraphicsTextItem. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 2 + .../textresizehandles.cpp | 224 +++++++++++++ .../textresizehandles.h | 122 +++++++ sources/textresize.h | 120 +++++++ tests/qttest/CMakeLists.txt | 12 + tests/qttest/tst_textresizehandles.cpp | 297 ++++++++++++++++++ 6 files changed, 777 insertions(+) create mode 100644 sources/QetGraphicsItemModeler/textresizehandles.cpp create mode 100644 sources/QetGraphicsItemModeler/textresizehandles.h create mode 100644 sources/textresize.h create mode 100644 tests/qttest/tst_textresizehandles.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 3e7b6f573..8afd0f6e1 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -614,6 +614,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/QetGraphicsItemModeler/qetgraphicshandleritem.h ${QET_DIR}/sources/QetGraphicsItemModeler/qetgraphicshandlerutility.cpp ${QET_DIR}/sources/QetGraphicsItemModeler/qetgraphicshandlerutility.h + ${QET_DIR}/sources/QetGraphicsItemModeler/textresizehandles.cpp + ${QET_DIR}/sources/QetGraphicsItemModeler/textresizehandles.h ${QET_DIR}/sources/QPropertyUndoCommand/qpropertyundocommand.cpp ${QET_DIR}/sources/QPropertyUndoCommand/qpropertyundocommand.h diff --git a/sources/QetGraphicsItemModeler/textresizehandles.cpp b/sources/QetGraphicsItemModeler/textresizehandles.cpp new file mode 100644 index 000000000..c92a02b2c --- /dev/null +++ b/sources/QetGraphicsItemModeler/textresizehandles.cpp @@ -0,0 +1,224 @@ +/* + 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 "textresizehandles.h" + +#include "qetgraphicshandleritem.h" + +#include +#include +#include +#include +#include + +#include + +/** + @brief TextResizeHandles::TextResizeHandles + @param text : the text to resize, becomes the parent of the handles. + It must be in a scene, the handles filter their own events. + @param handle_size : see QETUtils::graphicsHandlerSize() +*/ +TextResizeHandles::TextResizeHandles(QGraphicsTextItem *text, qreal handle_size) : + m_text(text) +{ + setFlag(QGraphicsItem::ItemHasNoContents); + setParentItem(text); + + for (int i = TextResize::TopLeft ; i <= TextResize::BottomLeft ; ++i) + { + auto *handle = new QetGraphicsHandlerItem(handle_size); + handle->setParentItem(this); + handle->setColor(Qt::darkGreen); + handle->setToolTip(tr("Glisser pour changer la largeur du texte, " + "double-cliquer pour une largeur automatique")); + handle->setCursor(i == TextResize::TopLeft || i == TextResize::BottomRight + ? Qt::SizeFDiagCursor : Qt::SizeBDiagCursor); + if (scene()) + handle->installSceneEventFilter(this); + m_handles << handle; + } + + m_size_connection = connect(text->document()->documentLayout(), + &QAbstractTextDocumentLayout::documentSizeChanged, + this, &TextResizeHandles::updateHandlesPos); + updateHandlesPos(); +} + +TextResizeHandles::~TextResizeHandles() +{ + disconnect(m_size_connection); +} + +QRectF TextResizeHandles::boundingRect() const { + return QRectF(); +} + +/** + @return the handle at corner +*/ +QetGraphicsHandlerItem *TextResizeHandles::handle(TextResize::Corner corner) const { + return m_handles.at(corner); +} + +/** + @brief TextResizeHandles::updateHandlesPos + Put the handles at the corners of the box of the text. Called when the + size of the text changes; the owner calls it when the box changes in an + other way. + The box is boundingRect(), the box a user sees selected and resizes, + not the tighter frame of a framed text (qelectrotech#591). +*/ +void TextResizeHandles::updateHandlesPos() +{ + const QRectF rect = m_text->boundingRect(); + for (int i = TextResize::TopLeft ; i <= TextResize::BottomLeft ; ++i) + m_handles.at(i)->setPos(TextResize::cornerOf(rect, TextResize::Corner(i))); +} + +bool TextResizeHandles::sceneEventFilter(QGraphicsItem *watched, QEvent *event) +{ + const int index = m_handles.indexOf(static_cast(watched)); + if (index < 0) + return false; + + switch (event->type()) + { + case QEvent::GraphicsSceneMousePress: + { + auto *mouse_event = static_cast(event); + if (mouse_event->button() != Qt::LeftButton) + return false; + pressed(TextResize::Corner(index), mouse_event->scenePos()); + return true; + } + case QEvent::GraphicsSceneMouseMove: + if (m_dragging) + moved(static_cast(event)->scenePos()); + return true; + case QEvent::GraphicsSceneMouseRelease: + released(); + return true; + case QEvent::GraphicsSceneMouseDoubleClick: + resetToAutomaticWidth(); + return true; + default: + return false; + } +} + +void TextResizeHandles::pressed(TextResize::Corner corner, const QPointF &scene_pos) +{ + m_dragging = true; + m_press_scene = scene_pos; + m_dragged = corner; + m_old_width = currentWidth(); + m_old_pos = m_text->pos(); + m_press_inverse = m_text->sceneTransform().inverted(); + m_fixed_local = TextResize::cornerOf(m_text->boundingRect(), TextResize::opposite(corner)); + m_fixed_scene = m_text->mapToScene(m_fixed_local); + m_min_width = TextResize::minimumWidth(m_text->document()); +} + +void TextResizeHandles::moved(const QPointF &scene_pos) +{ + //A click without a move keeps an automatic width automatic + if (scene_pos == m_press_scene) + return; + + //Whole pixels, as in the width spin boxes + const qreal width = qMax(qreal(qRound(TextResize::widthForDrag(m_press_inverse, m_fixed_local, + scene_pos, m_dragged, 0))), + std::ceil(m_min_width)); + m_text->setProperty("textWidth", width); + TextResize::pinCorner(m_text, TextResize::opposite(m_dragged), m_fixed_scene); + updateHandlesPos(); +} + +void TextResizeHandles::released() +{ + if (!m_dragging) + return; + m_dragging = false; + + const qreal new_width = currentWidth(); + if (!qFuzzyCompare(m_old_width, new_width) || m_old_pos != m_text->pos()) + emit resizeFinished(m_old_width, new_width, m_old_pos, m_text->pos()); +} + +/** + @brief TextResizeHandles::resetToAutomaticWidth + Back to the automatic width (-1): the text keeps the point chosen by its + alignment in place, as it does for any other change of its width. +*/ +void TextResizeHandles::resetToAutomaticWidth() +{ + m_dragging = false; + + const qreal old_width = currentWidth(); + if (old_width < 0) + return; + + const QPointF old_pos = m_text->pos(); + m_text->setProperty("textWidth", qreal(-1)); + updateHandlesPos(); + emit resizeFinished(old_width, currentWidth(), old_pos, m_text->pos()); +} + +qreal TextResizeHandles::currentWidth() const { + return m_text->property("textWidth").toReal(); +} + + +/** + @brief TextResizeCommand::TextResizeCommand + @param text : a text with a "textWidth" property + @param old_width + @param new_width + @param old_pos + @param new_pos + @param parent +*/ +TextResizeCommand::TextResizeCommand(QGraphicsObject *text, + qreal old_width, qreal new_width, + const QPointF &old_pos, const QPointF &new_pos, + QUndoCommand *parent) : + QUndoCommand(parent), + m_text(text), + m_old_width(old_width), + m_new_width(new_width), + m_old_pos(old_pos), + m_new_pos(new_pos) +{ + setText(tr("Redimensionner un texte")); +} + +void TextResizeCommand::undo() { + apply(m_old_width, m_old_pos); +} + +void TextResizeCommand::redo() { + apply(m_new_width, m_new_pos); +} + +void TextResizeCommand::apply(qreal width, const QPointF &pos) +{ + if (!m_text) + return; + m_text->setProperty("textWidth", width); + m_text->setPos(pos); +} diff --git a/sources/QetGraphicsItemModeler/textresizehandles.h b/sources/QetGraphicsItemModeler/textresizehandles.h new file mode 100644 index 000000000..e098b54d4 --- /dev/null +++ b/sources/QetGraphicsItemModeler/textresizehandles.h @@ -0,0 +1,122 @@ +/* + 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 . +*/ +#ifndef TEXTRESIZEHANDLES_H +#define TEXTRESIZEHANDLES_H + +#include "../textresize.h" + +#include +#include +#include +#include +#include +#include + +class QetGraphicsHandlerItem; +class QGraphicsTextItem; + +/** + @brief The TextResizeHandles class + Four corner handles to change the width of a text box with the mouse. + The opposite corner of the dragged one stays in place, the text wraps + to the new width and the height of the box follows the text. + Double-clicking a handle goes back to the automatic width. + + The text must have a qreal "textWidth" property (-1 = automatic width). + The handles are a child of the text, so they follow it when it, or its + symbol, is moved, rotated or zoomed (qelectrotech#1002). + + The width is changed live while dragging; the owner of the text makes + the change undoable when resizeFinished() is emitted, by pushing a + TextResizeCommand on its own undo stack. +*/ +class TextResizeHandles : public QGraphicsObject +{ + Q_OBJECT + + public: + TextResizeHandles(QGraphicsTextItem *text, qreal handle_size = 10); + ~TextResizeHandles() override; + + QRectF boundingRect() const override; + void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override {} + + QetGraphicsHandlerItem *handle(TextResize::Corner corner) const; + void updateHandlesPos(); + + signals: + void resizeFinished(qreal old_width, qreal new_width, + QPointF old_pos, QPointF new_pos); + + protected: + bool sceneEventFilter(QGraphicsItem *watched, QEvent *event) override; + + private: + void pressed(TextResize::Corner corner, const QPointF &scene_pos); + void moved(const QPointF &scene_pos); + void released(); + void resetToAutomaticWidth(); + qreal currentWidth() const; + + QGraphicsTextItem *m_text; + QVector m_handles; + QMetaObject::Connection m_size_connection; + + //The drag in progress + bool m_dragging = false; + TextResize::Corner m_dragged = TextResize::BottomRight; + QTransform m_press_inverse; + QPointF m_press_scene, + m_fixed_local, + m_fixed_scene, + m_old_pos; + qreal m_old_width = -1, + m_min_width = 0; +}; + +/** + @brief The TextResizeCommand class + Undo a width change made with TextResizeHandles: the width, then the + position, which keeps the opposite corner of the dragged one in place. + Both are set in the same order on undo and redo, because a text may + move itself when its width changes (to keep its alignment point). +*/ +class TextResizeCommand : public QUndoCommand +{ + Q_DECLARE_TR_FUNCTIONS(TextResizeCommand) + + public: + TextResizeCommand(QGraphicsObject *text, + qreal old_width, qreal new_width, + const QPointF &old_pos, const QPointF &new_pos, + QUndoCommand *parent = nullptr); + + void undo() override; + void redo() override; + + private: + void apply(qreal width, const QPointF &pos); + + QPointer m_text; + qreal m_old_width, + m_new_width; + QPointF m_old_pos, + m_new_pos; +}; + +#endif // TEXTRESIZEHANDLES_H diff --git a/sources/textresize.h b/sources/textresize.h new file mode 100644 index 000000000..626a3e4d0 --- /dev/null +++ b/sources/textresize.h @@ -0,0 +1,120 @@ +/* + 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 . +*/ +#ifndef TEXTRESIZE_H +#define TEXTRESIZE_H + +#include +#include +#include +#include +#include + +/** + The geometry of changing the width of a text box by dragging one of + its corners: the text wraps to the new width, the opposite corner + stays where it is drawn. +*/ +namespace TextResize +{ + /** + A corner of the box of a text, in the order of the resize handles. + */ + enum Corner { + TopLeft, + TopRight, + BottomRight, + BottomLeft + }; + + inline Corner opposite(Corner corner) { + return Corner((int(corner) + 2) % 4); + } + + inline bool isLeft(Corner corner) { + return corner == TopLeft || corner == BottomLeft; + } + + /** + @return the corner of rect, in the coordinates of rect. + */ + inline QPointF cornerOf(const QRectF &rect, Corner corner) + { + switch (corner) { + case TopLeft: return rect.topLeft(); + case TopRight: return rect.topRight(); + case BottomRight: return rect.bottomRight(); + case BottomLeft: return rect.bottomLeft(); + } + return rect.topLeft(); + } + + /** + @return the smallest width that document can be given: the width + of its longest word, which is never broken, plus the margins. + */ + inline qreal minimumWidth(const QTextDocument *document) + { + QScopedPointer narrow(document->clone()); + QTextOption option = narrow->defaultTextOption(); + option.setWrapMode(QTextOption::WordWrap); + narrow->setDefaultTextOption(option); + narrow->setTextWidth(0); + return narrow->size().width(); + } + + /** + @return the width given by dragging the corner dragged of a text + box to mouse_scene, while the opposite corner stays at fixed_local. + @param press_inverse : the inverse of the scene transform of the + text when the drag started. Moving the text to keep the opposite + corner in place changes its transform, using the transform of the + press keeps that move from feeding back into the width. + @param fixed_local : the opposite corner, in the coordinates of the + text when the drag started. + @param min_width : see minimumWidth(). + Vertical movements of the mouse are ignored: the height of the box + follows the wrapped text. + */ + inline qreal widthForDrag(const QTransform &press_inverse, + const QPointF &fixed_local, + const QPointF &mouse_scene, + Corner dragged, + qreal min_width) + { + const QPointF mouse = press_inverse.map(mouse_scene); + const qreal width = isLeft(dragged) ? fixed_local.x() - mouse.x() + : mouse.x() - fixed_local.x(); + return qMax(width, min_width); + } + + /** + Move item so that its corner corner is at scene_point, in scene + coordinates. Right for any rotation, rotation point and alignment. + */ + inline void pinCorner(QGraphicsItem *item, Corner corner, const QPointF &scene_point) + { + const QPointF now = item->mapToScene(cornerOf(item->boundingRect(), corner)); + QPointF delta = scene_point - now; + if (const QGraphicsItem *parent = item->parentItem()) + delta = parent->mapFromScene(scene_point) - parent->mapFromScene(now); + item->setPos(item->pos() + delta); + } + +} + +#endif // TEXTRESIZE_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 3b7e8c2b5..1b98dbb68 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -155,6 +155,18 @@ set_tests_properties(tst_textanchor PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offs target_include_directories(tst_textanchor PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_textanchor PRIVATE Qt::Test Qt::Widgets) +# TextResizeHandles: the corner handles that change the width of a text box, +# on a plain QGraphicsTextItem with the "textWidth" property QET texts have. +add_executable( + tst_textresizehandles + tst_textresizehandles.cpp + ${QET_DIR}/sources/QetGraphicsItemModeler/qetgraphicshandleritem.cpp + ${QET_DIR}/sources/QetGraphicsItemModeler/textresizehandles.cpp) +add_test(NAME tst_textresizehandles COMMAND tst_textresizehandles) +set_tests_properties(tst_textresizehandles PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_textresizehandles PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_textresizehandles PRIVATE Qt::Test Qt::Widgets) + # legacyelementtypes.h is header-only: the rewrite of element type names in # nomenclature queries saved before June 2022. add_executable(tst_legacyelementtypes tst_legacyelementtypes.cpp) diff --git a/tests/qttest/tst_textresizehandles.cpp b/tests/qttest/tst_textresizehandles.cpp new file mode 100644 index 000000000..d4e7f34e7 --- /dev/null +++ b/tests/qttest/tst_textresizehandles.cpp @@ -0,0 +1,297 @@ +/* + 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 +#include +#include +#include + +#include + +#include "QetGraphicsItemModeler/qetgraphicshandleritem.h" +#include "QetGraphicsItemModeler/textresizehandles.h" + +using TextResize::Corner; + +/** + A text with the "textWidth" property the texts of QElectroTech have. +*/ +class WidthText : public QGraphicsTextItem +{ + Q_OBJECT + Q_PROPERTY(qreal textWidth READ textWidth WRITE setWidth) + + public: + WidthText(const QString &text) : QGraphicsTextItem(text) + { + QTextOption option = document()->defaultTextOption(); + option.setWrapMode(QTextOption::WordWrap); + document()->setDefaultTextOption(option); + } + void setWidth(qreal width) {setTextWidth(width);} +}; + +/// The number of lines the text is laid out on. +static int lineCount(const QGraphicsTextItem &text) +{ + text.document()->size(); + int lines = 0; + for (QTextBlock block = text.document()->begin() ; block.isValid() ; block = block.next()) + lines += block.layout()->lineCount(); + return lines; +} + +static bool samePoint(const QPointF &a, const QPointF &b) +{ + return qAbs(a.x() - b.x()) < 1e-6 && qAbs(a.y() - b.y()) < 1e-6; +} + +static QPointF sceneCorner(const QGraphicsTextItem &text, Corner corner) +{ + return text.mapToScene(TextResize::cornerOf(text.boundingRect(), corner)); +} + +/// Send a mouse event of type to the handle at corner, as the scene would. +static void sendMouse(QGraphicsScene &scene, TextResizeHandles &handles, + Corner corner, QEvent::Type type, const QPointF &scene_pos) +{ + QGraphicsSceneMouseEvent event(type); + event.setButton(Qt::LeftButton); + event.setButtons(type == QEvent::GraphicsSceneMouseRelease ? Qt::NoButton : Qt::LeftButton); + event.setScenePos(scene_pos); + scene.sendEvent(handles.handle(corner), &event); +} + +/// Drag the handle at corner from where it is by offset, in scene coordinates. +static void drag(QGraphicsScene &scene, TextResizeHandles &handles, + WidthText &text, Corner corner, const QPointF &offset) +{ + const QPointF start = sceneCorner(text, corner); + sendMouse(scene, handles, corner, QEvent::GraphicsSceneMousePress, start); + sendMouse(scene, handles, corner, QEvent::GraphicsSceneMouseMove, start + offset / 2); + sendMouse(scene, handles, corner, QEvent::GraphicsSceneMouseMove, start + offset); + sendMouse(scene, handles, corner, QEvent::GraphicsSceneMouseRelease, start + offset); +} + +class tst_textresizehandles : public QObject +{ + Q_OBJECT + +private slots: + // Pure geometry: the width given by a drag does not change while the + // text moves to keep its opposite corner in place. + void widthForDragUsesThePressTransform() + { + WidthText text(QStringLiteral("Motor protection switch")); + text.setRotation(37); + text.setPos(10, 10); + const QTransform press_inverse = text.sceneTransform().inverted(); + const QPointF fixed = TextResize::cornerOf(text.boundingRect(), TextResize::TopLeft); + const QPointF mouse = text.mapToScene(QPointF(80, 3)); + + const qreal width = TextResize::widthForDrag(press_inverse, fixed, mouse, TextResize::BottomRight, 0); + QCOMPARE(qRound(width * 1000), 80000); + + text.setPos(500, -300); + QCOMPARE(TextResize::widthForDrag(press_inverse, fixed, mouse, TextResize::BottomRight, 0), width); + } + + // The minimum width is the longest word: a text that narrow does not + // break any word, one narrower would. + void minimumWidthIsTheLongestWord() + { + WidthText text(QStringLiteral("a Motorschutzschalter b")); + const qreal minimum = TextResize::minimumWidth(text.document()); + + WidthText word(QStringLiteral("Motorschutzschalter")); + QVERIFY(qAbs(minimum - word.boundingRect().width()) < 0.5); + + text.setWidth(minimum); + QVERIFY(qAbs(text.boundingRect().width() - minimum) < 0.5); + QCOMPARE(lineCount(text), 3); + } + + // The minimum width never cuts a word, even for a document that would + // otherwise break a word anywhere. + void minimumWidthKeepsWords() + { + QGraphicsTextItem text(QStringLiteral("a Motorschutzschalter")); + QTextOption option = text.document()->defaultTextOption(); + option.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + text.document()->setDefaultTextOption(option); + + QGraphicsTextItem word(QStringLiteral("Motorschutzschalter")); + QVERIFY(qAbs(TextResize::minimumWidth(text.document()) - word.boundingRect().width()) < 0.5); + } + + // pinCorner() puts a corner where it was, for each corner, rotation, + // rotation point and inside a rotated parent. + void pinCornerKeepsTheCorner() + { + QGraphicsRectItem parent(0, 0, 10, 10); + parent.setRotation(30); + for (bool with_parent : {false, true}) + for (int c = TextResize::TopLeft ; c <= TextResize::BottomLeft ; ++c) + for (qreal angle : {0.0, 90.0, 37.0}) + for (bool centre : {false, true}) { + WidthText text(QStringLiteral("Motor protection switch")); + if (with_parent) + text.setParentItem(&parent); + text.setRotation(angle); + text.setTransformOriginPoint(centre ? text.boundingRect().center() : QPointF()); + const Corner corner = Corner(c); + const QPointF before = sceneCorner(text, corner); + + text.setWidth(70); + if (centre) + text.setTransformOriginPoint(text.boundingRect().center()); + TextResize::pinCorner(&text, corner, before); + QVERIFY2(samePoint(sceneCorner(text, corner), before), + qPrintable(QStringLiteral("corner %1 angle %2 centre %3 parent %4") + .arg(c).arg(angle).arg(centre).arg(with_parent))); + text.setParentItem(nullptr); + } + } + + // Dragging a handle keeps the opposite corner in place, wraps the text + // and reports the change once, on release. + void dragKeepsTheOppositeCorner() + { + for (int c = TextResize::TopLeft ; c <= TextResize::BottomLeft ; ++c) + for (qreal angle : {0.0, 90.0, 37.0}) { + QGraphicsScene scene; + auto *text = new WidthText(QStringLiteral("Motor protection switch Q12")); + scene.addItem(text); + text->setPos(100, 100); + text->setRotation(angle); + auto *handles = new TextResizeHandles(text); + QSignalSpy finished(handles, &TextResizeHandles::resizeFinished); + + const Corner corner = Corner(c); + const QPointF fixed = sceneCorner(*text, TextResize::opposite(corner)); + const qreal natural = text->boundingRect().width(); + const QPointF start_pos = text->pos(); + + //Narrower by 40 along the text, whatever its rotation; + //a vertical move of the mouse is ignored. + QTransform turn; + turn.rotate(angle); + const qreal sign = TextResize::isLeft(corner) ? 1 : -1; + drag(scene, *handles, *text, corner, turn.map(QPointF(sign * 40, 25))); + + const QString what = QStringLiteral("corner %1 angle %2").arg(c).arg(angle); + QCOMPARE(text->textWidth(), qreal(qRound(natural - 40))); + QVERIFY2(lineCount(*text) > 1, qPrintable(what)); + QVERIFY2(samePoint(sceneCorner(*text, TextResize::opposite(corner)), fixed), qPrintable(what)); + + QCOMPARE(finished.count(), 1); + const QList args = finished.first(); + QCOMPARE(args.at(0).toReal(), qreal(-1)); + QCOMPARE(args.at(1).toReal(), text->textWidth()); + QCOMPARE(args.at(2).toPointF(), start_pos); + QCOMPARE(args.at(3).toPointF(), text->pos()); + + //The handles follow the new box + QVERIFY(samePoint(handles->handle(corner)->scenePos(), sceneCorner(*text, corner))); + } + } + + // A text is never made narrower than its longest word. + void dragStopsAtTheLongestWord() + { + QGraphicsScene scene; + auto *text = new WidthText(QStringLiteral("a Motorschutzschalter b")); + scene.addItem(text); + auto *handles = new TextResizeHandles(text); + drag(scene, *handles, *text, TextResize::BottomRight, QPointF(-1000, 0)); + QCOMPARE(text->textWidth(), std::ceil(TextResize::minimumWidth(text->document()))); + } + + // A press and release without moving changes nothing. + void clickChangesNothing() + { + QGraphicsScene scene; + auto *text = new WidthText(QStringLiteral("Motor protection switch")); + scene.addItem(text); + auto *handles = new TextResizeHandles(text); + QSignalSpy finished(handles, &TextResizeHandles::resizeFinished); + drag(scene, *handles, *text, TextResize::TopRight, QPointF()); + QCOMPARE(finished.count(), 0); + QCOMPARE(text->textWidth(), qreal(-1)); + } + + // Double-clicking a handle goes back to the automatic width. + void doubleClickResetsToAutomaticWidth() + { + QGraphicsScene scene; + auto *text = new WidthText(QStringLiteral("Motor protection switch")); + scene.addItem(text); + text->setWidth(60); + auto *handles = new TextResizeHandles(text); + QSignalSpy finished(handles, &TextResizeHandles::resizeFinished); + + sendMouse(scene, *handles, TextResize::BottomLeft, QEvent::GraphicsSceneMouseDoubleClick, + sceneCorner(*text, TextResize::BottomLeft)); + QCOMPARE(text->textWidth(), qreal(-1)); + QCOMPARE(finished.count(), 1); + QCOMPARE(finished.first().at(0).toReal(), qreal(60)); + QCOMPARE(finished.first().at(1).toReal(), qreal(-1)); + + //Already automatic: nothing to undo + sendMouse(scene, *handles, TextResize::BottomLeft, QEvent::GraphicsSceneMouseDoubleClick, + sceneCorner(*text, TextResize::BottomLeft)); + QCOMPARE(finished.count(), 1); + } + + // One undo step gives back the width and the position, one redo + // applies them again. + void undoRestoresWidthAndPosition() + { + QGraphicsScene scene; + auto *text = new WidthText(QStringLiteral("Motor protection switch Q12")); + scene.addItem(text); + text->setPos(100, 100); + text->setRotation(90); + auto *handles = new TextResizeHandles(text); + QUndoStack stack; + connect(handles, &TextResizeHandles::resizeFinished, &stack, + [&stack, text](qreal ow, qreal nw, QPointF op, QPointF np) { + stack.push(new TextResizeCommand(text, ow, nw, op, np)); + }); + + drag(scene, *handles, *text, TextResize::TopLeft, QPointF(0, 40)); + QCOMPARE(stack.count(), 1); + const qreal new_width = text->textWidth(); + const QPointF new_pos = text->pos(); + QVERIFY(new_pos != QPointF(100, 100)); + + stack.undo(); + QCOMPARE(text->textWidth(), qreal(-1)); + QCOMPARE(text->pos(), QPointF(100, 100)); + stack.redo(); + QCOMPARE(text->textWidth(), new_width); + QCOMPARE(text->pos(), new_pos); + } +}; + +QTEST_MAIN(tst_textresizehandles) +#include "tst_textresizehandles.moc" From 1d37ce9b84ce42d7d988b46f9c3df247a4a3e9f7 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Mon, 5 Oct 2026 17:57:03 +0200 Subject: [PATCH 07/20] Show "Auto" in the width box of element editor text fields The width box of a dynamic text field in the element editor showed -1 for the automatic width and stopped at 500 px. It now shows "Auto" and "px", and goes up to 10000 px, so a wider field is not cut to 500 just by being shown. Leaving the box without changing it no longer pushes a change either: the box holds whole pixels, while a width fitted to the text (or set elsewhere) can have decimals, and the comparison made the field's width round itself on every focus loss. Co-Authored-By: Claude Opus 5.5 --- sources/editor/ui/dynamictextfieldeditor.cpp | 4 +++- sources/editor/ui/dynamictextfieldeditor.ui | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/sources/editor/ui/dynamictextfieldeditor.cpp b/sources/editor/ui/dynamictextfieldeditor.cpp index 47d87a91c..1640907ce 100644 --- a/sources/editor/ui/dynamictextfieldeditor.cpp +++ b/sources/editor/ui/dynamictextfieldeditor.cpp @@ -363,7 +363,9 @@ void DynamicTextFieldEditor::on_m_width_sb_editingFinished() qreal width = (qreal)ui -> m_width_sb -> value(); for (int i = 0; i < m_parts.length(); i++) { - if(width != m_parts[i] -> textWidth()) { + //The box shows whole pixels: a width dragged with the mouse or + //fitted to the text is not rounded just by leaving the box. + if(ui -> m_width_sb -> value() != qRound(m_parts[i] -> textWidth())) { QPropertyUndoCommand *undo = new QPropertyUndoCommand(m_parts[i], "textWidth", m_parts[i] -> textWidth(), width); undo -> setText(tr("Modifier la largeur d'un texte")); undoStack().push(undo); diff --git a/sources/editor/ui/dynamictextfieldeditor.ui b/sources/editor/ui/dynamictextfieldeditor.ui index 516b41853..59c8defde 100644 --- a/sources/editor/ui/dynamictextfieldeditor.ui +++ b/sources/editor/ui/dynamictextfieldeditor.ui @@ -101,11 +101,17 @@ + + Auto + + + px + -1 - 500 + 10000 From d4644286429481b5ee3c0b1ddeac27e432b860be Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Mon, 5 Oct 2026 18:00:26 +0200 Subject: [PATCH 08/20] Export wrapped texts to DXF line by line A symbol text with a width (text_width) is drawn wrapped on the folio, but the DXF export split texts on '\n' only, so it was written as one long line, running past its box and over the drawing. The export now writes the lines of folio texts as they are laid out (TextLines::layoutLines(), new header-only textlines.h). Texts without a width give the same lines as before. Tests: tst_textlines, tst_dxfwrappedtext (fails without this change). Co-Authored-By: Claude Opus 5.5 --- sources/dxfexport.cpp | 4 +- sources/textlines.h | 60 +++++++++++++++++++++ tests/qttest/CMakeLists.txt | 18 +++++++ tests/qttest/fixtures/dxf_wrapped_text.qet | 61 ++++++++++++++++++++++ tests/qttest/tst_dxfwrappedtext.cpp | 60 +++++++++++++++++++++ tests/qttest/tst_textlines.cpp | 54 +++++++++++++++++++ 6 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 sources/textlines.h create mode 100644 tests/qttest/fixtures/dxf_wrapped_text.qet create mode 100644 tests/qttest/tst_dxfwrappedtext.cpp create mode 100644 tests/qttest/tst_textlines.cpp diff --git a/sources/dxfexport.cpp b/sources/dxfexport.cpp index 5f3c266ff..6db86c5b4 100644 --- a/sources/dxfexport.cpp +++ b/sources/dxfexport.cpp @@ -35,6 +35,7 @@ #include "qetgraphicsitem/independenttextitem.h" #include "qetgraphicsitem/qetshapeitem.h" #include "qetgraphicsitem/terminal.h" +#include "textlines.h" #include #include @@ -358,7 +359,8 @@ void DxfExport::write(Diagram *diagram, int width, int height, qreal y = dti->scenePos().y() - ydir * fontSize * 1.8 - xdir * fontSize * 0.9; - QStringList lines = dti -> toPlainText().split('\n'); + //As drawn: a text with a width is wrapped + QStringList lines = TextLines::layoutLines(dti -> document()); qreal offset = fontSize * 1.6; foreach (QString line, lines) { if (line.size() > 0 && line != "_" ) diff --git a/sources/textlines.h b/sources/textlines.h new file mode 100644 index 000000000..e9a15e130 --- /dev/null +++ b/sources/textlines.h @@ -0,0 +1,60 @@ +/* + 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 . +*/ +#ifndef TEXTLINES_H +#define TEXTLINES_H + +#include +#include +#include +#include + +namespace TextLines +{ + /** + @return the lines of document as they are laid out: a paragraph + wrapped to the width of the text gives several lines. For the + exports that write a text line by line (DXF). + */ + inline QStringList layoutLines(const QTextDocument *document) + { + document->size(); //Lay the document out + + QStringList lines; + for (QTextBlock block = document->begin() ; block.isValid() ; block = block.next()) + { + const QString text = block.text(); + const QTextLayout *layout = block.layout(); + if (!layout || layout->lineCount() == 0) { + lines << text; + continue; + } + for (int i = 0 ; i < layout->lineCount() ; ++i) + { + const QTextLine line = layout->lineAt(i); + QString part = text.mid(line.textStart(), line.textLength()); + //The space the line was broken at, or a line separator + while (!part.isEmpty() && part.back().isSpace()) + part.chop(1); + lines << part; + } + } + return lines; + } +} + +#endif // TEXTLINES_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 3b7e8c2b5..f5182993d 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -104,6 +104,14 @@ add_test(NAME tst_textgrid COMMAND tst_textgrid) target_include_directories(tst_textgrid PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_textgrid PRIVATE Qt::Test) +# textlines.h is header-only: the lines of a text as they are laid out, +# for the exports that write a text line by line. +add_executable(tst_textlines tst_textlines.cpp) +add_test(NAME tst_textlines COMMAND tst_textlines) +set_tests_properties(tst_textlines PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_textlines PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_textlines PRIVATE Qt::Test Qt::Gui) + # elementviewgrid.h is header-only: the symbol editor's snap step at each # zoom (bugtracker #112), tested without an editor window. add_executable(tst_elementviewgrid tst_elementviewgrid.cpp) @@ -403,6 +411,16 @@ target_compile_definitions(tst_wiringlistexport PRIVATE "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") +# --export-dxf writes a wrapped symbol text line by line, as it is drawn. +add_executable( + tst_dxfwrappedtext + tst_dxfwrappedtext.cpp) +add_test(NAME tst_dxfwrappedtext COMMAND tst_dxfwrappedtext) +add_dependencies(tst_dxfwrappedtext qelectrotech) +target_link_libraries(tst_dxfwrappedtext PRIVATE Qt::Test) +target_compile_definitions(tst_dxfwrappedtext PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") + # --export-pdf puts an A3 folio on an A3 page whichever way it is turned: # QPageSize matches standard sheets upright only, so a wide folio is matched # upright and turned. fixtures/pdf_page_a3.qet has one folio each way. diff --git a/tests/qttest/fixtures/dxf_wrapped_text.qet b/tests/qttest/fixtures/dxf_wrapped_text.qet new file mode 100644 index 000000000..920aebc74 --- /dev/null +++ b/tests/qttest/fixtures/dxf_wrapped_text.qet @@ -0,0 +1,61 @@ + + + 10/2/26 + 02-10-2026 + 2026-10-02 + skeleton + /tmp/qet-mcp-new-k41dmnyo/skeleton.qet + 18:39 + + + + + + + + + + + + + + + + + + + + + + + + + + WrapAlpha WrapBeta WrapGamma WrapDelta WrapEpsilon + LineAlpha LineBeta LineGamma LineDelta LineEpsilon + + + + + + + + + Imported elements + + + + + + Wrapped symbol text + + + + WrapAlpha WrapBeta WrapGamma WrapDelta WrapEpsilon + LineAlpha LineBeta LineGamma LineDelta LineEpsilon + + + + + + diff --git a/tests/qttest/tst_dxfwrappedtext.cpp b/tests/qttest/tst_dxfwrappedtext.cpp new file mode 100644 index 000000000..857c32084 --- /dev/null +++ b/tests/qttest/tst_dxfwrappedtext.cpp @@ -0,0 +1,60 @@ +/* + 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 + +/** + --export-dxf writes a symbol text wrapped to its width line by line, + as it is drawn, rather than as one long line. A text without a width + is still written on one line. +*/ +class tst_dxfwrappedtext : public QObject +{ + Q_OBJECT + +private slots: + void wrappedLines() + { + const QString project = QFINDTESTDATA("fixtures/dxf_wrapped_text.qet"); + QVERIFY2(!project.isEmpty(), "fixture project not found"); + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--export-dxf"), project, dir.path()}); + QVERIFY2(proc.waitForFinished(60000), "--export-dxf timed out"); + QCOMPARE(proc.exitCode(), 0); + + QFile file(dir.filePath(QStringLiteral("01_diagram.dxf"))); + QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text)); + const QStringList values = QString::fromUtf8(file.readAll()).split(QLatin1Char('\n')); + + //One text entity per word of the text 70 px wide... + for (const QString &word : {QStringLiteral("WrapAlpha"), QStringLiteral("WrapGamma"), QStringLiteral("WrapEpsilon")}) + QVERIFY2(values.contains(word), qPrintable(word)); + //...one for the whole text without a width + QVERIFY(values.contains(QStringLiteral("LineAlpha LineBeta LineGamma LineDelta LineEpsilon"))); + } +}; + +QTEST_APPLESS_MAIN(tst_dxfwrappedtext) +#include "tst_dxfwrappedtext.moc" diff --git a/tests/qttest/tst_textlines.cpp b/tests/qttest/tst_textlines.cpp new file mode 100644 index 000000000..fb987c694 --- /dev/null +++ b/tests/qttest/tst_textlines.cpp @@ -0,0 +1,54 @@ +/* + 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 "textlines.h" + +class tst_textlines : public QObject +{ + Q_OBJECT + +private slots: + // A text without a width gives its own lines. + void unwrappedTextGivesItsLines() + { + QTextDocument document(QStringLiteral("Motor protection\nswitch Q12")); + QCOMPARE(TextLines::layoutLines(&document), + QStringList({QStringLiteral("Motor protection"), QStringLiteral("switch Q12")})); + } + + // A text wrapped to its width gives one line per drawn line, without + // the space the line was broken at. + void wrappedTextGivesTheDrawnLines() + { + QTextDocument document(QStringLiteral("Motor protection switch Q12")); + QTextOption option = document.defaultTextOption(); + option.setWrapMode(QTextOption::WordWrap); + document.setDefaultTextOption(option); + document.setTextWidth(1); + + QCOMPARE(TextLines::layoutLines(&document), + QStringList({QStringLiteral("Motor"), QStringLiteral("protection"), + QStringLiteral("switch"), QStringLiteral("Q12")})); + } +}; + +QTEST_MAIN(tst_textlines) +#include "tst_textlines.moc" From 896c38435d53046b0c871d3b2792e7b672956f79 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 6 Oct 2026 08:17:16 +1300 Subject: [PATCH 09/20] Add icons, menu locations, key search and copy to the Shortcuts page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Shortcuts page now shows: - each command's icon, and a Menu column saying where it is ("Projet › Scripts"); the text filter matches that too - a category filter beside the existing Bound/Unbound/Conflicts one - "Rechercher par touche": press a key combination and only the commands using it are listed, however the key is spelled in the user's language. A key also finds the two-key shortcuts it starts. - "Copier la liste": the shown rows go on the clipboard as tab-separated text with a header, so they paste into a spreadsheet one cell per column ShortcutManager::ShortcutInfo carries the icon and the first live QAction so the page needs no second registry. New test tst_shortcutspage covers the menu path, the columns, both filters and the copied text. Second stage of discussion #1319. Co-Authored-By: Claude Opus 5.5 --- sources/shortcutmanager.cpp | 7 + sources/shortcutmanager.h | 5 + sources/ui/configpage/shortcutsconfigpage.cpp | 163 ++++++++++++++++-- sources/ui/configpage/shortcutsconfigpage.h | 8 + tests/qttest/CMakeLists.txt | 17 ++ tests/qttest/tst_shortcutspage.cpp | 145 ++++++++++++++++ 6 files changed, 331 insertions(+), 14 deletions(-) create mode 100644 tests/qttest/tst_shortcutspage.cpp diff --git a/sources/shortcutmanager.cpp b/sources/shortcutmanager.cpp index 503d77993..fffcd9720 100644 --- a/sources/shortcutmanager.cpp +++ b/sources/shortcutmanager.cpp @@ -137,6 +137,13 @@ QList ShortcutManager::allShortcuts() const info.description = entry.description; info.default_sequence = entry.default_sequence; info.current_sequence = savedSequence(id, entry.default_sequence); + for (const QPointer &target : entry.targets) { + if (target) { + info.icon = target->property("icon").value(); + info.action = qobject_cast(target.data()); + break; + } + } list << info; } return list; diff --git a/sources/shortcutmanager.h b/sources/shortcutmanager.h index 5da92e332..fecaca6df 100644 --- a/sources/shortcutmanager.h +++ b/sources/shortcutmanager.h @@ -19,6 +19,7 @@ #define SHORTCUTMANAGER_H #include +#include #include #include #include @@ -60,6 +61,10 @@ class ShortcutManager QString description; QKeySequence default_sequence; QKeySequence current_sequence; + /// The command's icon, from its first live target; may be null + QIcon icon; + /// The first live target if it is a QAction, else nullptr + QAction *action = nullptr; }; void registerAction(QObject *target, const QString &id, diff --git a/sources/ui/configpage/shortcutsconfigpage.cpp b/sources/ui/configpage/shortcutsconfigpage.cpp index 242958fcd..c7aa25666 100644 --- a/sources/ui/configpage/shortcutsconfigpage.cpp +++ b/sources/ui/configpage/shortcutsconfigpage.cpp @@ -20,6 +20,9 @@ #include "../../qeticons.h" #include "../../shortcutmanager.h" +#include +#include +#include #include #include #include @@ -27,6 +30,8 @@ #include #include #include +#include +#include #include #include #include @@ -43,6 +48,10 @@ this, so "general" matches "Général" and "Ctrl+S" matches "ctrl+s" regardless of the keyboard layout the query was typed on. */ +namespace { + enum Column { ActionColumn, MenuColumn, SequenceColumn, ResetColumn }; +} + static QString normalizedForSearch(const QString &text) { const QString decomposed = text.normalized(QString::NormalizationForm_D); @@ -85,20 +94,51 @@ ShortcutsConfigPage::ShortcutsConfigPage(QWidget *parent) : connect(m_quick_filter, QOverload::of(&QComboBox::currentIndexChanged), this, &ShortcutsConfigPage::quickFilterChanged); + m_category_filter = new QComboBox(this); + m_category_filter->setObjectName(QStringLiteral("categoryFilterCombo")); + m_category_filter->addItem(tr("Toutes les catégories")); + connect(m_category_filter, QOverload::of(&QComboBox::currentIndexChanged), + this, &ShortcutsConfigPage::quickFilterChanged); + m_count_label = new QLabel(this); m_count_label->setObjectName(QStringLiteral("shortcutCountLabel")); auto *filter_layout = new QHBoxLayout(); filter_layout->addWidget(m_filter_edit, 1); + filter_layout->addWidget(m_category_filter); filter_layout->addWidget(m_quick_filter); filter_layout->addWidget(m_count_label); vlayout->addLayout(filter_layout); + //Press a key combination to list what uses it, without having to + //know how QElectroTech spells it ("Ctrl+Maj+S", "Ctrl+Shift+S"…) + m_key_search = new QKeySequenceEdit(this); + m_key_search->setObjectName(QStringLiteral("keySearchEdit")); + m_key_search->setToolTip(tr("Appuyez sur une combinaison de touches pour voir quelle commande l'utilise")); +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + m_key_search->setMaximumSequenceLength(1); +#endif + connect(m_key_search, &QKeySequenceEdit::keySequenceChanged, this, [this]() { applyFilter(); }); + + auto *clear_key_button = new QToolButton(this); + clear_key_button->setIcon(QET::Icons::EditClear); + clear_key_button->setToolTip(tr("Effacer la touche recherchée")); + clear_key_button->setAutoRaise(true); + connect(clear_key_button, &QToolButton::clicked, m_key_search, &QKeySequenceEdit::clear); + + auto *key_layout = new QHBoxLayout(); + key_layout->addWidget(new QLabel(tr("Rechercher par touche :"), this)); + key_layout->addWidget(m_key_search); + key_layout->addWidget(clear_key_button); + key_layout->addStretch(); + vlayout->addLayout(key_layout); + m_tree = new QTreeWidget(this); - m_tree->setHeaderLabels({tr("Action"), tr("Raccourci"), QString()}); - m_tree->header()->setSectionResizeMode(0, QHeaderView::Stretch); - m_tree->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents); - m_tree->header()->setSectionResizeMode(2, QHeaderView::ResizeToContents); + m_tree->setHeaderLabels({tr("Action"), tr("Menu"), tr("Raccourci"), QString()}); + m_tree->header()->setSectionResizeMode(ActionColumn, QHeaderView::Stretch); + m_tree->header()->setSectionResizeMode(MenuColumn, QHeaderView::ResizeToContents); + m_tree->header()->setSectionResizeMode(SequenceColumn, QHeaderView::ResizeToContents); + m_tree->header()->setSectionResizeMode(ResetColumn, QHeaderView::ResizeToContents); m_tree->setEditTriggers(QAbstractItemView::NoEditTriggers); m_tree->setSelectionMode(QAbstractItemView::NoSelection); vlayout->addWidget(m_tree); @@ -106,7 +146,12 @@ ShortcutsConfigPage::ShortcutsConfigPage(QWidget *parent) : auto *reset_all_button = new QPushButton(tr("Tout réinitialiser"), this); connect(reset_all_button, &QPushButton::clicked, this, &ShortcutsConfigPage::resetAllRows); + auto *copy_button = new QPushButton(QET::Icons::EditCopy, tr("Copier la liste"), this); + copy_button->setToolTip(tr("Copie les raccourcis affichés, à coller dans un tableur ou un document")); + connect(copy_button, &QPushButton::clicked, this, &ShortcutsConfigPage::copyList); + auto *bottom_layout = new QHBoxLayout(); + bottom_layout->addWidget(copy_button); bottom_layout->addStretch(); bottom_layout->addWidget(reset_all_button); vlayout->addLayout(bottom_layout); @@ -139,6 +184,9 @@ void ShortcutsConfigPage::populateTable() m_tree->clear(); m_rows.clear(); + while (m_category_filter->count() > 1) { + m_category_filter->removeItem(1); + } m_rows.reserve(shortcuts.size()); QHash category_nodes; @@ -147,18 +195,22 @@ void ShortcutsConfigPage::populateTable() QTreeWidgetItem *category_item = category_nodes.value(info.category, nullptr); if (!category_item) { category_item = new QTreeWidgetItem(m_tree); - category_item->setText(0, info.category); + category_item->setText(ActionColumn, info.category); category_item->setFlags(category_item->flags() & ~Qt::ItemIsEditable); category_nodes.insert(info.category, category_item); + m_category_filter->addItem(info.category); } auto *child = new QTreeWidgetItem(category_item); - child->setText(0, info.description); + const QString menu_path = menuPath(info.action); + child->setText(ActionColumn, info.description); + child->setIcon(ActionColumn, info.icon); + child->setText(MenuColumn, menu_path); child->setFlags(child->flags() & ~Qt::ItemIsEditable); auto *edit = new QKeySequenceEdit(info.current_sequence, m_tree); connect(edit, &QKeySequenceEdit::editingFinished, this, &ShortcutsConfigPage::checkConflicts); - m_tree->setItemWidget(child, 1, edit); + m_tree->setItemWidget(child, SequenceColumn, edit); auto *reset_button = new QToolButton(m_tree); reset_button->setIcon(QET::Icons::EditUndo); @@ -166,9 +218,10 @@ void ShortcutsConfigPage::populateTable() reset_button->setAutoRaise(true); const int row_index = m_rows.size(); connect(reset_button, &QToolButton::clicked, this, [this, row_index]() { resetRow(row_index); }); - m_tree->setItemWidget(child, 2, reset_button); + m_tree->setItemWidget(child, ResetColumn, reset_button); - m_rows << Row{info.id, info.category, info.description, info.default_sequence, edit, child, false}; + m_rows << Row{info.id, info.category, info.description, menu_path, + info.default_sequence, edit, child, false}; } checkConflicts(); @@ -210,6 +263,9 @@ void ShortcutsConfigPage::applyFilter() : needle.split(QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); const int quick_filter = m_quick_filter->currentIndex(); + const QString category = m_category_filter->currentIndex() > 0 + ? m_category_filter->currentText() : QString(); + const QKeySequence key = m_key_search->keySequence(); int visible_actions = 0; for (const Row &row : std::as_const(m_rows)) { @@ -218,7 +274,8 @@ void ShortcutsConfigPage::applyFilter() // of "Shift"), which is precisely the kind of false positive that hides // the one binding the user is looking for. const QString text_haystack = normalizedForSearch( - row.category + QLatin1Char(' ') + row.description); + row.category + QLatin1Char(' ') + row.description + + QLatin1Char(' ') + row.menu_path); const QString sequence_text = normalizedForSearch(row.edit->keySequence().toString()); bool matches = true; @@ -230,6 +287,16 @@ void ShortcutsConfigPage::applyFilter() } } + if (matches && !category.isEmpty()) { + matches = row.category == category; + } + if (matches && !key.isEmpty()) { + //A pressed key also finds the sequences it starts, such + //as a two-key shortcut whose first key it is + const QKeySequence sequence = row.edit->keySequence(); + matches = !sequence.isEmpty() && key.matches(sequence) != QKeySequence::NoMatch; + } + if (matches) { switch (quick_filter) { case BoundOnly: @@ -252,7 +319,8 @@ void ShortcutsConfigPage::applyFilter() } } - const bool filtering = !needle.isEmpty() || quick_filter != ShowAll; + const bool filtering = !needle.isEmpty() || quick_filter != ShowAll + || !category.isEmpty() || !key.isEmpty(); for (int i = 0; i < m_tree->topLevelItemCount(); ++i) { QTreeWidgetItem *top = m_tree->topLevelItem(i); bool any_visible = false; @@ -322,11 +390,11 @@ void ShortcutsConfigPage::checkConflicts() other_descriptions << m_rows.at(other_row).description; } } - current_row.item->setBackground(0, QColor(255, 205, 205)); + current_row.item->setBackground(ActionColumn, QColor(255, 205, 205)); current_row.edit->setToolTip( tr("Ce raccourci est aussi utilisé par : %1").arg(other_descriptions.join(QStringLiteral(", ")))); } else { - current_row.item->setBackground(0, QBrush()); + current_row.item->setBackground(ActionColumn, QBrush()); current_row.edit->setToolTip(QString()); } } @@ -335,7 +403,9 @@ void ShortcutsConfigPage::checkConflicts() // conflicts-only quick filter); refresh the visible set so the list doesn't // show stale results. const bool filtering = !m_filter_edit->text().trimmed().isEmpty() - || m_quick_filter->currentIndex() != ShowAll; + || m_quick_filter->currentIndex() != ShowAll + || m_category_filter->currentIndex() > 0 + || !m_key_search->keySequence().isEmpty(); if (filtering) { applyFilter(); } @@ -374,6 +444,71 @@ void ShortcutsConfigPage::applyConf() } } +/** + @brief ShortcutsConfigPage::menuPath + @param action + @return where \a action is in the menu bar, such as "Projet › Scripts", + or an empty string for a command that is in no menu of the menu bar + (a toolbar or shortcut bar only command, or one with no live action) +*/ +QString ShortcutsConfigPage::menuPath(const QAction *action) +{ + if (!action) { + return QString(); + } + for (QObject *object : action->associatedObjects()) { + QStringList titles; + for (auto *menu = qobject_cast(object); menu; ) { + titles.prepend(menu->title().remove(QLatin1Char('&'))); + QMenu *parent_menu = nullptr; + bool in_menu_bar = false; + for (QObject *owner : menu->menuAction()->associatedObjects()) { + if (qobject_cast(owner)) { + in_menu_bar = true; + } else if (!parent_menu) { + parent_menu = qobject_cast(owner); + } + } + if (in_menu_bar) { + return titles.join(QStringLiteral(" › ")); + } + menu = parent_menu; + } + } + return QString(); +} + +/** + @brief ShortcutsConfigPage::listAsText + @return the shortcuts shown, as the edits currently hold them, one per + line with tab-separated columns and a header line: pasted into a + spreadsheet it fills one cell per column. +*/ +QString ShortcutsConfigPage::listAsText() const +{ + QStringList lines; + lines << QStringList{tr("Catégorie"), tr("Menu"), tr("Action"), tr("Raccourci")} + .join(QLatin1Char('\t')); + for (const Row &row : std::as_const(m_rows)) { + if (row.item->isHidden()) { + continue; + } + lines << QStringList{row.category, row.menu_path, row.description, + row.edit->keySequence().toString(QKeySequence::NativeText)} + .join(QLatin1Char('\t')); + } + return lines.join(QLatin1Char('\n')) + QLatin1Char('\n'); +} + +/** + @brief ShortcutsConfigPage::copyList + Put listAsText() on the clipboard. +*/ +void ShortcutsConfigPage::copyList() +{ + QApplication::clipboard()->setText(listAsText()); +} + QString ShortcutsConfigPage::title() const { return tr("Raccourcis", "configuration page title"); diff --git a/sources/ui/configpage/shortcutsconfigpage.h b/sources/ui/configpage/shortcutsconfigpage.h index 87772bcf8..446964be5 100644 --- a/sources/ui/configpage/shortcutsconfigpage.h +++ b/sources/ui/configpage/shortcutsconfigpage.h @@ -22,6 +22,7 @@ #include +class QAction; class QComboBox; class QKeySequenceEdit; class QLabel; @@ -48,11 +49,15 @@ class ShortcutsConfigPage : public ConfigPage QString title() const override; QIcon icon() const override; + static QString menuPath(const QAction *action); + QString listAsText() const; + private slots: void filterRows(const QString &filter_text); void quickFilterChanged(int index); void checkConflicts(); void resetAllRows(); + void copyList(); private: enum QuickFilter { @@ -66,6 +71,7 @@ class ShortcutsConfigPage : public ConfigPage QString id; QString category; QString description; + QString menu_path; QKeySequence default_sequence; QKeySequenceEdit *edit; QTreeWidgetItem *item; @@ -78,6 +84,8 @@ class ShortcutsConfigPage : public ConfigPage QLineEdit *m_filter_edit; QComboBox *m_quick_filter; + QComboBox *m_category_filter; + QKeySequenceEdit *m_key_search; QLabel *m_count_label; QTreeWidget *m_tree; QList m_rows; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 3b7e8c2b5..25a41b809 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -227,6 +227,23 @@ set_tests_properties(tst_commandsearch PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=o target_include_directories(tst_commandsearch PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_commandsearch PRIVATE Qt::Test Qt::Widgets) +# ShortcutsConfigPage: icon and menu columns, category filter, finding a +# command by pressing its key, and the copied list. +add_executable( + tst_shortcutspage + tst_shortcutspage.cpp + ${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.h + ${QET_DIR}/sources/ui/configpage/configpage.h + ${QET_DIR}/sources/shortcutmanager.cpp + ${QET_DIR}/sources/qeticons.cpp + ${QET_DIR}/sources/qetpalette.cpp + ${QET_DIR}/sources/qetstyle.cpp) +add_test(NAME tst_shortcutspage COMMAND tst_shortcutspage) +set_tests_properties(tst_shortcutspage PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_shortcutspage PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_shortcutspage PRIVATE Qt::Test Qt::Widgets Qt::Svg) + # scriptheader.h is header-only: the // ==QETScript== block that turns a # stored script into a button; and ShortcutManager::unregisterAction(), which # takes a deleted script's command out of the lists again. diff --git a/tests/qttest/tst_shortcutspage.cpp b/tests/qttest/tst_shortcutspage.cpp new file mode 100644 index 000000000..f150a452e --- /dev/null +++ b/tests/qttest/tst_shortcutspage.cpp @@ -0,0 +1,145 @@ +/* + 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 "shortcutmanager.h" +#include "ui/configpage/shortcutsconfigpage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + The Shortcuts configuration page: the icon and menu columns, the + category filter, finding a command by pressing its key, and the + copied list. +*/ +class tst_shortcutspage : public QObject +{ + Q_OBJECT + + QMainWindow m_window; + QAction *m_save = nullptr; + QAction *m_run = nullptr; + QAction *m_tool = nullptr; + + QTreeWidgetItem *row(QTreeWidget *tree, const QString &text) + { + const auto found = tree->findItems(text, Qt::MatchExactly | Qt::MatchRecursive, 0); + return found.isEmpty() ? nullptr : found.first(); + } + + QStringList visible(QTreeWidget *tree) + { + QStringList texts; + for (const QString &text : {QStringLiteral("Enregistrer"), QStringLiteral("Exécuter"), QStringLiteral("Outil")}) { + if (QTreeWidgetItem *item = row(tree, text); item && !item->isHidden()) { + texts << text; + } + } + return texts; + } + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + QSettings().remove(QStringLiteral("shortcuts")); + + QPixmap pixmap(16, 16); + pixmap.fill(Qt::red); + + QMenu *file = m_window.menuBar()->addMenu(QStringLiteral("&Fichier")); + QMenu *scripts = file->addMenu(QStringLiteral("&Scripts")); + m_save = file->addAction(QIcon(pixmap), QStringLiteral("&Enregistrer")); + m_run = scripts->addAction(QStringLiteral("Exécuter")); + //On a toolbar only, in no menu + m_tool = new QAction(QStringLiteral("Outil"), &m_window); + + ShortcutManager &manager = ShortcutManager::instance(); + manager.registerAction(m_save, QStringLiteral("t.save"), QStringLiteral("Catégorie A"), QKeySequence(Qt::CTRL | Qt::Key_S)); + manager.registerAction(m_run, QStringLiteral("t.run"), QStringLiteral("Catégorie A"), QKeySequence(Qt::CTRL | Qt::Key_R)); + manager.registerAction(m_tool, QStringLiteral("t.tool"), QStringLiteral("Catégorie B"), QKeySequence()); + } + + void menuPath() + { + QCOMPARE(ShortcutsConfigPage::menuPath(m_save), QStringLiteral("Fichier")); + QCOMPARE(ShortcutsConfigPage::menuPath(m_run), QStringLiteral("Fichier › Scripts")); + QCOMPARE(ShortcutsConfigPage::menuPath(m_tool), QString()); + QCOMPARE(ShortcutsConfigPage::menuPath(nullptr), QString()); + } + + void iconAndMenuColumns() + { + ShortcutsConfigPage page(nullptr); + auto *tree = page.findChild(); + QVERIFY(row(tree, QStringLiteral("Enregistrer"))); + QVERIFY(!row(tree, QStringLiteral("Enregistrer"))->icon(0).isNull()); + QVERIFY(row(tree, QStringLiteral("Outil"))->icon(0).isNull()); + QCOMPARE(row(tree, QStringLiteral("Exécuter"))->text(1), QStringLiteral("Fichier › Scripts")); + QCOMPARE(row(tree, QStringLiteral("Outil"))->text(1), QString()); + } + + void categoryFilter() + { + ShortcutsConfigPage page(nullptr); + auto *tree = page.findChild(); + auto *combo = page.findChild(QStringLiteral("categoryFilterCombo")); + QCOMPARE(visible(tree).size(), 3); + combo->setCurrentIndex(combo->findText(QStringLiteral("Catégorie B"))); + QCOMPARE(visible(tree), QStringList{QStringLiteral("Outil")}); + combo->setCurrentIndex(0); + QCOMPARE(visible(tree).size(), 3); + } + + void findByKey() + { + ShortcutsConfigPage page(nullptr); + auto *tree = page.findChild(); + auto *key = page.findChild(QStringLiteral("keySearchEdit")); + key->setKeySequence(QKeySequence(Qt::CTRL | Qt::Key_S)); + QCOMPARE(visible(tree), QStringList{QStringLiteral("Enregistrer")}); + key->setKeySequence(QKeySequence(Qt::CTRL | Qt::Key_J)); + QVERIFY(visible(tree).isEmpty()); + key->clear(); + QCOMPARE(visible(tree).size(), 3); + } + + void copiedList() + { + ShortcutsConfigPage page(nullptr); + auto *combo = page.findChild(QStringLiteral("categoryFilterCombo")); + combo->setCurrentIndex(combo->findText(QStringLiteral("Catégorie A"))); + const QStringList lines = page.listAsText().split(QLatin1Char('\n'), Qt::SkipEmptyParts); + QCOMPARE(lines.size(), 3); + QCOMPARE(lines.at(0).count(QLatin1Char('\t')), 3); + QVERIFY(lines.contains(QStringLiteral("Catégorie A\tFichier\tEnregistrer\t") + + QKeySequence(Qt::CTRL | Qt::Key_S).toString(QKeySequence::NativeText))); + QVERIFY(lines.contains(QStringLiteral("Catégorie A\tFichier › Scripts\tExécuter\t") + + QKeySequence(Qt::CTRL | Qt::Key_R).toString(QKeySequence::NativeText))); + } +}; + +QTEST_MAIN(tst_shortcutspage) +#include "tst_shortcutspage.moc" From a0cfa580bbdb6fa9321c8c720e038b4691b5a1fd Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Tue, 6 Oct 2026 09:41:01 +0200 Subject: [PATCH 10/20] Round the width when filling the element editor width box updateForm() filled the box with setValue(textWidth()), and setValue(int) truncates: a field 61.7 px wide showed 61 while editingFinished compared with qRound() = 62, so leaving the box still set the width to 61 and pushed an undo step. Round in both places. Co-Authored-By: Claude Opus 5.5 --- sources/editor/ui/dynamictextfieldeditor.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sources/editor/ui/dynamictextfieldeditor.cpp b/sources/editor/ui/dynamictextfieldeditor.cpp index 1640907ce..972de4176 100644 --- a/sources/editor/ui/dynamictextfieldeditor.cpp +++ b/sources/editor/ui/dynamictextfieldeditor.cpp @@ -157,7 +157,9 @@ void DynamicTextFieldEditor::updateForm() m_color_kpb -> setColor(m_text_field.data() -> color()); } #endif - ui -> m_width_sb -> setValue(m_text_field.data() -> textWidth()); + //Rounded as on_m_width_sb_editingFinished() compares it; + //setValue(int) would truncate 61.7 to 61 + ui -> m_width_sb -> setValue(qRound(m_text_field.data() -> textWidth())); ui -> m_font_pb -> setText(m_text_field -> font().family()); switch (m_text_field.data() -> textFrom()) { @@ -363,8 +365,8 @@ void DynamicTextFieldEditor::on_m_width_sb_editingFinished() qreal width = (qreal)ui -> m_width_sb -> value(); for (int i = 0; i < m_parts.length(); i++) { - //The box shows whole pixels: a width dragged with the mouse or - //fitted to the text is not rounded just by leaving the box. + //The box shows whole pixels: a width fitted to the text is not + //rounded just by leaving the box. if(ui -> m_width_sb -> value() != qRound(m_parts[i] -> textWidth())) { QPropertyUndoCommand *undo = new QPropertyUndoCommand(m_parts[i], "textWidth", m_parts[i] -> textWidth(), width); undo -> setText(tr("Modifier la largeur d'un texte")); From 6a681cde67c4b5d4d56e87447b668746a284c384 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Mon, 5 Oct 2026 17:57:32 +0200 Subject: [PATCH 11/20] Keep an element editor text field in place when its width changes In the element editor, changing the width of a right-aligned or centred dynamic text field (with the width box) moved its aligned edge: the field always grew or shrank from its left edge, so a right-aligned field's right edge moved. PartDynamicTextField::setTextWidth() now keeps the point chosen by the alignment in place, with the prepareAlignment()/finishAlignment() pair setPlainText() and setFont() already use. Not while the field is read from the .elmt, whose saved position is the one to keep. No file format change. Co-Authored-By: Claude Opus 5.5 --- .../editor/graphicspart/partdynamictextfield.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/sources/editor/graphicspart/partdynamictextfield.cpp b/sources/editor/graphicspart/partdynamictextfield.cpp index 027a07ac4..958dcd0b8 100644 --- a/sources/editor/graphicspart/partdynamictextfield.cpp +++ b/sources/editor/graphicspart/partdynamictextfield.cpp @@ -235,7 +235,10 @@ void PartDynamicTextField::fromXml(const QDomElement &dom_elmt) { m_uuid = QUuid(dom_elmt.attribute("uuid", QUuid::createUuid().toString())); setFrame(dom_elmt.attribute("frame", "false") == "true"? true : false); + //The saved position is the one to keep + m_block_alignment = true; setTextWidth(dom_elmt.attribute("text_width", QString::number(-1)).toDouble()); + m_block_alignment = false; QMetaEnum me = DynamicElementTextItem::textFromMetaEnum(); m_text_from = DynamicElementTextItem::TextFrom( @@ -431,13 +434,23 @@ bool PartDynamicTextField::frame() const return m_frame; } +/** + @brief PartDynamicTextField::setTextWidth + Set the width of this text (-1 = automatic width). The point of the text + chosen by its alignment stays in place, as when the text changes: a + right-aligned or centred field no longer grows to the right only, and + undoing a width change puts it back where it was. + @param width +*/ void PartDynamicTextField::setTextWidth(qreal width) { + prepareAlignment(); this -> document() -> setTextWidth(width); //Adjust the width, to ideal width if needed if(width > 0 && document() -> size().width() > width) document() -> setTextWidth(document() -> idealWidth()); + finishAlignment(); m_text_width = document() -> textWidth(); emit textWidthChanged(m_text_width); } From 5cad0c6159b87fc03ad1a22484872b4668d712c8 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Tue, 6 Oct 2026 09:46:17 +0200 Subject: [PATCH 12/20] Keep the text resize handles working after a scene change A scene drops the event filters of an item that leaves it. The handles installed theirs once, in the constructor, and only if the text was in a scene: after the text was removed and added back (undoing a delete does that), or when the handles were made before the text was in a scene, they no longer reacted, and a drag moved the text instead. TextResizeHandles now installs the filters again whenever it enters a scene. Tests: dragAfterLeavingTheScene, dragWhenCreatedOutsideAScene (both fail before). Co-Authored-By: Claude Opus 5.5 --- .../textresizehandles.cpp | 34 +++++++++++++++++-- .../textresizehandles.h | 2 ++ tests/qttest/tst_textresizehandles.cpp | 32 +++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/sources/QetGraphicsItemModeler/textresizehandles.cpp b/sources/QetGraphicsItemModeler/textresizehandles.cpp index c92a02b2c..53c90d77a 100644 --- a/sources/QetGraphicsItemModeler/textresizehandles.cpp +++ b/sources/QetGraphicsItemModeler/textresizehandles.cpp @@ -26,11 +26,12 @@ #include #include +#include /** @brief TextResizeHandles::TextResizeHandles @param text : the text to resize, becomes the parent of the handles. - It must be in a scene, the handles filter their own events. + The text may be in a scene or not yet. @param handle_size : see QETUtils::graphicsHandlerSize() */ TextResizeHandles::TextResizeHandles(QGraphicsTextItem *text, qreal handle_size) : @@ -48,10 +49,9 @@ TextResizeHandles::TextResizeHandles(QGraphicsTextItem *text, qreal handle_size) "double-cliquer pour une largeur automatique")); handle->setCursor(i == TextResize::TopLeft || i == TextResize::BottomRight ? Qt::SizeFDiagCursor : Qt::SizeBDiagCursor); - if (scene()) - handle->installSceneEventFilter(this); m_handles << handle; } + installHandleFilters(); m_size_connection = connect(text->document()->documentLayout(), &QAbstractTextDocumentLayout::documentSizeChanged, @@ -90,6 +90,34 @@ void TextResizeHandles::updateHandlesPos() m_handles.at(i)->setPos(TextResize::cornerOf(rect, TextResize::Corner(i))); } +/** + @brief TextResizeHandles::itemChange + A scene drops the event filters of an item that leaves it, so the + handles stop reacting to the mouse when the text is removed from its + scene and added back (undoing a delete does that), or when they were + created before the text was in a scene. Install them again each time + these handles enter a scene. +*/ +QVariant TextResizeHandles::itemChange(GraphicsItemChange change, const QVariant &value) +{ + if (change == QGraphicsItem::ItemSceneHasChanged) + installHandleFilters(); + return QGraphicsObject::itemChange(change, value); +} + +/** + @brief TextResizeHandles::installHandleFilters + Filter the mouse events of the handles, when they are in a scene. +*/ +void TextResizeHandles::installHandleFilters() +{ + if (!scene()) + return; + for (QetGraphicsHandlerItem *handle : std::as_const(m_handles)) + if (handle->scene() == scene()) + handle->installSceneEventFilter(this); +} + bool TextResizeHandles::sceneEventFilter(QGraphicsItem *watched, QEvent *event) { const int index = m_handles.indexOf(static_cast(watched)); diff --git a/sources/QetGraphicsItemModeler/textresizehandles.h b/sources/QetGraphicsItemModeler/textresizehandles.h index e098b54d4..2f92ef905 100644 --- a/sources/QetGraphicsItemModeler/textresizehandles.h +++ b/sources/QetGraphicsItemModeler/textresizehandles.h @@ -65,8 +65,10 @@ class TextResizeHandles : public QGraphicsObject protected: bool sceneEventFilter(QGraphicsItem *watched, QEvent *event) override; + QVariant itemChange(GraphicsItemChange change, const QVariant &value) override; private: + void installHandleFilters(); void pressed(TextResize::Corner corner, const QPointF &scene_pos); void moved(const QPointF &scene_pos); void released(); diff --git a/tests/qttest/tst_textresizehandles.cpp b/tests/qttest/tst_textresizehandles.cpp index d4e7f34e7..99f4c4779 100644 --- a/tests/qttest/tst_textresizehandles.cpp +++ b/tests/qttest/tst_textresizehandles.cpp @@ -215,6 +215,38 @@ private slots: } } + // The handles still work after the text left its scene and came back, + // as undoing a delete does: Qt drops scene event filters on removal. + void dragAfterLeavingTheScene() + { + QGraphicsScene scene; + auto *text = new WidthText(QStringLiteral("Motor protection switch Q12")); + scene.addItem(text); + auto *handles = new TextResizeHandles(text); + + scene.removeItem(text); + scene.addItem(text); + + const QPointF start_pos = text->pos(); + drag(scene, *handles, *text, TextResize::BottomRight, QPointF(-40, 0)); + QVERIFY(text->textWidth() > 0); + QCOMPARE(text->pos(), start_pos); + delete text; + } + + // The handles also work when they are created before the text is in a + // scene. + void dragWhenCreatedOutsideAScene() + { + QGraphicsScene scene; + auto *text = new WidthText(QStringLiteral("Motor protection switch Q12")); + auto *handles = new TextResizeHandles(text); + scene.addItem(text); + + drag(scene, *handles, *text, TextResize::BottomRight, QPointF(-40, 0)); + QVERIFY(text->textWidth() > 0); + } + // A text is never made narrower than its longest word. void dragStopsAtTheLongestWord() { From 315de47a12255484472c7c7a66afa04200411be4 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Tue, 6 Oct 2026 09:50:08 +0200 Subject: [PATCH 13/20] Keep the width of free texts in the project database The independent_text table had the text and rotation of a free text but not its width, and a width change did not update the row at all -- not even its width and height, which follow the wrapped text. Add a text_width column (NULL for the automatic width) and update the row on textWidthChanged. Test: tst_scriptfreetextwidth reads the table with qet.query() (fails before). Co-Authored-By: Claude Opus 5.5 --- sources/dataBase/projectdatabase.cpp | 11 ++- tests/qttest/CMakeLists.txt | 11 +++ tests/qttest/tst_scriptfreetextwidth.cpp | 100 +++++++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 tests/qttest/tst_scriptfreetextwidth.cpp 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/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 33134d0da..0c49470f8 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -543,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/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" From 069cf1e7b59769ba4e77afef73aeb98db9035472 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Tue, 6 Oct 2026 09:50:08 +0200 Subject: [PATCH 14/20] Tidy the free text width - setTextWidth() returns without a signal when the width does not change, which is also the case on every load of a text without one. - Say why IndependentTextItem::textWidth()/setTextWidth() hide the non-virtual QGraphicsTextItem functions, and what that means for code holding a base pointer. - The fixture no longer carries a /tmp path from the machine it was made on. Co-Authored-By: Claude Opus 5.5 --- sources/qetgraphicsitem/independenttextitem.cpp | 2 ++ sources/qetgraphicsitem/independenttextitem.h | 5 +++++ tests/qttest/fixtures/free_text_width.qet | 3 +-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/sources/qetgraphicsitem/independenttextitem.cpp b/sources/qetgraphicsitem/independenttextitem.cpp index 208906463..8e5d03ef4 100644 --- a/sources/qetgraphicsitem/independenttextitem.cpp +++ b/sources/qetgraphicsitem/independenttextitem.cpp @@ -130,6 +130,8 @@ 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 diff --git a/sources/qetgraphicsitem/independenttextitem.h b/sources/qetgraphicsitem/independenttextitem.h index 3ff526c5d..571c9eeea 100644 --- a/sources/qetgraphicsitem/independenttextitem.h +++ b/sources/qetgraphicsitem/independenttextitem.h @@ -53,6 +53,11 @@ class IndependentTextItem : public DiagramTextItem 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); diff --git a/tests/qttest/fixtures/free_text_width.qet b/tests/qttest/fixtures/free_text_width.qet index f2f9f019b..cd6eb53ee 100644 --- a/tests/qttest/fixtures/free_text_width.qet +++ b/tests/qttest/fixtures/free_text_width.qet @@ -3,8 +3,7 @@ 10/2/26 02-10-2026 2026-10-02 - skeleton - /tmp/qet-mcp-new-k41dmnyo/skeleton.qet + free_text_width 18:39 From 4b187e049fe5ecb08b4f6a29ae9d143c88ae8998 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Tue, 6 Oct 2026 09:53:49 +0200 Subject: [PATCH 15/20] Keep the DXF text lines of master for texts that do not wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit layoutLines() trimmed the trailing spaces of every line and kept non-breaking spaces, which the DXF export used to write as plain spaces (toPlainText()). A DXF R10 file has no code page, so a reader assuming Windows-1252 could show them as 'Â '. Now a paragraph that does not wrap gives exactly its line of toPlainText().split('\n') again (trailing spaces and tabs kept, non-breaking spaces as plain spaces,
as a line break). In a paragraph that wraps, every line loses its trailing spaces, so spaces that wrap to a line of their own give an empty line: not written, but it keeps the spacing of the next lines. Test: tst_textlines compares with toPlainText() for trailing spaces, tabs, non-breaking spaces,
and paragraphs, and checks wrapped lines (fails before). Co-Authored-By: Claude Opus 5.5 --- sources/textlines.h | 33 ++++++++++++++---- tests/qttest/tst_textlines.cpp | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/sources/textlines.h b/sources/textlines.h index e9a15e130..8d3382382 100644 --- a/sources/textlines.h +++ b/sources/textlines.h @@ -29,6 +29,11 @@ namespace TextLines @return the lines of document as they are laid out: a paragraph wrapped to the width of the text gives several lines. For the exports that write a text line by line (DXF). + A paragraph that is not wrapped gives exactly its line of + toPlainText().split('\n'), as these exports wrote before: trailing + spaces are kept, and non-breaking spaces become plain spaces. In a + wrapped paragraph, the lines lose their trailing spaces (the one a + line was broken at, and a line made only of spaces becomes empty). */ inline QStringList layoutLines(const QTextDocument *document) { @@ -39,18 +44,32 @@ namespace TextLines { const QString text = block.text(); const QTextLayout *layout = block.layout(); - if (!layout || layout->lineCount() == 0) { - lines << text; + const int count = layout ? layout->lineCount() : 0; + if (count == 0) { + lines << QString(text).replace(QChar::Nbsp, QLatin1Char(' ')); continue; } - for (int i = 0 ; i < layout->lineCount() ; ++i) + + //Wrapped: a line ends without a line break typed by the user + bool wrapped = false; + for (int i = 0 ; i < count - 1 ; ++i) { + const QTextLine line = layout->lineAt(i); + const int end = line.textStart() + line.textLength(); + if (end == 0 || text.at(end - 1) != QChar::LineSeparator) + wrapped = true; + } + + for (int i = 0 ; i < count ; ++i) { const QTextLine line = layout->lineAt(i); QString part = text.mid(line.textStart(), line.textLength()); - //The space the line was broken at, or a line separator - while (!part.isEmpty() && part.back().isSpace()) - part.chop(1); - lines << part; + if (part.endsWith(QChar::LineSeparator)) + part.chop(1); //A line break typed by the user (Shift+Enter) + if (wrapped) { + while (!part.isEmpty() && part.back().isSpace()) + part.chop(1); + } + lines << part.replace(QChar::Nbsp, QLatin1Char(' ')); } } return lines; diff --git a/tests/qttest/tst_textlines.cpp b/tests/qttest/tst_textlines.cpp index fb987c694..e9bd25ec3 100644 --- a/tests/qttest/tst_textlines.cpp +++ b/tests/qttest/tst_textlines.cpp @@ -34,6 +34,32 @@ private slots: QStringList({QStringLiteral("Motor protection"), QStringLiteral("switch Q12")})); } + // A text without a width gives exactly the lines the DXF export wrote + // before, toPlainText().split('\n'): trailing spaces and tabs kept, + // non-breaking spaces as plain spaces,
as a line break. + void unwrappedTextIsUnchanged_data() + { + QTest::addColumn("html"); + QTest::newRow("trailing spaces") << QStringLiteral("K1 \nswitch\t"); + QTest::newRow("non-breaking spaces") << QStringLiteral("

K1  24V DC

"); + QTest::newRow("line break") << QStringLiteral("

Wiper to 6
contacts

"); + QTest::newRow("paragraphs") << QStringLiteral("

relais voyant

preventa

"); + QTest::newRow("empty") << QString(); + } + + void unwrappedTextIsUnchanged() + { + QFETCH(QString, html); + QTextDocument document; + if (Qt::mightBeRichText(html)) + document.setHtml(html); + else + document.setPlainText(html); + + QCOMPARE(TextLines::layoutLines(&document), + document.toPlainText().split(QLatin1Char('\n'))); + } + // A text wrapped to its width gives one line per drawn line, without // the space the line was broken at. void wrappedTextGivesTheDrawnLines() @@ -48,6 +74,41 @@ private slots: QStringList({QStringLiteral("Motor"), QStringLiteral("protection"), QStringLiteral("switch"), QStringLiteral("Q12")})); } + + // A wrapped paragraph has no trailing spaces left, even on its last + // line: tabs and spaces that wrap to a line of their own (as in the + // affuteuse_250h example) give an empty line, which is not written but + // still keeps the line spacing. + void wrappedTextHasNoTrailingSpaces() + { + QTextDocument document(QStringLiteral("\tMotor\t\t ")); + QTextOption option = document.defaultTextOption(); + option.setWrapMode(QTextOption::WordWrap); + document.setDefaultTextOption(option); + document.setTextWidth(80); + + const QStringList lines = TextLines::layoutLines(&document); + QVERIFY(lines.size() > 1); + QVERIFY(lines.contains(QStringLiteral("Motor"))); + for (const QString &line : lines) + QVERIFY2(line.isEmpty() || !line.back().isSpace(), qPrintable(line)); + } + + // Non-breaking spaces also become plain spaces in a wrapped text. + void wrappedTextHasPlainSpaces() + { + QTextDocument document; + document.setHtml(QStringLiteral("

K1 24V and more words

")); + QTextOption option = document.defaultTextOption(); + option.setWrapMode(QTextOption::WordWrap); + document.setDefaultTextOption(option); + document.setTextWidth(1); + + const QStringList lines = TextLines::layoutLines(&document); + QCOMPARE(lines.first(), QStringLiteral("K1 24V")); + for (const QString &line : lines) + QVERIFY(!line.contains(QChar::Nbsp)); + } }; QTEST_MAIN(tst_textlines) From f6ac685ebec176627713d61635c11d8c75cf7cda Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Tue, 6 Oct 2026 09:53:49 +0200 Subject: [PATCH 16/20] Drop a machine-specific path from the DXF test fixture Co-Authored-By: Claude Opus 5.5 --- tests/qttest/fixtures/dxf_wrapped_text.qet | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/qttest/fixtures/dxf_wrapped_text.qet b/tests/qttest/fixtures/dxf_wrapped_text.qet index 920aebc74..25fdaf656 100644 --- a/tests/qttest/fixtures/dxf_wrapped_text.qet +++ b/tests/qttest/fixtures/dxf_wrapped_text.qet @@ -3,8 +3,7 @@ 10/2/26 02-10-2026 2026-10-02 - skeleton - /tmp/qet-mcp-new-k41dmnyo/skeleton.qet + dxf_wrapped_text 18:39 From 2680ae05f4f97b0dc8ce9d94b81797a05bbe4d14 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 6 Oct 2026 21:41:51 +1300 Subject: [PATCH 17/20] Fix KDE Frameworks links in README (#1328) The kcoreaddons and kwidgetsaddons links pointed at a kf6 branch that does not exist (404). KDE Frameworks 6 is developed on master; link to the projects' home on invent.kde.org, which the GitHub mirrors name as their homepage. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bbd246ca6..47e5432b3 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ Here are the technical choices made for the software development: * Libraries: Qt 6.x * [KF6 Framework](https://github.com/KDE) [Cmake](https://cmake.org/install/) - [kcoreaddons](https://github.com/KDE/kcoreaddons/tree/kf6) - [kwidgetsaddons](https://github.com/KDE/kwidgetsaddons/tree/kf6). + [kcoreaddons](https://invent.kde.org/frameworks/kcoreaddons) + [kwidgetsaddons](https://invent.kde.org/frameworks/kwidgetsaddons). * Coding language: [C++](https://en.wikipedia.org/wiki/C%2B%2B) * GUI translations: [Qt Linguist](http://doc.qt.io/qt-6/qtlinguist-index.html) * Version control: [GIT](https://github.com/qelectrotech/qelectrotech-source-mirror.git) From 43cc013e543c212c526ff1e949e8bb6689a4102e Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Tue, 6 Oct 2026 10:53:10 +0200 Subject: [PATCH 18/20] Fix tst_elementautonumids on macOS: give the binary its settings through QET_SETTINGS_DIR duplicatesAreNotNumberedWhenSwitchedOff turns two preferences off by writing /config/QElectroTech/QElectroTech.conf and pointing XDG_CONFIG_HOME there. Only Linux reads settings from that file. macOS keeps them in the system preferences, so the binary never saw the two preferences, numbered the pasted duplicates anyway, and the test failed on macOS, on master too. Write the file where QET_SETTINGS_DIR points instead (#1178), and set that variable for every run of the binary, so no run reads the user's own settings either. No file-format change. Tests: tst_elementautonumids fails on master on macOS and passes with this change. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Beat Hangartner --- tests/qttest/tst_elementautonumids.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/qttest/tst_elementautonumids.cpp b/tests/qttest/tst_elementautonumids.cpp index 2b213da82..1c703efda 100644 --- a/tests/qttest/tst_elementautonumids.cpp +++ b/tests/qttest/tst_elementautonumids.cpp @@ -47,14 +47,18 @@ class tst_elementautonumids : public QObject { const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++)); QDir().mkpath(home); + // QET_SETTINGS_DIR, not a file under XDG_CONFIG_HOME: macOS and + // Windows keep the settings elsewhere and would never read it. + const QString settings_dir = home + QStringLiteral("/settings"); if (!m_preferences.isEmpty()) { - QDir().mkpath(home + QStringLiteral("/config/QElectroTech")); - QFile settings(home + QStringLiteral("/config/QElectroTech/QElectroTech.conf")); + QDir().mkpath(settings_dir + QStringLiteral("/QElectroTech")); + QFile settings(settings_dir + QStringLiteral("/QElectroTech/QElectroTech.ini")); if (settings.open(QIODevice::WriteOnly)) { settings.write(m_preferences.toUtf8()); } } QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QET_SETTINGS_DIR"), settings_dir); env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); env.insert(QStringLiteral("HOME"), home); From 306c682112ba8855df08618e41a5a3524e2ee954 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 6 Oct 2026 22:02:11 +1300 Subject: [PATCH 19/20] Fix the free terminal table staying empty after a move (#1306) In the terminal strip manager, after a free terminal was moved into a strip, clicking another free terminal showed nothing on the right. It only came back after selecting a strip first. The tree remembers whether a free terminal is selected and only tells the window to update when that, or the selected strip, changes. Moving a terminal rebuilds the tree, which clears the selection and shows the empty page, but the "free terminal selected" flag stayed true. The next click on a free terminal then looked like no change and was ignored. The flag is now reset when the selection is cleared, and updated on every change it takes part in. Co-Authored-By: Claude Opus 5.5 --- .../TerminalStrip/ui/terminalstriptreedockwidget.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sources/TerminalStrip/ui/terminalstriptreedockwidget.cpp b/sources/TerminalStrip/ui/terminalstriptreedockwidget.cpp index 2611ee5b8..a4c3576a8 100644 --- a/sources/TerminalStrip/ui/terminalstriptreedockwidget.cpp +++ b/sources/TerminalStrip/ui/terminalstriptreedockwidget.cpp @@ -205,6 +205,7 @@ void TerminalStripTreeDockWidget::on_m_tree_view_currentItemChanged(QTreeWidgetI Q_UNUSED(previous) if (!current) { + m_current_is_free_terminal = false; setCurrentStrip(nullptr); return; } @@ -225,11 +226,12 @@ void TerminalStripTreeDockWidget::on_m_tree_view_currentItemChanged(QTreeWidgetI } } - if (strip_ != m_current_strip) { - setCurrentStrip(strip_); - } else if (current_is_free != m_current_is_free_terminal) { + //The flag must follow every selection change, or a reload of the tree + //(e.g. after moving a free terminal) leaves it stale and the next + //click on a free terminal shows nothing (#1306) + if (strip_ != m_current_strip || current_is_free != m_current_is_free_terminal) { m_current_is_free_terminal = current_is_free; - emit currentStripChanged(nullptr); + setCurrentStrip(strip_); } } From ce6a88c98ceda9b0851122a09daa081969148f1a Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 6 Oct 2026 22:32:43 +1300 Subject: [PATCH 20/20] Fix the MSVC build: QPrinter built through ?: in live mode print MSVC stopped with C2280 at liveserver.cpp:861 (issue #1333): it copies the result of the ?: expression, and QPrinter cannot be copied. GCC and Clang build the object in place under C++17, so Linux and CI built. QPrinter(info) gives the same printer in both cases: info is only set when printing to a printer, and QPrinter() itself constructs with an empty QPrinterInfo (qprinter.cpp, QPrinter::QPrinter(PrinterMode)). Co-Authored-By: Claude Opus 5.5 --- sources/scripting/liveserver.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index da9ccc352..f76aaaaec 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -858,7 +858,9 @@ QJsonObject LiveServer::print(const QJsonObject &request) if (fi.exists()) return failure(QStringLiteral("%1 already exists; printing never replaces a file").arg(file)); } - QPrinter printer = file.isEmpty() ? QPrinter(info) : QPrinter(); + //info is still null when printing to a file, which is what QPrinter() + //uses. No ?: here: MSVC copies its result, and QPrinter cannot be copied + QPrinter printer(info); //96 dpi, as the PDF export draws: symbols are replayed at the //device's resolution, so at a printer's 600-1200 dpi they came out //many times too big next to the wires (seen on a test print)