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) diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 859e343c3..334d7e715 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -616,6 +616,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..53c90d77a --- /dev/null +++ b/sources/QetGraphicsItemModeler/textresizehandles.cpp @@ -0,0 +1,252 @@ +/* + 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 +#include + +/** + @brief TextResizeHandles::TextResizeHandles + @param text : the text to resize, becomes the parent of the handles. + The text may be in a scene or not yet. + @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); + m_handles << handle; + } + installHandleFilters(); + + 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))); +} + +/** + @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)); + 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..2f92ef905 --- /dev/null +++ b/sources/QetGraphicsItemModeler/textresizehandles.h @@ -0,0 +1,124 @@ +/* + 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; + 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(); + 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/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_); } } diff --git a/sources/dataBase/projectdatabase.cpp b/sources/dataBase/projectdatabase.cpp index 653bcf172..e93d53061 100644 --- a/sources/dataBase/projectdatabase.cpp +++ b/sources/dataBase/projectdatabase.cpp @@ -1248,6 +1248,8 @@ void projectDataBase::addDrawingItem(QGraphicsItem *item) } else if (auto text = qobject_cast(object)) { connect(text, &IndependentTextItem::uuidChanged, this, &projectDataBase::drawingItemChanged, unique); + //Also changes the height of the text, kept in the row + connect(text, &IndependentTextItem::textWidthChanged, this, &projectDataBase::drawingItemChanged, unique); //Sent by the document, not the item: drawingItemChanged() walks //back up to the item. It is the one signal that catches every way //the text changes -- typing, undo, a script's setTextContent(). @@ -1424,6 +1426,9 @@ bool projectDataBase::writeDrawingItem(QObject *object) query = &m_insert_independent_text_query; query->bindValue(QStringLiteral(":text"), text->toPlainText()); query->bindValue(QStringLiteral(":rotation"), text->rotation()); + //NULL for the automatic width + query->bindValue(QStringLiteral(":text_width"), text->textWidth() > 0 + ? QVariant(text->textWidth()) : QVariant()); } else if (auto image = qobject_cast(object)) { @@ -1667,7 +1672,7 @@ bool projectDataBase::createDataBase() "type VARCHAR(20), color VARCHAR(20), fill VARCHAR(20), " "FOREIGN KEY (diagram_uuid) REFERENCES diagram (uuid))", QStringLiteral("CREATE TABLE independent_text (") + drawing_columns + - "text TEXT, rotation REAL, " + "text TEXT, rotation REAL, text_width REAL, " "FOREIGN KEY (diagram_uuid) REFERENCES diagram (uuid))", QStringLiteral("CREATE TABLE image (") + drawing_columns + "pixel_width INTEGER, pixel_height INTEGER, " @@ -2123,8 +2128,8 @@ void projectDataBase::prepareQuery() ", :type, :color, :fill)"); m_insert_independent_text_query = QSqlQuery(m_data_base); m_insert_independent_text_query.prepare("INSERT OR REPLACE INTO independent_text (" + drawing_columns + - ", text, rotation) VALUES (" + drawing_values + - ", :text, :rotation)"); + ", text, rotation, text_width) VALUES (" + drawing_values + + ", :text, :rotation, :text_width)"); m_insert_image_query = QSqlQuery(m_data_base); m_insert_image_query.prepare("INSERT OR REPLACE INTO image (" + drawing_columns + ", pixel_width, pixel_height) VALUES (" + drawing_values + diff --git a/sources/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/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); } diff --git a/sources/editor/ui/dynamictextfieldeditor.cpp b/sources/editor/ui/dynamictextfieldeditor.cpp index 47d87a91c..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,7 +365,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 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 diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 49150857f..414cd80b6 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -366,6 +366,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(); @@ -482,6 +483,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); @@ -517,6 +519,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); { @@ -535,6 +538,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); @@ -576,6 +580,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()); @@ -588,6 +593,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()); @@ -600,6 +606,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()); @@ -654,6 +661,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()); }); @@ -669,10 +677,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()) { @@ -683,6 +693,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(); @@ -690,6 +701,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()); @@ -698,6 +710,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()); @@ -705,6 +718,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()) @@ -715,10 +729,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) @@ -729,6 +745,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) @@ -740,6 +757,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) @@ -751,10 +769,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)")); @@ -763,6 +783,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)")); @@ -813,6 +834,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")); @@ -824,6 +846,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); }); @@ -831,11 +854,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); @@ -846,15 +871,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); @@ -867,6 +895,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]() { @@ -938,6 +967,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) @@ -955,6 +990,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/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp index ac82aa5bd..00f8b030b 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,21 +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")); - // 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); + new QPropertyUndoCommand(this, "imageSource", QVariant::fromValue(oldSource), + QVariant::fromValue(newSource), undo); diagram()->undoStack().push(undo); } @@ -1854,7 +1870,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,19 +1882,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")); - // 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); + new QPropertyUndoCommand(this, "imageSource", QVariant::fromValue(oldSource), + QVariant::fromValue(newSource), undo); diagram()->undoStack().push(undo); } @@ -1919,41 +1934,20 @@ 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")); - // 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); + new QPropertyUndoCommand(this, "imageSource", QVariant::fromValue(oldSource), + QVariant::fromValue(newSource), 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 @@ -1962,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() { @@ -1994,9 +1968,41 @@ void DiagramImageItem::crop() if (dialog.exec() != QDialog::Accepted) return; - const QRect newCropRect = dialog.cropRect(); + applyCrop(dialog.cropRect()); +} + +/** + @brief DiagramImageItem::applyCrop + 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. +*/ +bool DiagramImageItem::applyCrop(const QRect &cropRect) +{ + if (!diagram() || diagram()->isReadOnly()) + 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, @@ -2009,7 +2015,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 +2035,9 @@ 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); + return true; } diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h index 570d06770..b3263f9b3 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; } + bool 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 diff --git a/sources/qetgraphicsitem/diagramtextitem.cpp b/sources/qetgraphicsitem/diagramtextitem.cpp index c94695926..cc44c6b0c 100644 --- a/sources/qetgraphicsitem/diagramtextitem.cpp +++ b/sources/qetgraphicsitem/diagramtextitem.cpp @@ -269,7 +269,9 @@ void DiagramTextItem::setHtml(const QString &text) block = block.next(); } - if (m_non_left_alignment) { + //Room for centred or right-aligned lines, unless the user has + //given this text a width of its own + if (m_non_left_alignment && !hasUserTextWidth()) { document()->setTextWidth(document()->idealWidth() + 40.0); } } diff --git a/sources/qetgraphicsitem/diagramtextitem.h b/sources/qetgraphicsitem/diagramtextitem.h index e0c807cca..323c0db56 100644 --- a/sources/qetgraphicsitem/diagramtextitem.h +++ b/sources/qetgraphicsitem/diagramtextitem.h @@ -110,6 +110,8 @@ class DiagramTextItem : public QGraphicsTextItem void prepareAlignment(); void finishAlignment(); + virtual bool hasUserTextWidth() const {return false;} + protected: bool diff --git a/sources/qetgraphicsitem/independenttextitem.cpp b/sources/qetgraphicsitem/independenttextitem.cpp index bb7af5fa8..8e5d03ef4 100644 --- a/sources/qetgraphicsitem/independenttextitem.cpp +++ b/sources/qetgraphicsitem/independenttextitem.cpp @@ -26,7 +26,9 @@ #include "../utils/qetutils.h" #include +#include #include +#include /** Constructeur @@ -36,6 +38,7 @@ IndependentTextItem::IndependentTextItem() : DiagramTextItem(nullptr) { ShownKinds::tag(this, ShownKinds::FreeTexts); + wrapAtWords(); //Start from the font last applied to a text item this session, //falling back to the app-wide Preferences default otherwise. setFont(LastUsedStyle::hasTextFont() ? LastUsedStyle::textFont() @@ -53,6 +56,19 @@ IndependentTextItem::IndependentTextItem(const QString &text) : DiagramTextItem(text, nullptr) { ShownKinds::tag(this, ShownKinds::FreeTexts); + wrapAtWords(); +} + +/** + @brief IndependentTextItem::wrapAtWords + A text with a width wraps between words only: a word longer than the + width goes past it rather than being cut, as for the texts of symbols. +*/ +void IndependentTextItem::wrapAtWords() +{ + QTextOption option = document()->defaultTextOption(); + option.setWrapMode(QTextOption::WordWrap); + document()->setDefaultTextOption(option); } /// Destructeur @@ -78,6 +94,10 @@ void IndependentTextItem::fromXml(const QDomElement &e) { QETUtils::fontFromString(font, e.attribute("font")); setFont(font); } + //Optional: absent for a text with the automatic width, the only + //kind older versions know (they show such a text unwrapped). + //Read after the text, setHtml() sets a width of its own. + setTextWidth(e.attribute(QStringLiteral("text_width"), QStringLiteral("-1")).toDouble()); } /** @@ -93,10 +113,41 @@ QDomElement IndependentTextItem::toXml(QDomDocument &document) const result.setAttribute("text", toHtml()); result.setAttribute("rotation", QString::number(QET::correctAngle(rotation()))); result.setAttribute("font", QETUtils::fontToString(font())); + //Only when set, so a text with the automatic width is saved as before + if (m_text_width > 0) + result.setAttribute("text_width", QString::number(m_text_width)); return(result); } +/** + @brief IndependentTextItem::setTextWidth + Set the width of this text (-1 = automatic width): the text wraps to + it, its top-left corner stays in place. + @param width +*/ +void IndependentTextItem::setTextWidth(qreal width) +{ + if (!qIsFinite(width) || width <= 0) + width = -1; + if (qFuzzyCompare(width, m_text_width)) + return; + + qreal document_width = width; + //The automatic width of a text with centred or right-aligned + //lines, as setHtml() gives it + if (width < 0 && m_non_left_alignment) + { + QScopedPointer natural(document()->clone()); + natural->setTextWidth(-1); + document_width = natural->idealWidth() + 40.0; + } + + document()->setTextWidth(document_width); + m_text_width = width; + emit textWidthChanged(width); +} + void IndependentTextItem::focusOutEvent(QFocusEvent *event) { DiagramTextItem::focusOutEvent(event); diff --git a/sources/qetgraphicsitem/independenttextitem.h b/sources/qetgraphicsitem/independenttextitem.h index 85b51ffd7..571c9eeea 100644 --- a/sources/qetgraphicsitem/independenttextitem.h +++ b/sources/qetgraphicsitem/independenttextitem.h @@ -29,10 +29,13 @@ class IndependentTextItem : public DiagramTextItem { Q_OBJECT + + Q_PROPERTY(qreal textWidth READ textWidth WRITE setTextWidth NOTIFY textWidthChanged) // constructors, destructor signals: void uuidChanged(); + void textWidthChanged(qreal width); public: IndependentTextItem(); @@ -49,11 +52,23 @@ class IndependentTextItem : public DiagramTextItem QUuid uuid() const {return m_uuid;} void setUuid(const QUuid &uuid) {m_uuid = uuid; emit uuidChanged();} void newUuid() {setUuid(QUuid::createUuid());} //create new uuid for this item + + //Hide QGraphicsTextItem::textWidth()/setTextWidth(), which are not + //virtual: called through a QGraphicsTextItem or DiagramTextItem + //pointer they would change the document only, and the width would + //be neither saved nor shown in the properties. Use these, or the + //"textWidth" property. + qreal textWidth() const {return m_text_width;} + void setTextWidth(qreal width); protected: void focusOutEvent(QFocusEvent *event) override; + bool hasUserTextWidth() const override {return m_text_width > 0;} private: + void wrapAtWords(); + QUuid m_uuid = QUuid::createUuid(); + qreal m_text_width = -1; }; #endif diff --git a/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(); 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) diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index ae16daba6..3ddbb537a 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -3475,6 +3475,44 @@ 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. + @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) +{ + 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; + } + return list.at(imageIndex)->applyCrop(QRect(x, y, width, height)); +} + +/** + @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 b548e74ec..be9bdf061 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -540,6 +540,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/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/textlines.h b/sources/textlines.h new file mode 100644 index 000000000..8d3382382 --- /dev/null +++ b/sources/textlines.h @@ -0,0 +1,79 @@ +/* + 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). + 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) + { + 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(); + const int count = layout ? layout->lineCount() : 0; + if (count == 0) { + lines << QString(text).replace(QChar::Nbsp, QLatin1Char(' ')); + continue; + } + + //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()); + 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; + } +} + +#endif // TEXTLINES_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/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 9e2ae2e61..a23836895 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) @@ -155,6 +163,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) @@ -227,8 +247,25 @@ 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) + # ToolbarSettings and its configuration page: defaults change nothing, -# saved values reach every open window's toolbars, panels are left alone. +# saved values reach every open window that took them, panels are left alone. add_executable( tst_toolbarsettings tst_toolbarsettings.cpp @@ -407,6 +444,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 @@ -421,6 +470,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. @@ -549,6 +608,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( @@ -611,6 +681,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/fixtures/dxf_wrapped_text.qet b/tests/qttest/fixtures/dxf_wrapped_text.qet new file mode 100644 index 000000000..25fdaf656 --- /dev/null +++ b/tests/qttest/fixtures/dxf_wrapped_text.qet @@ -0,0 +1,60 @@ + + + 10/2/26 + 02-10-2026 + 2026-10-02 + dxf_wrapped_text + 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/fixtures/free_text_width.qet b/tests/qttest/fixtures/free_text_width.qet new file mode 100644 index 000000000..cd6eb53ee --- /dev/null +++ b/tests/qttest/fixtures/free_text_width.qet @@ -0,0 +1,41 @@ + + + 10/2/26 + 02-10-2026 + 2026-10-02 + free_text_width + 18:39 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Imported elements + + + + diff --git a/tests/qttest/tst_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_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); diff --git a/tests/qttest/tst_freetextwidth.cpp b/tests/qttest/tst_freetextwidth.cpp new file mode 100644 index 000000000..5d6c3e801 --- /dev/null +++ b/tests/qttest/tst_freetextwidth.cpp @@ -0,0 +1,113 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include +#include +#include +#include +#include +#include + +/// Run the qelectrotech binary with arguments, without a display. +static bool runQet(const QStringList &arguments) +{ + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), arguments); + return proc.waitForFinished(60000) + && proc.exitStatus() == QProcess::NormalExit + && proc.exitCode() == 0; +} + +/// The number of lines the words starting with prefix are drawn on. +static int drawnLines(const QString &svg, const QString &prefix) +{ + const QRegularExpression text_re( + QStringLiteral("]*\\by=\"([^\"]+)\"[^>]*>\\s*%1").arg(prefix)); + QSet lines; + auto it = text_re.globalMatch(svg); + while (it.hasNext()) + lines.insert(it.next().captured(1)); + return lines.size(); +} + +/** + A free text with a width (text_width on its ) keeps it when the + project is saved again, and wraps to it. A text without a width is saved + without the attribute, as before, so older versions read it unchanged. +*/ +class tst_freetextwidth : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase() + { + QVERIFY2(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)), "qelectrotech binary not found"); + m_fixture = QFINDTESTDATA("fixtures/free_text_width.qet"); + QVERIFY2(!m_fixture.isEmpty(), "fixture project not found"); + QVERIFY(m_dir.isValid()); + } + + void widthIsSavedOnlyWhenSet() + { + const QString out = m_dir.filePath(QStringLiteral("resaved.qet")); + QVERIFY2(runQet({QStringLiteral("--resave"), m_fixture, out}), "--resave failed"); + + QFile file(out); + QVERIFY(file.open(QIODevice::ReadOnly)); + QDomDocument document; + QVERIFY(document.setContent(&file)); + + QMap widths; // first word -> text_width + const QDomNodeList inputs = document.elementsByTagName(QStringLiteral("input")); + for (int i = 0 ; i < inputs.size() ; ++i) { + const QDomElement input = inputs.at(i).toElement(); + QTextDocument text; + text.setHtml(input.attribute(QStringLiteral("text"))); + const QString first_word = text.toPlainText().section(QLatin1Char(' '), 0, 0); + widths.insert(first_word, input.hasAttribute(QStringLiteral("text_width")) + ? input.attribute(QStringLiteral("text_width")) + : QStringLiteral("none")); + } + + QCOMPARE(widths.value(QStringLiteral("FreeAlpha")), QStringLiteral("70")); + QCOMPARE(widths.value(QStringLiteral("OpenAlpha")), QStringLiteral("none")); + //Centred lines: the width of the user, not the one setHtml() gives + QCOMPARE(widths.value(QStringLiteral("CentAlpha")), QStringLiteral("120")); + } + + void textWrapsToItsWidth() + { + QVERIFY2(runQet({QStringLiteral("--export-svg"), m_fixture, m_dir.path()}), "--export-svg failed"); + QFile file(m_dir.filePath(QStringLiteral("01_diagram.svg"))); + QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString svg = QString::fromUtf8(file.readAll()); + + QVERIFY(drawnLines(svg, QStringLiteral("Free")) > 1); + QCOMPARE(drawnLines(svg, QStringLiteral("Open")), 1); + } + +private: + QString m_fixture; + QTemporaryDir m_dir; +}; + +QTEST_MAIN(tst_freetextwidth) +#include "tst_freetextwidth.moc" diff --git a/tests/qttest/tst_imagecropundo.cpp b/tests/qttest/tst_imagecropundo.cpp new file mode 100644 index 000000000..5ea009e82 --- /dev/null +++ b/tests/qttest/tst_imagecropundo.cpp @@ -0,0 +1,137 @@ +/* + 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); +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'); +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")); + // 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")); + + 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" 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" 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" diff --git a/tests/qttest/tst_textlines.cpp b/tests/qttest/tst_textlines.cpp new file mode 100644 index 000000000..e9bd25ec3 --- /dev/null +++ b/tests/qttest/tst_textlines.cpp @@ -0,0 +1,115 @@ +/* + 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 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() + { + 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")})); + } + + // 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) +#include "tst_textlines.moc" diff --git a/tests/qttest/tst_textresizehandles.cpp b/tests/qttest/tst_textresizehandles.cpp new file mode 100644 index 000000000..99f4c4779 --- /dev/null +++ b/tests/qttest/tst_textresizehandles.cpp @@ -0,0 +1,329 @@ +/* + 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))); + } + } + + // 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() + { + 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"