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("
");
+ 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"