From 9eef8f95c92389d40b0aecf3fc8a826b39594340 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 6 Oct 2026 09:14:35 +1300 Subject: [PATCH 01/26] Add a Toolbars settings page: icon size, text with icons, lock A new page in Configurer QElectroTech, "Barres d'outils", sets for every window (folio editor, element editor, title block editor): - the icon size: default, 16, 24, 32 or 48 px - the buttons: icon only, text beside the icon, or text under it - locking the toolbars so they can't be dragged out of place OK applies it to the open windows at once. The defaults are Qt's own, and a default value removes its key, so nothing changes for anyone who never opens the page. A toolbar inside a panel is left alone; only the window's own toolbars are locked. New test tst_toolbarsettings: defaults change nothing, saved values reach every toolbar of an open window, back to defaults removes the keys. Third stage of discussion #1319. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 4 + sources/editor/ui/qetelementeditor.cpp | 2 + sources/qetapp.cpp | 2 + sources/qetdiagrameditor.cpp | 2 + sources/titleblock/qettemplateeditor.cpp | 2 + sources/toolbarsettings.cpp | 102 ++++++++++++++ sources/toolbarsettings.h | 45 +++++++ sources/ui/configpage/toolbarsconfigpage.cpp | 88 ++++++++++++ sources/ui/configpage/toolbarsconfigpage.h | 49 +++++++ tests/qttest/CMakeLists.txt | 18 +++ tests/qttest/tst_toolbarsettings.cpp | 133 +++++++++++++++++++ 11 files changed, 447 insertions(+) create mode 100644 sources/toolbarsettings.cpp create mode 100644 sources/toolbarsettings.h create mode 100644 sources/ui/configpage/toolbarsconfigpage.cpp create mode 100644 sources/ui/configpage/toolbarsconfigpage.h create mode 100644 tests/qttest/tst_toolbarsettings.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 3e7b6f573..859e343c3 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -301,6 +301,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/shortcutmanager.h ${QET_DIR}/sources/shortcutbarsettings.cpp ${QET_DIR}/sources/shortcutbarsettings.h + ${QET_DIR}/sources/toolbarsettings.cpp + ${QET_DIR}/sources/toolbarsettings.h ${QET_DIR}/sources/diagramcontexttoolbar.cpp ${QET_DIR}/sources/diagramcontexttoolbar.h ${QET_DIR}/sources/diagramgestureoverlay.cpp @@ -854,6 +856,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.h ${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.cpp ${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.h + ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.h ${QET_DIR}/sources/undocommand/addelementtextcommand.cpp ${QET_DIR}/sources/undocommand/addelementtextcommand.h diff --git a/sources/editor/ui/qetelementeditor.cpp b/sources/editor/ui/qetelementeditor.cpp index 7469a2a26..af39d81bb 100644 --- a/sources/editor/ui/qetelementeditor.cpp +++ b/sources/editor/ui/qetelementeditor.cpp @@ -54,6 +54,7 @@ #include "../../dxf/dxftoelmt.h" #include "../../qet_elementscaler/qet_elementscaler.h" #include "../UndoCommand/openelmtcommand.h" +#include "../../toolbarsettings.h" #include #include @@ -89,6 +90,7 @@ QETElementEditor::QETElementEditor(QWidget *parent) : readSettings(); // restoreGeometry before show() show(); readSettingsState(); // restoreState() must be called after show() in Qt6 + ToolbarSettings::applyTo(this); } /** diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index 67ba6ab23..ccd4981c7 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -42,6 +42,7 @@ #include "ui/configpage/generalconfigurationpage.h" #include "ui/configpage/shortcutsconfigpage.h" #include "ui/configpage/shortcutbarconfigpage.h" +#include "ui/configpage/toolbarsconfigpage.h" #include "machine_info.h" #include "TerminalStrip/ui/terminalstripeditorwindow.h" #include "qetversion.h" @@ -2225,6 +2226,7 @@ void QETApp::configureQET() cd.addPage(new PrintConfigPage()); cd.addPage(new ShortcutsConfigPage()); cd.addPage(new ShortcutBarConfigPage()); + cd.addPage(new ToolbarsConfigPage()); #ifdef QET_SPACEMOUSE_SUPPORT cd.addPage(new SpaceMouseConfigPage()); #endif diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index c7faddfa6..49150857f 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -85,6 +85,7 @@ #include "wiringlistexport.h" #include "ui/wiringlistdialog.h" #include "ui/terminalnumberingdialog.h" +#include "toolbarsettings.h" #include #include #include @@ -186,6 +187,7 @@ QETDiagramEditor::QETDiagramEditor(const QStringList &files, QWidget *parent) : readSettings(); // restoreGeometry before show() show(); readSettingsState(); // restoreState() must be called after show() in Qt6 + ToolbarSettings::applyTo(this); #ifdef QET_HAS_SCRIPTING setUpLiveIndicator(); setUpMacroRecorder(); diff --git a/sources/titleblock/qettemplateeditor.cpp b/sources/titleblock/qettemplateeditor.cpp index e3a44ecbb..04e57827b 100644 --- a/sources/titleblock/qettemplateeditor.cpp +++ b/sources/titleblock/qettemplateeditor.cpp @@ -27,6 +27,7 @@ #include "templatelocationsaver.h" #include "templatelogomanager.h" #include "templateview.h" +#include "../toolbarsettings.h" /** @param parent parent QWidget of this window @@ -46,6 +47,7 @@ QETTitleBlockTemplateEditor::QETTitleBlockTemplateEditor(QWidget *parent) : initActions(); initMenus(); initToolbars(); + ToolbarSettings::applyTo(this); readSettings(); } diff --git a/sources/toolbarsettings.cpp b/sources/toolbarsettings.cpp new file mode 100644 index 000000000..04d59ee11 --- /dev/null +++ b/sources/toolbarsettings.cpp @@ -0,0 +1,102 @@ +/* + 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 "toolbarsettings.h" + +#include +#include +#include +#include + +namespace { + const QString ICON_SIZE = QStringLiteral("toolbars/icon_size"); + const QString BUTTON_STYLE = QStringLiteral("toolbars/button_style"); + const QString LOCKED = QStringLiteral("toolbars/locked"); +} + +int ToolbarSettings::iconSize() +{ + return QSettings().value(ICON_SIZE, 0).toInt(); +} + +Qt::ToolButtonStyle ToolbarSettings::buttonStyle() +{ + const int style = QSettings().value(BUTTON_STYLE, int(Qt::ToolButtonIconOnly)).toInt(); + switch (style) { + case Qt::ToolButtonTextBesideIcon: + case Qt::ToolButtonTextUnderIcon: + return Qt::ToolButtonStyle(style); + default: + return Qt::ToolButtonIconOnly; + } +} + +bool ToolbarSettings::locked() +{ + return QSettings().value(LOCKED, false).toBool(); +} + +/** + @brief ToolbarSettings::save + Store the three values; a default value removes its key. +*/ +void ToolbarSettings::save(int icon_size, Qt::ToolButtonStyle style, bool locked) +{ + QSettings settings; + if (icon_size > 0) settings.setValue(ICON_SIZE, icon_size); + else settings.remove(ICON_SIZE); + if (style != Qt::ToolButtonIconOnly) settings.setValue(BUTTON_STYLE, int(style)); + else settings.remove(BUTTON_STYLE); + if (locked) settings.setValue(LOCKED, true); + else settings.remove(LOCKED); +} + +/** + @brief ToolbarSettings::applyTo + Apply the settings to \a window and its toolbars. The window passes + its icon size and button style on to every toolbar that has none of + its own; an invalid size gives the style's default back. +*/ +void ToolbarSettings::applyTo(QMainWindow *window) +{ + if (!window) { + return; + } + const int size = iconSize(); + window->setIconSize(size > 0 ? QSize(size, size) : QSize()); + window->setToolButtonStyle(buttonStyle()); + const bool movable = !locked(); + for (QToolBar *toolbar : window->findChildren()) { + //Not a toolbar inside a panel: only the window's own + if (window->toolBarArea(toolbar) != Qt::NoToolBarArea) { + toolbar->setMovable(movable); + } + } +} + +/** + @brief ToolbarSettings::applyToAll + Apply the settings to every open window, after they change. +*/ +void ToolbarSettings::applyToAll() +{ + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (auto *window = qobject_cast(widget)) { + applyTo(window); + } + } +} diff --git a/sources/toolbarsettings.h b/sources/toolbarsettings.h new file mode 100644 index 000000000..7e7dd1e1a --- /dev/null +++ b/sources/toolbarsettings.h @@ -0,0 +1,45 @@ +/* + 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 TOOLBARSETTINGS_H +#define TOOLBARSETTINGS_H + +#include +#include + +class QMainWindow; + +/** + @brief How every window's toolbars look: icon size, text with the + icons, and whether they can be moved. Stored in QSettings; a value + the user never changed keeps Qt's default, so nothing changes for + anyone who does not set one. +*/ +class ToolbarSettings +{ + public: + /// Icon size in pixels, or 0 for the style's default size + static int iconSize(); + static Qt::ToolButtonStyle buttonStyle(); + static bool locked(); + static void save(int icon_size, Qt::ToolButtonStyle style, bool locked); + + static void applyTo(QMainWindow *window); + static void applyToAll(); +}; + +#endif // TOOLBARSETTINGS_H diff --git a/sources/ui/configpage/toolbarsconfigpage.cpp b/sources/ui/configpage/toolbarsconfigpage.cpp new file mode 100644 index 000000000..bd1abd000 --- /dev/null +++ b/sources/ui/configpage/toolbarsconfigpage.cpp @@ -0,0 +1,88 @@ +/* + 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 "toolbarsconfigpage.h" + +#include "../../qeticons.h" +#include "../../toolbarsettings.h" + +#include +#include +#include +#include +#include +#include + +ToolbarsConfigPage::ToolbarsConfigPage(QWidget *parent) : + ConfigPage(parent) +{ + auto *vlayout = new QVBoxLayout(this); + vlayout->addWidget(new QLabel(title(), this)); + auto *line = new QFrame(this); + line->setFrameShape(QFrame::HLine); + vlayout->addWidget(line); + + m_icon_size = new QComboBox(this); + m_icon_size->setObjectName(QStringLiteral("iconSizeCombo")); + m_icon_size->addItem(tr("Par défaut"), 0); + m_icon_size->addItem(tr("Petites (16 px)"), 16); + m_icon_size->addItem(tr("Moyennes (24 px)"), 24); + m_icon_size->addItem(tr("Grandes (32 px)"), 32); + m_icon_size->addItem(tr("Très grandes (48 px)"), 48); + const int size_index = m_icon_size->findData(ToolbarSettings::iconSize()); + m_icon_size->setCurrentIndex(size_index < 0 ? 0 : size_index); + + m_button_style = new QComboBox(this); + m_button_style->setObjectName(QStringLiteral("buttonStyleCombo")); + m_button_style->addItem(tr("Icône seule"), int(Qt::ToolButtonIconOnly)); + m_button_style->addItem(tr("Texte à côté de l'icône"), int(Qt::ToolButtonTextBesideIcon)); + m_button_style->addItem(tr("Texte sous l'icône"), int(Qt::ToolButtonTextUnderIcon)); + m_button_style->setCurrentIndex(m_button_style->findData(int(ToolbarSettings::buttonStyle()))); + + m_locked = new QCheckBox(tr("Verrouiller les barres d'outils (elles ne peuvent plus être déplacées)"), this); + m_locked->setObjectName(QStringLiteral("lockedCheck")); + m_locked->setChecked(ToolbarSettings::locked()); + + auto *form = new QFormLayout(); + form->addRow(tr("Taille des icônes :"), m_icon_size); + form->addRow(tr("Boutons :"), m_button_style); + form->addRow(m_locked); + vlayout->addLayout(form); + + auto *hint = new QLabel(tr("Pour afficher ou masquer une barre d'outils, faites un clic droit sur une barre d'outils."), this); + hint->setWordWrap(true); + vlayout->addWidget(hint); + vlayout->addStretch(); +} + +void ToolbarsConfigPage::applyConf() +{ + ToolbarSettings::save(m_icon_size->currentData().toInt(), + Qt::ToolButtonStyle(m_button_style->currentData().toInt()), + m_locked->isChecked()); + ToolbarSettings::applyToAll(); +} + +QString ToolbarsConfigPage::title() const +{ + return tr("Barres d'outils", "configuration page title"); +} + +QIcon ToolbarsConfigPage::icon() const +{ + return QET::Icons::ConfigureToolbars; +} diff --git a/sources/ui/configpage/toolbarsconfigpage.h b/sources/ui/configpage/toolbarsconfigpage.h new file mode 100644 index 000000000..175c58369 --- /dev/null +++ b/sources/ui/configpage/toolbarsconfigpage.h @@ -0,0 +1,49 @@ +/* + 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 TOOLBARSCONFIGPAGE_H +#define TOOLBARSCONFIGPAGE_H + +#include "configpage.h" + +class QCheckBox; +class QComboBox; + +/** + @brief The ToolbarsConfigPage class + Toolbar icon size, text with the icons, and locking the toolbars in + place, for every window. Saved and applied to the open windows by + applyConf(). +*/ +class ToolbarsConfigPage : public ConfigPage +{ + Q_OBJECT + + public: + explicit ToolbarsConfigPage(QWidget *parent = nullptr); + + void applyConf() override; + QString title() const override; + QIcon icon() const override; + + private: + QComboBox *m_icon_size; + QComboBox *m_button_style; + QCheckBox *m_locked; +}; + +#endif // TOOLBARSCONFIGPAGE_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 3b7e8c2b5..9e2ae2e61 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -227,6 +227,24 @@ 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) +# ToolbarSettings and its configuration page: defaults change nothing, +# saved values reach every open window's toolbars, panels are left alone. +add_executable( + tst_toolbarsettings + tst_toolbarsettings.cpp + ${QET_DIR}/sources/toolbarsettings.cpp + ${QET_DIR}/sources/toolbarsettings.h + ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.h + ${QET_DIR}/sources/ui/configpage/configpage.h + ${QET_DIR}/sources/qeticons.cpp + ${QET_DIR}/sources/qetpalette.cpp + ${QET_DIR}/sources/qetstyle.cpp) +add_test(NAME tst_toolbarsettings COMMAND tst_toolbarsettings) +set_tests_properties(tst_toolbarsettings PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_toolbarsettings PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_toolbarsettings PRIVATE Qt::Test Qt::Widgets Qt::Svg) + # scriptheader.h is header-only: the // ==QETScript== block that turns a # stored script into a button; and ShortcutManager::unregisterAction(), which # takes a deleted script's command out of the lists again. diff --git a/tests/qttest/tst_toolbarsettings.cpp b/tests/qttest/tst_toolbarsettings.cpp new file mode 100644 index 000000000..1b77c01a3 --- /dev/null +++ b/tests/qttest/tst_toolbarsettings.cpp @@ -0,0 +1,133 @@ +/* + 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 "toolbarsettings.h" +#include "ui/configpage/toolbarsconfigpage.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + ToolbarSettings and its configuration page: defaults change nothing, + saved values reach every open window's toolbars, and a toolbar inside + a panel is left alone. +*/ +class tst_toolbarsettings : public QObject +{ + Q_OBJECT + + QMainWindow *m_window = nullptr; + QToolBar *m_top = nullptr; + QToolBar *m_left = nullptr; + QToolBar *m_in_dock = nullptr; + QSize m_default_size; + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + QSettings().remove(QStringLiteral("toolbars")); + + m_window = new QMainWindow(); + m_default_size = m_window->iconSize(); + m_top = new QToolBar(QStringLiteral("top"), m_window); + m_window->addToolBar(Qt::TopToolBarArea, m_top); + //Created without a parent, as some QET toolbars are + m_left = new QToolBar(QStringLiteral("left")); + m_window->addToolBar(Qt::LeftToolBarArea, m_left); + auto *dock = new QDockWidget(m_window); + m_in_dock = new QToolBar(dock); + dock->setWidget(m_in_dock); + m_window->addDockWidget(Qt::LeftDockWidgetArea, dock); + m_window->show(); + } + + void cleanupTestCase() + { + delete m_window; + } + + void defaultsChangeNothing() + { + QCOMPARE(ToolbarSettings::iconSize(), 0); + QCOMPARE(ToolbarSettings::buttonStyle(), Qt::ToolButtonIconOnly); + QCOMPARE(ToolbarSettings::locked(), false); + ToolbarSettings::applyTo(m_window); + QCOMPARE(m_window->iconSize(), m_default_size); + QCOMPARE(m_top->iconSize(), m_default_size); + QCOMPARE(m_top->toolButtonStyle(), Qt::ToolButtonIconOnly); + QVERIFY(m_top->isMovable()); + QVERIFY(m_left->isMovable()); + } + + void pageSavesAndAppliesToOpenWindows() + { + { + ToolbarsConfigPage page; + auto *size = page.findChild(QStringLiteral("iconSizeCombo")); + auto *style = page.findChild(QStringLiteral("buttonStyleCombo")); + size->setCurrentIndex(size->findData(32)); + style->setCurrentIndex(style->findData(int(Qt::ToolButtonTextUnderIcon))); + page.findChild(QStringLiteral("lockedCheck"))->setChecked(true); + page.applyConf(); + } + QCOMPARE(ToolbarSettings::iconSize(), 32); + QCOMPARE(ToolbarSettings::buttonStyle(), Qt::ToolButtonTextUnderIcon); + QVERIFY(ToolbarSettings::locked()); + + QCOMPARE(m_top->iconSize(), QSize(32, 32)); + QCOMPARE(m_left->iconSize(), QSize(32, 32)); + QCOMPARE(m_top->toolButtonStyle(), Qt::ToolButtonTextUnderIcon); + QVERIFY(!m_top->isMovable()); + QVERIFY(!m_left->isMovable()); + //A toolbar inside a panel is not one of the window's toolbars + QVERIFY(m_in_dock->isMovable()); + + //A page opened now shows what was saved + ToolbarsConfigPage page; + QCOMPARE(page.findChild(QStringLiteral("iconSizeCombo"))->currentData().toInt(), 32); + QVERIFY(page.findChild(QStringLiteral("lockedCheck"))->isChecked()); + } + + void backToDefaultsRemovesTheKeys() + { + ToolbarSettings::save(0, Qt::ToolButtonIconOnly, false); + QVERIFY(!QSettings().contains(QStringLiteral("toolbars/icon_size"))); + QVERIFY(!QSettings().contains(QStringLiteral("toolbars/button_style"))); + QVERIFY(!QSettings().contains(QStringLiteral("toolbars/locked"))); + ToolbarSettings::applyToAll(); + QCOMPARE(m_top->iconSize(), m_default_size); + QCOMPARE(m_top->toolButtonStyle(), Qt::ToolButtonIconOnly); + QVERIFY(m_top->isMovable()); + } + + void unknownStyleFallsBack() + { + QSettings().setValue(QStringLiteral("toolbars/button_style"), 99); + QCOMPARE(ToolbarSettings::buttonStyle(), Qt::ToolButtonIconOnly); + QSettings().remove(QStringLiteral("toolbars/button_style")); + } +}; + +QTEST_MAIN(tst_toolbarsettings) +#include "tst_toolbarsettings.moc" From b07378bf6e39d58c27135cb68d72088039adad1c Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Mon, 5 Oct 2026 17:52:44 +0200 Subject: [PATCH 02/26] Set the width of free texts in their properties A "Width" spin box in the properties of a free text sets the width it wraps to (previous commit). "Auto" (-1) is the automatic width. It also works with several selected texts, like the size and the angle; texts with different widths show "Auto" and are left alone unless the box is changed. Co-Authored-By: Claude Opus 5.5 --- sources/ui/inditextpropertieswidget.cpp | 45 +++++++++++++++++++++++++ sources/ui/inditextpropertieswidget.ui | 39 +++++++++++++++++++-- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/sources/ui/inditextpropertieswidget.cpp b/sources/ui/inditextpropertieswidget.cpp index 0ebe9ba15..bae484752 100644 --- a/sources/ui/inditextpropertieswidget.cpp +++ b/sources/ui/inditextpropertieswidget.cpp @@ -27,6 +27,15 @@ #include #include +/** + @return width as shown in the width spin box: whole pixels, -1 for + the automatic width ("Auto"). +*/ +static int spinBoxWidth(qreal width) +{ + return width > 0 ? qRound(width) : -1; +} + /** @brief IndiTextPropertiesWidget::IndiTextPropertiesWidget @param text : the text to edit @@ -84,6 +93,7 @@ void IndiTextPropertiesWidget::setText(IndependentTextItem *text) m_connect_list << connect(m_text.data(), &IndependentTextItem::rotationChanged, this, &IndiTextPropertiesWidget::updateUi); m_connect_list << connect(m_text.data(), &IndependentTextItem::fontChanged, this, &IndiTextPropertiesWidget::updateUi); m_connect_list << connect(m_text.data(), &IndependentTextItem::textEdited, this, &IndiTextPropertiesWidget::updateUi); + m_connect_list << connect(m_text.data(), &IndependentTextItem::textWidthChanged, this, &IndiTextPropertiesWidget::updateUi); updateUi(); } @@ -209,6 +219,10 @@ QUndoCommand *IndiTextPropertiesWidget::associatedUndo() const undo = new QPropertyUndoCommand(m_text.data(), "font", m_text->font(), m_selected_font); undo->setText(tr("Modifier la police d'un champ texte")); } + if (ui->m_width_sb->value() != spinBoxWidth(m_text->textWidth())) { + undo = new QPropertyUndoCommand(m_text.data(), "textWidth", m_text->textWidth(), qreal(ui->m_width_sb->value())); + undo->setText(tr("Modifier la largeur d'un champ texte")); + } return undo; } @@ -218,9 +232,11 @@ QUndoCommand *IndiTextPropertiesWidget::associatedUndo() const bool size_equal = true; bool angle_equal = true; bool font_equal = true; + bool width_equal = true; qreal rotation_ = m_text_list.first()->rotation(); int size_ = m_text_list.first()->font().pointSize(); QFont font_ = m_text_list.first()->font(); + int width_ = spinBoxWidth(m_text_list.first()->textWidth()); for (QPointer piti : m_text_list) { if (piti->rotation() != rotation_) { @@ -232,6 +248,9 @@ QUndoCommand *IndiTextPropertiesWidget::associatedUndo() const if (piti->font() != font_) { font_equal = false; } + if (spinBoxWidth(piti->textWidth()) != width_) { + width_equal = false; + } } if ((angle_equal && (ui->m_angle_sb->value() != rotation_)) || @@ -279,6 +298,20 @@ QUndoCommand *IndiTextPropertiesWidget::associatedUndo() const } } } + else if ((width_equal && (ui->m_width_sb->value() != width_)) || + (!width_equal && (ui->m_width_sb->value() != ui->m_width_sb->minimum()))) + { + for (QPointer piti : m_text_list) + { + if (piti) + { + if (!parent_undo) { + parent_undo = new QUndoCommand(tr("Modifier la largeur de plusieurs champs texte")); + } + new QPropertyUndoCommand(piti.data(), "textWidth", piti->textWidth(), qreal(ui->m_width_sb->value()), parent_undo); + } + } + } return parent_undo; } } @@ -307,6 +340,9 @@ QUndoCommand *IndiTextPropertiesWidget::associatedUndo() const if (m_font_is_selected && m_selected_font != m_text->font()) { new QPropertyUndoCommand(m_text.data(), "font", m_text->font(), m_selected_font, undo); } + if (ui->m_width_sb->value() != spinBoxWidth(m_text->textWidth())) { + new QPropertyUndoCommand(m_text.data(), "textWidth", m_text->textWidth(), qreal(ui->m_width_sb->value()), undo); + } if (undo->childCount()) { return undo; @@ -337,6 +373,7 @@ void IndiTextPropertiesWidget::setUpEditConnection() m_edit_connection << connect(ui->m_line_edit, &QLineEdit::textEdited, this, &IndiTextPropertiesWidget::apply); } m_edit_connection << connect(ui->m_angle_sb, QOverload::of(&QDoubleSpinBox::valueChanged), this, &IndiTextPropertiesWidget::apply); + m_edit_connection << connect(ui->m_width_sb, QOverload::of(&QSpinBox::valueChanged), this, &IndiTextPropertiesWidget::apply); m_edit_connection << connect(ui->m_size_sb, QOverload::of(&QSpinBox::valueChanged), [this]() { this->m_selected_font.setPointSize(ui->m_size_sb->value()); @@ -372,6 +409,7 @@ void IndiTextPropertiesWidget::updateUi() ui->m_line_edit->setText(m_text->toPlainText()); ui->m_angle_sb->setValue(m_text->rotation()); ui->m_size_sb->setValue(m_text->font().pointSize()); + ui->m_width_sb->setValue(spinBoxWidth(m_text->textWidth())); ui->m_line_edit->setDisabled(m_text->isHtml() ? true : false); ui->m_size_sb->setDisabled(m_text->isHtml() ? true : false); @@ -385,9 +423,11 @@ void IndiTextPropertiesWidget::updateUi() bool size_equal = true; bool angle_equal = true; bool font_equal = true; + bool width_equal = true; qreal rotation_ = m_text_list.first()->rotation(); int size_ = m_text_list.first()->font().pointSize(); QFont font_ = m_text_list.first()->font(); + int width_ = spinBoxWidth(m_text_list.first()->textWidth()); for (QPointer piti : m_text_list) { @@ -400,8 +440,13 @@ void IndiTextPropertiesWidget::updateUi() if (piti->font() != font_) { font_equal = false; } + if (spinBoxWidth(piti->textWidth()) != width_) { + width_equal = false; + } } ui->m_angle_sb->setValue(angle_equal ? rotation_ : 0); + //Different widths show the minimum, as the other boxes do + ui->m_width_sb->setValue(width_equal ? width_ : ui->m_width_sb->minimum()); bool valid_ = true; for (QPointer piti : m_text_list) { diff --git a/sources/ui/inditextpropertieswidget.ui b/sources/ui/inditextpropertieswidget.ui index 6b370bf5f..6bdb94422 100644 --- a/sources/ui/inditextpropertieswidget.ui +++ b/sources/ui/inditextpropertieswidget.ui @@ -64,7 +64,7 @@ - + Qt::Vertical @@ -126,7 +126,39 @@ - + + + + Largeur : + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Largeur du texte, le texte passe à la ligne. Auto : pas de retour à la ligne automatique + + + Auto + + + px + + + -1 + + + 10000 + + + -1 + + + + Le contenu, la taille et la police du texte ne peuvent être modifié car formaté en html. @@ -140,7 +172,7 @@ Veuillez utiliser l'éditeur avancé pour cela. - + Cliquez ici pour annuler le formatage html @@ -171,6 +203,7 @@ Veuillez utiliser l'éditeur avancé pour cela. m_y_sb m_angle_sb m_size_sb + m_width_sb m_break_html_pb From 2989f6cb4b74b0e5cb525e2dfb008007262d801a Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Mon, 5 Oct 2026 17:56:22 +0200 Subject: [PATCH 03/26] Resize free texts by dragging a corner A selected free text gets the corner handles of the previous commit: dragging one sets the width the text wraps to, keeping the opposite corner in place; double-clicking a handle goes back to the automatic width. One undo step per drag, on the folio's undo stack. The handles are hidden while the text is typed in. DiagramTextItem shows the handles for a subclass that asks for them (textResizeHandlesWanted()); only free texts do for now. Co-Authored-By: Claude Opus 5.5 --- sources/qetgraphicsitem/diagramtextitem.cpp | 84 +++++++++++++++++++ sources/qetgraphicsitem/diagramtextitem.h | 12 +++ .../qetgraphicsitem/independenttextitem.cpp | 10 +++ sources/qetgraphicsitem/independenttextitem.h | 1 + 4 files changed, 107 insertions(+) diff --git a/sources/qetgraphicsitem/diagramtextitem.cpp b/sources/qetgraphicsitem/diagramtextitem.cpp index cc44c6b0c..01e4829d9 100644 --- a/sources/qetgraphicsitem/diagramtextitem.cpp +++ b/sources/qetgraphicsitem/diagramtextitem.cpp @@ -22,6 +22,8 @@ #include "../qetapp.h" #include "../richtext/richtexteditor_p.h" #include "../textanchor.h" +#include "../utils/qetutils.h" +#include "../QetGraphicsItemModeler/textresizehandles.h" /** @brief DiagramTextItem::DiagramTextItem @@ -335,6 +337,7 @@ void DiagramTextItem::focusInEvent(QFocusEvent *event) m_previous_html_text = toHtml(); m_previous_text = toPlainText(); + refreshTextResizeHandles(); } /** @@ -357,6 +360,87 @@ void DiagramTextItem::focusOutEvent(QFocusEvent *event) setFlag(QGraphicsItem::ItemIsMovable, true); setFlag(QGraphicsTextItem::ItemIsFocusable, false); + refreshTextResizeHandles(); +} + +/** + @brief DiagramTextItem::itemChange + Show or hide the resize handles with the selection. + @param change + @param value + @return +*/ +QVariant DiagramTextItem::itemChange(GraphicsItemChange change, const QVariant &value) +{ + if (change == QGraphicsItem::ItemSelectedHasChanged) + refreshTextResizeHandles(); + else if (change == QGraphicsItem::ItemSceneHasChanged && !scene()) + removeTextResizeHandles(); + + return QGraphicsTextItem::itemChange(change, value); +} + +/** + @brief DiagramTextItem::textResizeHandlesWanted + @return true when the corner handles to change the width of this text + should be shown. A text without a "textWidth" property never has them. +*/ +bool DiagramTextItem::textResizeHandlesWanted() const +{ + return false; +} + +/** + @brief DiagramTextItem::isEditing + @return true while the text itself is being typed in +*/ +bool DiagramTextItem::isEditing() const +{ + return textInteractionFlags() & Qt::TextEditable; +} + +/** + @brief DiagramTextItem::refreshTextResizeHandles + Create or remove the corner handles to change the width of this text, + according to textResizeHandlesWanted(). + Called from itemChange() and when the edition starts or ends, not from + paint(): moving items from paint() left fragments behind + (qelectrotech#1002). +*/ +void DiagramTextItem::refreshTextResizeHandles() +{ + const bool wanted = scene() && textResizeHandlesWanted(); + if (wanted && !m_resize_handles) + { + m_resize_handles = new TextResizeHandles(this, QETUtils::graphicsHandlerSize(this)); + connect(m_resize_handles, &TextResizeHandles::resizeFinished, + this, &DiagramTextItem::pushResizeCommand); + } + else if (!wanted && m_resize_handles) { + removeTextResizeHandles(); + } +} + +/** + @brief DiagramTextItem::removeTextResizeHandles +*/ +void DiagramTextItem::removeTextResizeHandles() +{ + delete m_resize_handles; + m_resize_handles = nullptr; +} + +/** + @brief DiagramTextItem::pushResizeCommand + Make the width change done with the resize handles undoable. The change + is already applied, live during the drag. +*/ +void DiagramTextItem::pushResizeCommand(qreal old_width, qreal new_width, + QPointF old_pos, QPointF new_pos) +{ + if (Diagram *diagram_ = diagram()) + diagram_->undoStack().push(new TextResizeCommand(this, old_width, new_width, + old_pos, new_pos)); } /** diff --git a/sources/qetgraphicsitem/diagramtextitem.h b/sources/qetgraphicsitem/diagramtextitem.h index 323c0db56..e43f1f1de 100644 --- a/sources/qetgraphicsitem/diagramtextitem.h +++ b/sources/qetgraphicsitem/diagramtextitem.h @@ -24,6 +24,7 @@ class Diagram; class QDomElement; class QDomDocument; +class TextResizeHandles; /** This class represents a selectable, movable and editable text field on a @@ -90,12 +91,16 @@ class DiagramTextItem : public QGraphicsTextItem void setPlainText(const QString &text); bool isHtml() const; + void refreshTextResizeHandles(); + TextResizeHandles *textResizeHandles() const {return m_resize_handles;} + protected: void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override; void focusInEvent(QFocusEvent *) override; void focusOutEvent(QFocusEvent *) override; + QVariant itemChange(GraphicsItemChange change, const QVariant &value) override; void mouseDoubleClickEvent (QGraphicsSceneMouseEvent *event) override; void mousePressEvent (QGraphicsSceneMouseEvent *event) override; @@ -110,7 +115,10 @@ class DiagramTextItem : public QGraphicsTextItem void prepareAlignment(); void finishAlignment(); + virtual bool textResizeHandlesWanted() const; virtual bool hasUserTextWidth() const {return false;} + bool isEditing() const; + void removeTextResizeHandles(); protected: @@ -128,7 +136,11 @@ class DiagramTextItem : public QGraphicsTextItem QPointF m_mouse_to_origin_movement; private: + void pushResizeCommand(qreal old_width, qreal new_width, + QPointF old_pos, QPointF new_pos); + QRectF m_alignment_rect; + TextResizeHandles *m_resize_handles = nullptr; Qt::Alignment m_alignment = (Qt::AlignTop | Qt::AlignLeft); }; #endif diff --git a/sources/qetgraphicsitem/independenttextitem.cpp b/sources/qetgraphicsitem/independenttextitem.cpp index 8e5d03ef4..833b8dd48 100644 --- a/sources/qetgraphicsitem/independenttextitem.cpp +++ b/sources/qetgraphicsitem/independenttextitem.cpp @@ -148,6 +148,16 @@ void IndependentTextItem::setTextWidth(qreal width) emit textWidthChanged(width); } +/** + @brief IndependentTextItem::textResizeHandlesWanted + @return true when this text is selected and not being typed in: the + corner handles then change its width. +*/ +bool IndependentTextItem::textResizeHandlesWanted() const +{ + return isSelected() && !isEditing(); +} + void IndependentTextItem::focusOutEvent(QFocusEvent *event) { DiagramTextItem::focusOutEvent(event); diff --git a/sources/qetgraphicsitem/independenttextitem.h b/sources/qetgraphicsitem/independenttextitem.h index 571c9eeea..bc2083e6a 100644 --- a/sources/qetgraphicsitem/independenttextitem.h +++ b/sources/qetgraphicsitem/independenttextitem.h @@ -63,6 +63,7 @@ class IndependentTextItem : public DiagramTextItem protected: void focusOutEvent(QFocusEvent *event) override; + bool textResizeHandlesWanted() const override; bool hasUserTextWidth() const override {return m_text_width > 0;} private: From 3c67147824b800167386bd3ee5168d6f27fee4ff Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Mon, 5 Oct 2026 17:59:28 +0200 Subject: [PATCH 04/26] Resize text fields in the element editor by dragging a corner In the element editor, a dynamic text field that is the only selected item gets the corner handles of TextResizeHandles: dragging one sets the width the field wraps to, keeping the opposite corner in place; double-clicking a handle goes back to the automatic width. One undo step per drag, on the editor's undo stack. ElementScene keeps the handles of the same text when the undo stack changes: pushing the resize itself calls managePrimitivesGroups() again, and deleting the handles there would delete them while they are still emitting. No file format change (text_width on already exists). Co-Authored-By: Claude Opus 5.5 --- sources/editor/elementscene.cpp | 44 +++++++++++++++++++++++++++++++++ sources/editor/elementscene.h | 3 +++ 2 files changed, 47 insertions(+) diff --git a/sources/editor/elementscene.cpp b/sources/editor/elementscene.cpp index a9b8d85d2..b08892cb0 100644 --- a/sources/editor/elementscene.cpp +++ b/sources/editor/elementscene.cpp @@ -23,6 +23,7 @@ #include "../NameList/ui/namelistwidget.h" #include "../QPropertyUndoCommand/qpropertyundocommand.h" #include "../QetGraphicsItemModeler/qetgraphicshandleritem.h" +#include "../QetGraphicsItemModeler/textresizehandles.h" #include "editorcommands.h" #include "elementcontent.h" #include "elementprimitivedecorator.h" @@ -1498,9 +1499,52 @@ void ElementScene::managePrimitivesGroups() m_decorator -> setPos(0, 0); m_decorator -> setItems(selected_items); } + manageTextResizeHandles(selected_items); m_decorator_lock -> unlock(); } +/** + @brief ElementScene::manageTextResizeHandles + Show the corner handles that change the width of a text field when it + is the only selected item, and it is not being typed in. + Called again when the undo stack changes, including from the push of a + resize itself: the handles of the same text are then kept, not deleted + while they are still emitting. + @param selected_items +*/ +void ElementScene::manageTextResizeHandles(const QList &selected_items) +{ + QGraphicsTextItem *text = nullptr; + if (selected_items.size() == 1 && + selected_items.first()->type() == PartDynamicTextField::Type) + { + text = static_cast(selected_items.first()); + } + if (text && (text->textInteractionFlags() & Qt::TextEditable)) + text = nullptr; + + if (m_text_resize_handles && m_text_resize_handles->parentItem() == text) { + m_text_resize_handles->updateHandlesPos(); + return; + } + + if (m_text_resize_handles) { + m_text_resize_handles->hide(); + m_text_resize_handles->deleteLater(); + m_text_resize_handles.clear(); + } + + if (text) + { + m_text_resize_handles = new TextResizeHandles(text); + connect(m_text_resize_handles, &TextResizeHandles::resizeFinished, this, + [this, text](qreal old_width, qreal new_width, QPointF old_pos, QPointF new_pos) + { + m_undo_stack.push(new TextResizeCommand(text, old_width, new_width, old_pos, new_pos)); + }); + } +} + /** @brief ElementScene::stackAction Push the provided \a command on the undo stack. diff --git a/sources/editor/elementscene.h b/sources/editor/elementscene.h index ee275703f..5d3beb427 100644 --- a/sources/editor/elementscene.h +++ b/sources/editor/elementscene.h @@ -33,6 +33,7 @@ class QETElementEditor; class ESEventInterface; class QKeyEvent; class CustomElementGraphicPart; +class TextResizeHandles; /** @brief The ElementScene class This class is the canvas allowing the visual edition of an electrical element. @@ -90,6 +91,7 @@ class ElementScene : public QGraphicsScene m_y_grid; QPointer m_single_selected_item; + QPointer m_text_resize_handles; bool m_hotspot_visible = true; bool m_background_frame_visible = false; @@ -180,6 +182,7 @@ class ElementScene : public QGraphicsScene void slot_editAuthorInformations(); void slot_editProperties(); void managePrimitivesGroups(); + void manageTextResizeHandles(const QList &selected_items); void stackAction(ElementEditionCommand *); signals: From 210a26b29ebe8af93da6c0b3c87cdbfe53c69899 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Tue, 6 Oct 2026 16:26:42 +0200 Subject: [PATCH 05/26] Skip tst_unreadableelement where a file cannot be made unreadable anUnreadableSymbolIsNotACollision() takes every permission away from a symbol file to check that an unreadable symbol is reported as such, not as a collision. QFile::setPermissions() cannot do that on Windows, where Qt only knows the read-only flag, so it returns false and the test failed there. It now skips, as it already does when run as root, which reads the file anyway. Co-Authored-By: Claude Fable 5.1 --- tests/qttest/tst_unreadableelement.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/qttest/tst_unreadableelement.cpp b/tests/qttest/tst_unreadableelement.cpp index 906b8da58..607ac210b 100644 --- a/tests/qttest/tst_unreadableelement.cpp +++ b/tests/qttest/tst_unreadableelement.cpp @@ -89,7 +89,11 @@ private slots: .contains(QStringLiteral("RESULT placed"))); QVERIFY(QFile::exists(embedded)); - QVERIFY(QFile::setPermissions(m_symbol, QFileDevice::Permissions())); + // Qt cannot take read permission away on Windows: it only knows the + // read-only flag there, so this returns false. The check needs a + // file the user cannot read, which this system cannot give it. + if (!QFile::setPermissions(m_symbol, QFileDevice::Permissions())) + QSKIP("this system cannot make the file unreadable (Windows?)"); QFile probe(m_symbol); if (probe.open(QIODevice::ReadOnly)) QSKIP("permissions do not stop this user reading the file (root?)"); From 4e090e6ec2a52720262d6ae4f6e0930e6ac91a19 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Tue, 6 Oct 2026 16:31:21 +0200 Subject: [PATCH 06/26] Write the messages of a headless run to stderr on Windows too The exports, --info, --resave and --run return before QetLogger's handler is installed, so they keep Qt's default handler. On Linux and macOS it writes to stderr. On Windows it writes to the debugger when the program has no console, and a GUI program started by another program never has one: everything a headless run reported -- a wire that could not be reconnected, which way the project database was filled, an element that could not be read -- was lost to the caller, a test or a tool like qet-mcp. Install a handler for these runs that writes to stderr in the form the default handler uses on the other systems (the default message pattern, UTF-8). On macOS the stderr of --info, --resave, --run and --export-svg is byte-identical before and after (memory addresses aside). On Windows, tst_databasefromdocument and tst_terminaluuids, which read these messages, fail before and pass after. Co-Authored-By: Claude Fable 5.1 --- sources/main.cpp | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/sources/main.cpp b/sources/main.cpp index 9058b7cc8..827c38329 100644 --- a/sources/main.cpp +++ b/sources/main.cpp @@ -42,6 +42,13 @@ #include #include +#include +#include +#ifdef Q_OS_WIN +#include +#include +#endif + #ifdef Q_OS_MACOS #include @@ -120,6 +127,41 @@ static QStringList headlessArguments(const QStringList &args) return kept; } +/** + The headless runs below (the exports, --info, --resave and --run) return + before the log-file handler is installed further down, so they keep + Qt's default handler. On Linux and macOS that one writes to stderr. On + Windows it writes to the debugger when the program has no console, and + a GUI program started by another program never has one: what the run + reports -- a wire that could not be reconnected, which way the project + database was filled -- never reached the caller. Write it to stderr + ourselves, in the form the default handler uses on the other systems, + so a caller reads the same lines on every system. +*/ +static void headlessMessageHandler(QtMsgType type, + const QMessageLogContext &context, + const QString &msg) +{ + const QByteArray line = qFormatLogMessage(type, context, msg).toUtf8(); + fprintf(stderr, "%s\n", line.constData()); + fflush(stderr); + if (type == QtFatalMsg) { + abort(); + } +} + +static void installHeadlessMessageHandler() +{ +#ifdef Q_OS_WIN + // stderr is in text mode on Windows and turns "\n" into "\r\n": a + // caller would read lines ending in '\r' where the other systems give + // none, and what the script logs with qet.log() already ends in "\n" + // alone. Binary mode gives one line ending for both. + _setmode(_fileno(stderr), _O_BINARY); +#endif + qInstallMessageHandler(headlessMessageHandler); +} + int main(int argc, char **argv) { // before creating Application: @@ -190,6 +232,7 @@ int main(int argc, char **argv) for (int i = 0; i < argc; ++i) raw_args << QString::fromLocal8Bit(argv[i]); if (CLIExport::isExportRequest(raw_args)) { + installHeadlessMessageHandler(); QApplication export_app(argc, argv); // No crash-recovery backups in one-shot CLI mode: the backup write // runs on a background thread referencing the project and races the @@ -207,6 +250,7 @@ int main(int argc, char **argv) // #162). Same reasoning as the export branch above for running // before SingleApplication and answering message boxes headlessly. if (QetScripting::isRunRequest(raw_args)) { + installHeadlessMessageHandler(); QApplication script_app(argc, argv); QETProject::setBackupEnabled(false); QET::QetMessageBox::setNonInteractive(true); From ad14eb17243a2397c8710b6894aeab53d125e3f3 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Tue, 6 Oct 2026 17:42:19 +0200 Subject: [PATCH 07/26] Delete the text items ElementPictureFactory::build() creates and drops parseText() creates a QGraphicsSimpleTextItem for every of an element (the DXF export reads them later) and appends it to the primitives it is given. build() draws every primitive twice, once for the normal and once for the low-zoom picture, and gave the second pass a fresh `primitives fake_prim` per primitive that went out of scope with its text items still allocated. dropCache() also removed a location's primitives without deleting their text items. LeakSanitizer on the Linux CI sanitizer build (separate PR) stops every --resave or export of a project whose elements have texts: ERROR: LeakSanitizer: detected memory leaks Indirect leak of 37200 byte(s) in 75 object(s) allocated from: #1 QGraphicsSimpleTextItem::QGraphicsSimpleTextItem(QGraphicsItem*) #2 ElementPictureFactory::parseText(...) elementpicturefactory.cpp:658 #4 ElementPictureFactory::build(...) elementpicturefactory.cpp:324 Give the low-zoom pass one set of primitives for the whole element and delete its text items when both pictures are drawn; in dropCache(), take the primitives out of the hash and delete their text items. The items kept in m_primitives_H for the DXF export are unchanged, and the destructor deletes those as before. No file-format change, nothing drawn differently. Tests: the sanitizer job on Linux fails on master with the report above and passes with this change; ctest on macOS 64/64. The leak is invisible to a plain test and to ASan on macOS, which has no LeakSanitizer. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Beat Hangartner --- sources/factory/elementpicturefactory.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/sources/factory/elementpicturefactory.cpp b/sources/factory/elementpicturefactory.cpp index f0ad1e22d..2603d1bf5 100644 --- a/sources/factory/elementpicturefactory.cpp +++ b/sources/factory/elementpicturefactory.cpp @@ -114,7 +114,8 @@ void ElementPictureFactory::dropCache(const ElementsLocation &location) m_pictures_H.remove(uuid); m_low_pictures_H.remove(uuid); m_pixmap_H.remove(uuid); - m_primitives_H.remove(uuid); + //The text items belong to the cache; nothing else holds them. + qDeleteAll(m_primitives_H.take(uuid).m_texts); } /** @@ -249,6 +250,9 @@ bool ElementPictureFactory::build(const ElementsLocation &location, QPainter painter; QPicture pic; primitives primitives_; + //The low-zoom pass fills a second set of primitives that is only + //drawn, never kept: its text items are deleted at the end. + primitives low_primitives; if (picture) { painter.begin(picture); } @@ -320,8 +324,7 @@ bool ElementPictureFactory::build(const ElementsLocation &location, // complex font/text operations. } else { parseElement(qde, painter, primitives_); - primitives fake_prim; - parseElement(qde, low_painter, fake_prim); + parseElement(qde, low_painter, low_primitives); } } } @@ -330,6 +333,7 @@ bool ElementPictureFactory::build(const ElementsLocation &location, //End of the drawing painter.end(); low_painter.end(); + qDeleteAll(low_primitives.m_texts); const auto uuid_ = cacheKey(location); if (!picture) { From bf3c0fc6332ab9d955fb6bc5647a648260d2b616 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 07:05:12 +1300 Subject: [PATCH 08/26] Toolbars settings: only update windows that took them applyToAll() restyled every QMainWindow, including the terminal strip editor and the print window, which never read the settings when they open: they changed on OK and went back to defaults when reopened. applyTo() now marks the window, and applyToAll() only updates marked windows, so the page covers the folio, element and title block editors as described. Co-Authored-By: Claude Opus 5.5 --- sources/toolbarsettings.cpp | 13 ++++++++++--- tests/qttest/tst_toolbarsettings.cpp | 17 +++++++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/sources/toolbarsettings.cpp b/sources/toolbarsettings.cpp index 04d59ee11..bd0379e35 100644 --- a/sources/toolbarsettings.cpp +++ b/sources/toolbarsettings.cpp @@ -26,6 +26,8 @@ namespace { const QString ICON_SIZE = QStringLiteral("toolbars/icon_size"); const QString BUTTON_STYLE = QStringLiteral("toolbars/button_style"); const QString LOCKED = QStringLiteral("toolbars/locked"); + //Set on a window by applyTo() + const char *const APPLIED = "qetToolbarSettings"; } int ToolbarSettings::iconSize() @@ -69,13 +71,15 @@ void ToolbarSettings::save(int icon_size, Qt::ToolButtonStyle style, bool locked @brief ToolbarSettings::applyTo Apply the settings to \a window and its toolbars. The window passes its icon size and button style on to every toolbar that has none of - its own; an invalid size gives the style's default back. + its own; an invalid size gives the style's default back. The window + is marked, so applyToAll() updates it again later. */ void ToolbarSettings::applyTo(QMainWindow *window) { if (!window) { return; } + window->setProperty(APPLIED, true); const int size = iconSize(); window->setIconSize(size > 0 ? QSize(size, size) : QSize()); window->setToolButtonStyle(buttonStyle()); @@ -90,12 +94,15 @@ void ToolbarSettings::applyTo(QMainWindow *window) /** @brief ToolbarSettings::applyToAll - Apply the settings to every open window, after they change. + Apply the settings again to every open window that took them with + applyTo(), after they change. Other windows are left alone: they + would lose the settings the next time they open. */ void ToolbarSettings::applyToAll() { for (QWidget *widget : QApplication::topLevelWidgets()) { - if (auto *window = qobject_cast(widget)) { + auto *window = qobject_cast(widget); + if (window && window->property(APPLIED).toBool()) { applyTo(window); } } diff --git a/tests/qttest/tst_toolbarsettings.cpp b/tests/qttest/tst_toolbarsettings.cpp index 1b77c01a3..184b773d1 100644 --- a/tests/qttest/tst_toolbarsettings.cpp +++ b/tests/qttest/tst_toolbarsettings.cpp @@ -29,8 +29,8 @@ /** ToolbarSettings and its configuration page: defaults change nothing, - saved values reach every open window's toolbars, and a toolbar inside - a panel is left alone. + saved values reach every open window that took them, and a toolbar + inside a panel or a window that never took them is left alone. */ class tst_toolbarsettings : public QObject { @@ -40,6 +40,8 @@ class tst_toolbarsettings : public QObject QToolBar *m_top = nullptr; QToolBar *m_left = nullptr; QToolBar *m_in_dock = nullptr; + QMainWindow *m_other = nullptr; + QToolBar *m_other_bar = nullptr; QSize m_default_size; private slots: @@ -60,11 +62,18 @@ private slots: dock->setWidget(m_in_dock); m_window->addDockWidget(Qt::LeftDockWidgetArea, dock); m_window->show(); + + //A window that never takes the settings, like the print window + m_other = new QMainWindow(); + m_other_bar = new QToolBar(QStringLiteral("other"), m_other); + m_other->addToolBar(Qt::TopToolBarArea, m_other_bar); + m_other->show(); } void cleanupTestCase() { delete m_window; + delete m_other; } void defaultsChangeNothing() @@ -102,6 +111,10 @@ private slots: QVERIFY(!m_left->isMovable()); //A toolbar inside a panel is not one of the window's toolbars QVERIFY(m_in_dock->isMovable()); + //Nor is a window that did not take the settings when it opened + QCOMPARE(m_other_bar->iconSize(), m_default_size); + QCOMPARE(m_other_bar->toolButtonStyle(), Qt::ToolButtonIconOnly); + QVERIFY(m_other_bar->isMovable()); //A page opened now shows what was saved ToolbarsConfigPage page; From 286770836985115d4a4ab6141b0df69096d9c55b Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 07:43:45 +1300 Subject: [PATCH 09/26] Fix #1336: fold Edit menu families into submenus The Edit menu had grown to 42 entries and no longer fitted on a laptop screen; Qt wrapped it into a second column. Select, Group, Rows and columns and Depth now sit one level down, as Add and Align already did. Search for a command, Shortcut bar and Repeat move to the end, beside Search/Replace. No action, label or shortcut changes: Ctrl+A was checked to still select everything from inside the new Select submenu. Lignes et colonnes was already built as a submenu for the folio's context menu; the Edit menu now uses the same one instead of a flat copy. Co-Authored-By: Claude Opus 5.5 --- sources/qetdiagrameditor.cpp | 38 ++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index bf6b01396..65e46b11e 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -1428,12 +1428,9 @@ void QETDiagramEditor::setUpMenu() menu_edition -> addAction(m_paste_origin); menu_edition -> addAction(m_duplicate); menu_edition -> addAction(m_configure_duplicate); + menu_edition -> addSeparator(); menu_edition -> addAction(m_insert_last_element); menu_edition -> addAction(m_show_element_picker); - menu_edition -> addAction(m_show_shortcut_bar); - menu_edition -> addAction(m_repeat_last_command); - menu_edition -> addAction(m_command_search); - menu_edition -> addSeparator(); //The same actions the "Ajouter" toolbar holds. They were toolbar-only, //which left them unreachable for anyone working without a mouse: a //toolbar button has no key, so text fields, images and every drawing @@ -1443,9 +1440,24 @@ void QETDiagramEditor::setUpMenu() m_add_item_menu -> setIcon(QET::Icons::Add); m_add_item_menu -> addActions(m_add_item_actions_group.actions()); menu_edition -> addSeparator(); - menu_edition -> addActions(m_select_actions_group.actions()); + //The menu had grown to over forty entries and no longer fitted on a + //laptop screen (issue #1336). Whole families now sit one level down, + //as Ajouter and Aligner already did; every action and its shortcut + //is unchanged. + QMenu *select_menu = menu_edition -> addMenu(QET::Icons::EditSelectAll, tr("Sélection")); + select_menu -> addActions(m_select_actions_group.actions()); menu_edition -> addSeparator(); - menu_edition -> addActions(m_selection_actions_group.actions()); + menu_edition -> addAction(m_delete_selection); + menu_edition -> addAction(m_rotate_selection); + menu_edition -> addAction(m_rotate_group_selection); + menu_edition -> addAction(m_rotate_texts); + menu_edition -> addAction(m_find_element); + menu_edition -> addAction(m_edit_selection); + QMenu *group_menu = menu_edition -> addMenu(QET::Icons::textGroup, tr("Grouper")); + group_menu -> addAction(m_group_selection); + group_menu -> addAction(m_ungroup_selection); + group_menu -> addSeparator(); + group_menu -> addAction(m_group_selected_texts); m_align_menu = menu_edition -> addMenu(tr("Aligner")); //Snap to grid, then the horizontal three, then the vertical three, //in the order setUpActions() adds them @@ -1458,18 +1470,20 @@ void QETDiagramEditor::setUpMenu() menu_edition -> addAction(m_conductor_reset); menu_edition -> addSeparator(); menu_edition -> addAction(m_edit_diagram_properties); - menu_edition -> addActions(m_row_column_actions_group.actions()); - //Not added to a menu here: it exists so the folio's context menu can - //hold the row and column actions one level down (see + //Shared with the folio's context menu (see //DiagramView::contextMenuActions()). - m_row_column_menu = new QMenu(tr("Lignes et colonnes"), this); + m_row_column_menu = menu_edition -> addMenu(tr("Lignes et colonnes")); m_row_column_menu -> setIcon(QET::Icons::EditTableInsertColumnRight); m_row_column_menu -> addActions(m_row_column_actions_group.actions()); - menu_edition -> addSeparator(); - menu_edition -> addActions(m_depth_action_group->actions()); + QMenu *depth_menu = menu_edition -> addMenu(QET::Icons::BringForward, tr("Profondeur")); + depth_menu -> addActions(m_depth_action_group->actions()); menu_edition -> addSeparator(); menu_edition -> addAction(m_find); menu_edition -> addAction(m_jump_to_element); + menu_edition -> addAction(m_command_search); + menu_edition -> addSeparator(); + menu_edition -> addAction(m_show_shortcut_bar); + menu_edition -> addAction(m_repeat_last_command); // menu Projet menu_project -> addAction(m_project_edit_properties); From 0421ea15862035403fbd634d6129266c2654374c Mon Sep 17 00:00:00 2001 From: Sasch_be <138025546+saschbe@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:46:25 +0200 Subject: [PATCH 10/26] fix: name the sheet background color submenu --- sources/ui/diagrambgcolorbutton.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sources/ui/diagrambgcolorbutton.cpp b/sources/ui/diagrambgcolorbutton.cpp index dce538faf..e16e81321 100644 --- a/sources/ui/diagrambgcolorbutton.cpp +++ b/sources/ui/diagrambgcolorbutton.cpp @@ -85,7 +85,7 @@ DiagramBgColorToolButton::DiagramBgColorToolButton(QETDiagramEditor *editor, QWi m_recent.removeLast(); } - setMenu(new QMenu(this)); + setMenu(new QMenu(tr("Couleur de fond du folio"), this)); rebuildMenu(); setSwatch(m_current); } From 8ff5fbac0ca9777b4a5db1dfb6a3dde707605e8c Mon Sep 17 00:00:00 2001 From: saschbe Date: Tue, 6 Oct 2026 21:09:41 +0200 Subject: [PATCH 11/26] Preserve imported image colours by default in dark mode --- sources/darkimagerendering.h | 42 ++++++ sources/palettegraphicsview.cpp | 53 ++++++- sources/palettegraphicsview.h | 4 + sources/qetgraphicsitem/diagramimageitem.cpp | 13 +- sources/qetgraphicsitem/diagramimageitem.h | 5 + sources/qetpalette.cpp | 13 +- sources/qetpalette.h | 3 + sources/ui/imagepropertieswidget.cpp | 53 ++++++- sources/ui/imagepropertieswidget.h | 5 + sources/ui/imagepropertieswidget.ui | 12 +- tests/qttest/CMakeLists.txt | 16 +++ tests/qttest/image_dark_integration_probe.cpp | 124 +++++++++++++++++ tests/qttest/run_image_dark_probe.py | 96 +++++++++++++ tests/qttest/tst_darkimagelayers.cpp | 129 ++++++++++++++++++ 14 files changed, 553 insertions(+), 15 deletions(-) create mode 100644 sources/darkimagerendering.h create mode 100644 tests/qttest/image_dark_integration_probe.cpp create mode 100644 tests/qttest/run_image_dark_probe.py create mode 100644 tests/qttest/tst_darkimagelayers.cpp diff --git a/sources/darkimagerendering.h b/sources/darkimagerendering.h new file mode 100644 index 000000000..ad7c25206 --- /dev/null +++ b/sources/darkimagerendering.h @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#ifndef DARK_IMAGE_RENDERING_H +#define DARK_IMAGE_RENDERING_H + +#include +#include +#include + +namespace DarkImageRendering { +// View-local raster composition boundary. Qt still paints the scene in +// its native order, with its own transforms, clipping and opacity. +// A different painter (printing, export, another view or an item cache) +// cannot consume this display-only context. +struct Context { + QPainter *painter; + bool invertScene; + std::function flush; +}; +inline thread_local Context *current = nullptr; +class Scope { + Context *previous; +public: + explicit Scope(Context *context) : previous(current) { current = context; } + ~Scope() { current = previous; } + Scope(const Scope &) = delete; + Scope &operator=(const Scope &) = delete; +}; +inline void paintPixmap(QPainter *painter, const QPixmap &pixmap, bool adapt) +{ + Context *context = current; + const bool boundary = context && context->painter == painter && adapt != context->invertScene; + if (boundary) context->flush(context->invertScene, QRect()); + painter->drawPixmap(pixmap.rect(), pixmap); + if (boundary) { + // Only the raster was drawn since the preceding flush. Avoid a + // whole-viewport copy for a small logo; include filtering/AA edges. + const QRect area = painter->worldTransform().mapRect(QRectF(pixmap.rect())).toAlignedRect().adjusted(-2,-2,2,2); + context->flush(adapt, area); + } +} +} +#endif diff --git a/sources/palettegraphicsview.cpp b/sources/palettegraphicsview.cpp index ab9b322db..77c7ddeda 100644 --- a/sources/palettegraphicsview.cpp +++ b/sources/palettegraphicsview.cpp @@ -18,6 +18,7 @@ #include "palettegraphicsview.h" #include "qetpalette.h" +#include "darkimagerendering.h" #include #include @@ -99,12 +100,13 @@ bool PaletteGraphicsView::eventFilter(QObject *watched, QEvent *event) */ void PaletteGraphicsView::paintEvent(QPaintEvent *event) { - if (invertsLightness() && !customBackgroundColor()) + if (invertsLightness()) { paintInverted(event); return; } m_buffer = QImage(); + m_composite = QImage(); QGraphicsView::paintEvent(event); } @@ -130,7 +132,7 @@ void PaletteGraphicsView::paintInverted(QPaintEvent *event) const QSize size(qCeil(viewport()->width() * ratio), qCeil(viewport()->height() * ratio)); if (m_buffer.size() != size || m_buffer.devicePixelRatio() != ratio) { - m_buffer = QImage(size, QImage::Format_RGB32); + m_buffer = QImage(size, QImage::Format_ARGB32_Premultiplied); m_buffer.setDevicePixelRatio(ratio); } @@ -141,9 +143,13 @@ void PaletteGraphicsView::paintInverted(QPaintEvent *event) m_buffer_painter.setClipRect(exposed); m_buffer_painter.setRenderHints(renderHints()); m_buffer_painter.setWorldTransform(viewportTransform()); + m_exposed = exposed; + m_layered = false; + DarkImageRendering::Context context{&m_buffer_painter, !customBackgroundColor(), [this](bool invert, const QRect &bounds) { flushLayer(invert, bounds); }}; + DarkImageRendering::Scope rasterScope(&context); m_inverting = true; - paintingInverted(true); + paintingInverted(!customBackgroundColor()); const OptimizationFlags flags = optimizationFlags(); setOptimizationFlag(QGraphicsView::IndirectPainting, true); QGraphicsView::paintEvent(event); @@ -151,10 +157,44 @@ void PaletteGraphicsView::paintInverted(QPaintEvent *event) paintingInverted(false); m_inverting = false; + if (m_layered) flushLayer(!customBackgroundColor()); m_buffer_painter.end(); blitInverted(exposed); } +void PaletteGraphicsView::flushLayer(bool invert, const QRect &bounds) +{ + const QRect area = bounds.isNull() ? m_exposed : bounds.intersected(m_exposed); + if (area.isEmpty()) return; + const qreal ratio = m_buffer.devicePixelRatio(); + const QRect pixels = QRectF(area.topLeft() * ratio, area.size() * ratio).toAlignedRect(); + QImage layer = m_buffer.copy(pixels); + layer.setDevicePixelRatio(ratio); + if (invert) { + const QPalette palette = QApplication::palette(); + QET::Palette::invertLightnessLayer(layer, palette.color(QPalette::Base), palette.color(QPalette::Text)); + } + if (!m_layered) { + if (m_composite.size() != m_buffer.size() || m_composite.devicePixelRatio() != ratio) { + m_composite = QImage(m_buffer.size(), QImage::Format_ARGB32_Premultiplied); + m_composite.setDevicePixelRatio(ratio); + } + m_composite.fill(Qt::transparent); + m_layered = true; + } + QPainter composite(&m_composite); + composite.drawImage(QPointF(pixels.topLeft()) / ratio, layer); + // Keep Qt's active painter and its saved state stack intact. Replacing + // or restarting it here would lose ancestor clipping and opacity. + m_buffer_painter.save(); + m_buffer_painter.resetTransform(); + m_buffer_painter.setClipping(false); + m_buffer_painter.setOpacity(1.0); + m_buffer_painter.setCompositionMode(QPainter::CompositionMode_Source); + m_buffer_painter.fillRect(QRectF(QPointF(pixels.topLeft()) / ratio, QSizeF(pixels.size()) / ratio), Qt::transparent); + m_buffer_painter.restore(); +} + /** @brief PaletteGraphicsView::drawBackground Into the off-screen image while painting inverted, else as @@ -199,15 +239,16 @@ void PaletteGraphicsView::drawForeground(QPainter *painter, const QRectF &rect) void PaletteGraphicsView::blitInverted(const QRect &area) { const qreal ratio = m_buffer.devicePixelRatio(); - QImage part = m_buffer.copy(QRectF(area.topLeft() * ratio, area.size() * ratio).toAlignedRect()); + const QRect pixels = QRectF(area.topLeft() * ratio, area.size() * ratio).toAlignedRect(); + QImage part = (m_layered ? m_composite : m_buffer).copy(pixels); part.setDevicePixelRatio(ratio); // The application palette, for the reason given in invertsLightness(). const QPalette application_palette = QApplication::palette(); - QET::Palette::invertLightness(part, application_palette.color(QPalette::Base), + if (!m_layered && !customBackgroundColor()) QET::Palette::invertLightness(part, application_palette.color(QPalette::Base), application_palette.color(QPalette::Text)); QPainter painter(viewport()); - painter.drawImage(area.topLeft(), part); + painter.drawImage(QPointF(pixels.topLeft()) / ratio, part); drawRubberBand(painter); } diff --git a/sources/palettegraphicsview.h b/sources/palettegraphicsview.h index 96d2ace36..36818e51b 100644 --- a/sources/palettegraphicsview.h +++ b/sources/palettegraphicsview.h @@ -90,11 +90,15 @@ class PaletteGraphicsView : public QGraphicsView void paintInverted(QPaintEvent *event); void blitInverted(const QRect &area); void drawRubberBand(QPainter &painter); + void flushLayer(bool invert, const QRect &bounds = QRect()); /// The off-screen image the hooks paint into while m_inverting: /// the viewport's size, in its coordinates. Kept between paints, /// dropped when the view paints on a light palette again. QImage m_buffer; + QImage m_composite; + QRect m_exposed; + bool m_layered = false; QPainter m_buffer_painter; /// True while paintEvent() paints for an inverted display. bool m_inverting = false; diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp index 00f8b030b..c8ef93ce7 100644 --- a/sources/qetgraphicsitem/diagramimageitem.cpp +++ b/sources/qetgraphicsitem/diagramimageitem.cpp @@ -16,6 +16,7 @@ along with QElectroTech. If not, see . */ #include "diagramimageitem.h" +#include "../darkimagerendering.h" #include "../shownkinds.h" #include "../PropertiesEditor/propertieseditordialog.h" @@ -97,7 +98,7 @@ DiagramImageItem::~DiagramImageItem() @param widget the QWidget where we draw the pixmap */ void DiagramImageItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { - painter -> drawPixmap(pixmap_.rect(),pixmap_); + DarkImageRendering::paintPixmap(painter, pixmap_, m_adapt_to_dark_theme); Q_UNUSED(option); Q_UNUSED(widget); @@ -155,6 +156,14 @@ void DiagramImageItem::setLabel(const QString &label) emit labelChanged(); } +void DiagramImageItem::setAdaptToDarkTheme(bool adapt) +{ + if (m_adapt_to_dark_theme == adapt) return; + m_adapt_to_dark_theme = adapt; + update(); + emit adaptToDarkThemeChanged(); +} + /** @brief DiagramImageItem::editProperty Open the appropriate dialog to edit this image @@ -1479,6 +1488,7 @@ bool DiagramImageItem::fromXml(const QDomElement &e) if (e.tagName() != "image") { return (false); } + setAdaptToDarkTheme(e.attribute("adapt_to_dark_theme", "false") == "true"); const QUuid uuid(e.attribute(QStringLiteral("uuid"))); if (!uuid.isNull() && uuid != m_uuid) setUuid(uuid); @@ -1623,6 +1633,7 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const { QDomElement result = document.createElement("image"); result.setAttribute("uuid", m_uuid.toString()); + if (m_adapt_to_dark_theme) result.setAttribute("adapt_to_dark_theme", "true"); //write some attribute result.setAttribute("x", QString::number(pos().x())); result.setAttribute("y", QString::number(pos().y())); diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h index b3263f9b3..1298563a4 100644 --- a/sources/qetgraphicsitem/diagramimageitem.h +++ b/sources/qetgraphicsitem/diagramimageitem.h @@ -53,6 +53,7 @@ class DiagramImageItem : public QetGraphicsItem { // 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) + Q_PROPERTY(bool adaptToDarkTheme READ adaptToDarkTheme WRITE setAdaptToDarkTheme NOTIFY adaptToDarkThemeChanged) // 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 @@ -149,12 +150,15 @@ class DiagramImageItem : public QetGraphicsItem { void setPivotRaw(const QPointF &pivot); QString label() const { return m_label; } void setLabel(const QString &label); + bool adaptToDarkTheme() const { return m_adapt_to_dark_theme; } + void setAdaptToDarkTheme(bool adapt); signals: void pixmapChanged(); void transformChanged(); void uuidChanged(); void labelChanged(); + void adaptToDarkThemeChanged(); protected: void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override; @@ -258,6 +262,7 @@ class DiagramImageItem : public QetGraphicsItem { // Empty by default, and then neither saved nor painted, so a picture // without one costs exactly what it did before. QString m_label; + bool m_adapt_to_dark_theme = false; QFont m_label_font; QSizeF m_label_size; // in scene units, measured once in setLabel() QPointF m_label_scale{1.0, 1.0}; // scale the label rect was last computed for -- see updateLabelScale() diff --git a/sources/qetpalette.cpp b/sources/qetpalette.cpp index e8910a297..c8e661533 100644 --- a/sources/qetpalette.cpp +++ b/sources/qetpalette.cpp @@ -83,8 +83,14 @@ bool QET::Palette::isDark(const QPalette &palette) void QET::Palette::invertLightness(QImage &image, const QColor &sheet, const QColor &ink) { - if (image.format() != QImage::Format_RGB32) - image.convertTo(QImage::Format_RGB32); + if (image.format() != QImage::Format_RGB32) image.convertTo(QImage::Format_RGB32); + invertLightnessLayer(image, sheet, ink); +} + +void QET::Palette::invertLightnessLayer(QImage &image, const QColor &sheet, const QColor &ink) +{ + if (image.format() != QImage::Format_RGB32 && image.format() != QImage::Format_ARGB32) + image.convertTo(QImage::Format_ARGB32); // One table per channel maps the inverted value (0 = was white, // 255 = was black) onto the sheet..ink span. @@ -106,6 +112,7 @@ void QET::Palette::invertLightness(QImage &image, const QColor &sheet, quint32 *line = reinterpret_cast(image.scanLine(y)); for (int x = 0, width = image.width(); x < width; ++x) { const quint32 pixel = line[x]; + if ((pixel & 0xff000000u) == 0) continue; const int red = (pixel >> 16) & 0xff; const int green = (pixel >> 8) & 0xff; const int blue = pixel & 0xff; @@ -114,7 +121,7 @@ void QET::Palette::invertLightness(QImage &image, const QColor &sheet, if (blue > highest) highest = blue; if (blue < lowest) lowest = blue; const int offset = 255 - highest - lowest; - line[x] = 0xff000000u + line[x] = (pixel & 0xff000000u) | (quint32(red_of[red + offset]) << 16) | (quint32(green_of[green + offset]) << 8) | quint32(blue_of[blue + offset]); diff --git a/sources/qetpalette.h b/sources/qetpalette.h index e1a5cabcd..a60190c0b 100644 --- a/sources/qetpalette.h +++ b/sources/qetpalette.h @@ -66,6 +66,9 @@ namespace QET { */ void invertLightness(QImage &image, const QColor &sheet = Qt::black, const QColor &ink = Qt::white); + /// The same colour mapping for a transparent composition layer. + /// Retains alpha; premultiplied input is converted to straight ARGB32. + void invertLightnessLayer(QImage &image, const QColor &sheet, const QColor &ink); /** The color of the grid dots on a sheet of color \a sheet: black, diff --git a/sources/ui/imagepropertieswidget.cpp b/sources/ui/imagepropertieswidget.cpp index 3b0931437..3ee2df604 100644 --- a/sources/ui/imagepropertieswidget.cpp +++ b/sources/ui/imagepropertieswidget.cpp @@ -22,6 +22,9 @@ #include "../qetgraphicsitem/diagramimageitem.h" #include "../qeticons.h" #include "../ui_imagepropertieswidget.h" +#include +#include +#include /** @brief ImagePropertiesWidget::ImagePropertiesWidget @@ -53,6 +56,19 @@ ImagePropertiesWidget::~ImagePropertiesWidget() delete ui; } +bool ImagePropertiesWidget::event(QEvent *event) +{ + // The generic properties dialog resets on its Cancel button, but its + // title-bar close and Escape reach reject() directly. Roll back the + // same preview in all rejection paths, without changing other editors. + if (event->type() == QEvent::ParentChange) { + if (auto *dialog = qobject_cast(parentWidget())) { + connect(dialog, &QDialog::rejected, this, &ImagePropertiesWidget::reset, Qt::UniqueConnection); + } + } + return PropertiesEditorWidget::event(event); +} + /** @brief ImagePropertiesWidget::setImageItem Set the image to edit properties @@ -67,12 +83,15 @@ void ImagePropertiesWidget::setImageItem(DiagramImageItem *image) { disconnect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); disconnect(m_image, &DiagramImageItem::labelChanged, this, &ImagePropertiesWidget::updateUi); + disconnect(m_image, &DiagramImageItem::adaptToDarkThemeChanged, this, &ImagePropertiesWidget::updateUi); } m_image = image; connect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); connect(m_image, &DiagramImageItem::labelChanged, this, &ImagePropertiesWidget::updateUi); + connect(m_image, &DiagramImageItem::adaptToDarkThemeChanged, this, &ImagePropertiesWidget::updateUi); m_movable = image->isMovable(); + m_adapt_to_dark_theme = image->adaptToDarkTheme(); m_scaleX = m_image->scaleFactorX(); m_scaleY = m_image->scaleFactorY(); m_rotation = m_image->rotationAngle(); @@ -107,6 +126,7 @@ void ImagePropertiesWidget::apply() m_skewX = m_image->skewX(); m_skewY = m_image->skewY(); m_label = m_image->label(); + m_adapt_to_dark_theme = m_image->adaptToDarkTheme(); } /** @@ -124,6 +144,9 @@ void ImagePropertiesWidget::reset() m_image->setSkewY(m_skewY); m_image->setLabel(m_label); m_image->setMovable(m_movable); + m_preview_dark_theme = true; + m_image->setAdaptToDarkTheme(m_adapt_to_dark_theme); + m_preview_dark_theme = false; updateUi(); } @@ -146,6 +169,7 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit) connect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); connect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); connect (ui->m_label_le, &QLineEdit::editingFinished, this, &ImagePropertiesWidget::apply); + connect (ui->m_adapt_to_dark_theme_cb, &QCheckBox::clicked, this, &ImagePropertiesWidget::apply); } else { @@ -155,6 +179,7 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit) disconnect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); disconnect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); disconnect (ui->m_label_le, &QLineEdit::editingFinished, this, &ImagePropertiesWidget::apply); + disconnect (ui->m_adapt_to_dark_theme_cb, &QCheckBox::clicked, this, &ImagePropertiesWidget::apply); } return true; @@ -179,7 +204,7 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const const qreal newSkewX = ui->m_skew_x_sb->value(); const qreal newSkewY = ui->m_skew_y_sb->value(); - QPropertyUndoCommand *undo = nullptr; + QUndoCommand *undo = nullptr; auto chain = [&](const char *property, qreal oldValue, qreal newValue, const QString &text) { if (qFuzzyCompare(oldValue, newValue)) @@ -188,9 +213,10 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const new QPropertyUndoCommand(m_image, property, oldValue, newValue, undo); else { - undo = new QPropertyUndoCommand(m_image, property, oldValue, newValue); - undo->enableAnimation(); - undo->setText(text); + auto *change = new QPropertyUndoCommand(m_image, property, oldValue, newValue); + change->enableAnimation(); + change->setText(text); + undo = change; } }; @@ -213,6 +239,14 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const } } + const bool adapt = ui->m_adapt_to_dark_theme_cb->isChecked(); + if (adapt != m_adapt_to_dark_theme) { + if (undo) new QPropertyUndoCommand(m_image, "adaptToDarkTheme", m_adapt_to_dark_theme, adapt, undo); + else { + undo = new QUndoCommand(tr("Modifier l'adaptation d'une image au thème sombre")); + new QPropertyUndoCommand(m_image, "adaptToDarkTheme", m_adapt_to_dark_theme, adapt, undo); + } + } return undo; } @@ -233,6 +267,9 @@ void ImagePropertiesWidget::updateUi() // signal from a resize handle used directly on the canvas while // this dialog is open). if (!m_image || m_updating_ratio) return; + if (m_live_edit && !m_preview_dark_theme) m_adapt_to_dark_theme = m_image->adaptToDarkTheme(); + const QSignalBlocker darkThemeBlocker(ui->m_adapt_to_dark_theme_cb); + ui->m_adapt_to_dark_theme_cb->setChecked(m_image->adaptToDarkTheme()); m_updating_ratio = true; ui->m_width_sb->setValue(m_image->scaleFactorX() * 100.0); @@ -395,3 +432,11 @@ void ImagePropertiesWidget::on_m_lock_pos_cb_clicked() { m_image->setMovable(!ui->m_lock_pos_cb->isChecked()); } + +void ImagePropertiesWidget::on_m_adapt_to_dark_theme_cb_toggled(bool checked) +{ + if (!m_image) return; + m_preview_dark_theme = true; + m_image->setAdaptToDarkTheme(checked); + m_preview_dark_theme = false; +} diff --git a/sources/ui/imagepropertieswidget.h b/sources/ui/imagepropertieswidget.h index c692d7892..57ddf1108 100644 --- a/sources/ui/imagepropertieswidget.h +++ b/sources/ui/imagepropertieswidget.h @@ -43,6 +43,8 @@ class ImagePropertiesWidget : public PropertiesEditorWidget void reset() override; bool setLiveEdit(bool live_edit) override; QUndoCommand* associatedUndo() const override; + protected: + bool event(QEvent *event) override; private: void updateUi() override; @@ -56,11 +58,14 @@ class ImagePropertiesWidget : public PropertiesEditorWidget void on_m_lock_ratio_tb_toggled(bool checked); void on_m_restore_ratio_pb_clicked(); void on_m_lock_pos_cb_clicked(); + void on_m_adapt_to_dark_theme_cb_toggled(bool checked); private: Ui::ImagePropertiesWidget *ui; DiagramImageItem *m_image; bool m_movable; + bool m_adapt_to_dark_theme = false; + bool m_preview_dark_theme = false; // All tracked independently -- the image may already be // non-uniformly scaled, rotated, and/or skewed via the resize/ // rotate/skew handles before this dialog is even opened, and diff --git a/sources/ui/imagepropertieswidget.ui b/sources/ui/imagepropertieswidget.ui index 4c9954cd6..4d00b6f29 100644 --- a/sources/ui/imagepropertieswidget.ui +++ b/sources/ui/imagepropertieswidget.ui @@ -173,7 +173,17 @@ - + + + + Adapter l’image au thème sombre + + + Adapte les couleurs à l’affichage sombre uniquement. L’image originale, les exports et les impressions restent inchangés. + + + + Qt::Vertical diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index b03046a5a..7fd6172df 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -66,6 +66,22 @@ include(../../cmake/fetch_pugixml.cmake) enable_testing() +add_executable(tst_darkimagelayers tst_darkimagelayers.cpp + ${QET_DIR}/sources/palettegraphicsview.cpp ${QET_DIR}/sources/palettegraphicsview.h + ${QET_DIR}/sources/qetpalette.cpp) +add_test(NAME tst_darkimagelayers COMMAND tst_darkimagelayers) +target_include_directories(tst_darkimagelayers PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_darkimagelayers PRIVATE Qt::Test Qt::Widgets) +find_package(Python3 QUIET COMPONENTS Interpreter) +if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja") + add_test(NAME tst_darkimageintegration + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_image_dark_probe.py + --build ${CMAKE_BINARY_DIR} --binary $ + --source ${CMAKE_CURRENT_SOURCE_DIR}/image_dark_integration_probe.cpp + --fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet) + set_tests_properties(tst_darkimageintegration PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE) +endif() + add_executable(${PROJECT_NAME} tst_mytest.cpp) add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) diff --git a/tests/qttest/image_dark_integration_probe.cpp b/tests/qttest/image_dark_integration_probe.cpp new file mode 100644 index 000000000..23d86f61c --- /dev/null +++ b/tests/qttest/image_dark_integration_probe.cpp @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Linked by run_image_dark_probe.py against the application's own objects. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../../sources/diagram.h" +#include "../../sources/qetproject.h" +#include "../../sources/qetresult.h" +#include "../../sources/qetmessagebox.h" +#include "../../sources/qetpalette.h" +#include "../../sources/palettegraphicsview.h" +#include "../../sources/qetgraphicsitem/diagramimageitem.h" +#include "../../sources/ui/imagepropertieswidget.h" +#include "../../sources/PropertiesEditor/propertieseditordialog.h" + +static void check(bool value, const char *message) { if (!value) throw std::runtime_error(message); } +static QByteArray png(const QPixmap &pixmap) { + QByteArray bytes; QBuffer buffer(&bytes); buffer.open(QIODevice::WriteOnly); pixmap.save(&buffer,"PNG"); return bytes; +} +static QByteArray read(const QString &path) { QFile file(path); check(file.open(QIODevice::ReadOnly),"read export"); return file.readAll(); } +static DiagramImageItem *findImage(Diagram *diagram) { + for (auto *item : diagram->items()) if (auto *image = dynamic_cast(item)) return image; + return nullptr; +} +int main(int argc, char **argv) +{ + QApplication app(argc,argv); + std::freopen(argv[2],"w",stdout); std::freopen(argv[2],"a",stderr); + QTemporaryDir settings; + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat,QSettings::UserScope,settings.path()); + QCoreApplication::setOrganizationName("QETImageDarkRegression"); + QETProject::setBackupEnabled(false); QET::QetMessageBox::setNonInteractive(true); + QFontDatabase::addApplicationFont(":/fonts/LiberationSans-Regular.ttf"); + try { + const QString output=QString::fromLocal8Bit(argv[3]); + QETProject project(QString::fromLocal8Bit(argv[1])); check(project.state()==QETProject::Ok,"load fixture"); + auto *diagram=project.diagrams().first(); + QImage original(40,30,QImage::Format_ARGB32); original.fill(QColor(197,81,33)); + for(int y=0;y<30;++y) for(int x=10;x<20;++x) original.setPixelColor(x,y,QColor(197,81,33,128)); + for(int y=10;y<16;++y) for(int x=24;x<30;++x) original.setPixelColor(x,y,Qt::transparent); + QPixmap source=QPixmap::fromImage(original); + auto *image=new DiagramImageItem(source); diagram->addItem(image); + image->setPos(55,55); image->setLabel("Image test"); image->setRotationAngle(17); image->setScaleFactorX(1.3); image->setSkewX(8); + check(!image->adaptToDarkTheme(),"new image defaults to original colours"); + QDomDocument xml; auto legacy=image->toXml(xml); + check(!legacy.hasAttribute("adapt_to_dark_theme"),"default does not add an attribute to old files"); + const QRect crop(4,3,24,20); + legacy.firstChild().setNodeValue(QString::fromLatin1(png(source.copy(crop)).toBase64())); + auto base=xml.createElement("image_base"); base.appendChild(xml.createTextNode(QString::fromLatin1(png(source).toBase64()))); legacy.appendChild(base); + auto cropElement=xml.createElement("crop"); cropElement.setAttribute("x",4); cropElement.setAttribute("y",3); cropElement.setAttribute("w",24); cropElement.setAttribute("h",20); legacy.appendChild(cropElement); + image->setAdaptToDarkTheme(true); check(image->fromXml(legacy),"load cropped legacy image"); + check(!image->adaptToDarkTheme(),"missing attribute resets an existing image to original colours"); + check(image->pixmap().size()==crop.size(),"crop survives"); + const QByteArray pristine=png(image->pixmap()); const auto transform=image->transform(); const auto bounds=image->boundingRect(); + ImagePropertiesWidget widget(image); auto *box=widget.findChild("m_adapt_to_dark_theme_cb"); + check(box && !box->isChecked(),"unchecked properties default"); + check(box->text()==QString::fromUtf8("Adapter l’image au thème sombre"),"checkbox wording"); + check(box->toolTip()==QString::fromUtf8("Adapte les couleurs à l’affichage sombre uniquement. L’image originale, les exports et les impressions restent inchangés."),"tooltip wording"); + box->setChecked(true); check(image->adaptToDarkTheme(),"preview"); widget.reset(); check(!image->adaptToDarkTheme(),"reset cancels preview"); + diagram->undoStack().clear(); box->setChecked(true); widget.apply(); check(image->adaptToDarkTheme(),"apply"); + diagram->undoStack().undo(); check(!image->adaptToDarkTheme()&&!box->isChecked(),"undo updates checkbox"); + diagram->undoStack().redo(); check(image->adaptToDarkTheme()&&box->isChecked(),"redo updates checkbox"); + widget.setLiveEdit(true); box->click(); check(!image->adaptToDarkTheme(),"live edit"); + check(diagram->undoStack().count()==2,"deliberate toggles do not merge"); + diagram->undoStack().undo(); check(image->adaptToDarkTheme(),"live undo"); + diagram->undoStack().redo(); check(!image->adaptToDarkTheme(),"live redo"); + box->click(); check(image->adaptToDarkTheme()&&diagram->undoStack().count()==3,"edit after undo has current baseline"); + for (auto role : {QDialogButtonBox::Cancel,QDialogButtonBox::Reset,QDialogButtonBox::Apply}) { + image->setAdaptToDarkTheme(false); + auto *editor=new ImagePropertiesWidget(image); PropertiesEditorDialog dialog(editor); + editor->findChild("m_adapt_to_dark_theme_cb")->setChecked(true); + dialog.findChild()->button(role)->click(); + check(image->adaptToDarkTheme()==(role==QDialogButtonBox::Apply),"actual dialog button semantics"); + } + check(png(image->pixmap())==pristine && image->transform()==transform && image->boundingRect()==bounds && image->label()=="Image test","choice cannot modify pixels, transform, crop or caption"); + image->setAdaptToDarkTheme(false); + { + auto *editor=new ImagePropertiesWidget(image); PropertiesEditorDialog dialog(editor); + editor->findChild("m_adapt_to_dark_theme_cb")->setChecked(true); + dialog.reject(); check(!image->adaptToDarkTheme(),"Escape/title-bar rejection cancels preview"); + } + PaletteGraphicsView view(diagram); view.setSceneRect(0,0,180,180); view.setFrameShape(QFrame::NoFrame); + view.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); view.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + view.setAlignment(Qt::AlignLeft|Qt::AlignTop); view.resize(180,180); view.setAttribute(Qt::WA_DontShowOnScreen); view.show(); + for (bool dark : {false,true}) for(bool adapt : {false,true}) { + const auto palette=dark?QET::Palette::fusionDark():QET::Palette::fusionLight(); QApplication::setPalette(palette); + image->setAdaptToDarkTheme(adapt); QApplication::processEvents(); const auto shown=view.viewport()->grab().toImage(); + shown.save(output+QString("/screen-%1-%2.png").arg(dark).arg(adapt)); + const auto point=view.mapFromScene(image->mapToScene(QPointF(2,2))); const auto ratio=shown.devicePixelRatio(); + QImage expected(1,1,QImage::Format_RGB32); expected.fill(QColor(197,81,33)); + if(dark&&adapt) QET::Palette::invertLightness(expected,palette.color(QPalette::Base),palette.color(QPalette::Text)); + check(shown.pixelColor(qRound(point.x()*ratio),qRound(point.y()*ratio))==expected.pixelColor(0,0),"actual rotated/cropped image display colour"); + project.setFilePath(output+QString("/project-%1-%2.qet").arg(dark).arg(adapt)); check(project.write().isOk(),"project save"); + QETProject reopened(project.filePath()); check(reopened.state()==QETProject::Ok,"project reopen"); + auto *loaded=findImage(reopened.diagrams().first()); check(loaded && loaded->adaptToDarkTheme()==adapt,"per-image persisted choice"); + check(png(loaded->pixmap())==pristine && loaded->transform()==transform && loaded->label()=="Image test","reopen keeps original image/crop/transform/caption"); + } + QApplication::setPalette(QET::Palette::fusionDark()); QImage exports[2]; + for(int i=0;i<2;++i) { + image->setAdaptToDarkTheme(i); exports[i]=QImage(180,180,QImage::Format_RGB32); exports[i].fill(Qt::white); + {QPainter painter(&exports[i]); diagram->render(&painter,QRectF(0,0,180,180),QRectF(0,0,180,180));} + exports[i].save(output+QString("/export-%1.png").arg(i)); + QSvgGenerator svg; svg.setFileName(output+QString("/export-%1.svg").arg(i)); svg.setSize(QSize(180,180)); svg.setViewBox(QRect(0,0,180,180)); + {QPainter painter(&svg); diagram->render(&painter,QRectF(0,0,180,180),QRectF(0,0,180,180));} + QPrinter printer; printer.setOutputFormat(QPrinter::PdfFormat); printer.setOutputFileName(output+QString("/print-%1.pdf").arg(i)); printer.setResolution(72); + {QPainter painter(&printer); check(painter.isActive(),"PDF print painter"); diagram->render(&painter,QRectF(0,0,180,180),QRectF(0,0,180,180));} + } + check(exports[0]==exports[1],"raster export unchanged"); check(read(output+"/export-0.svg")==read(output+"/export-1.svg"),"SVG export unchanged"); + std::puts("PASS: image defaults, XML crop/alpha/transform/label, properties preview/apply/reset/cancel, undo/redo and live edit, four display modes, project save/reopen, raster/SVG export, PDF print generation"); + } catch(const std::exception &error) {std::fprintf(stderr,"FAIL: %s\n",error.what());return 1;} + return 0; +} diff --git a/tests/qttest/run_image_dark_probe.py b/tests/qttest/run_image_dark_probe.py new file mode 100644 index 000000000..cd5831d90 --- /dev/null +++ b/tests/qttest/run_image_dark_probe.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Link the real image/widget integration probe using an existing Ninja app build. + +Reuses app objects without duplicating the app or introducing a core-library +refactor. No application source or existing executable is replaced. The +generated manifest, probe binary, reports and export fixtures stay in build/. +""" +import argparse +import pathlib +import re +import shutil +import subprocess +import sys +import zlib + +parser = argparse.ArgumentParser() +parser.add_argument('--build', required=True) +parser.add_argument('--binary', required=True) +parser.add_argument('--source', required=True) +parser.add_argument('--fixture', required=True) +args = parser.parse_args() +build = pathlib.Path(args.build).resolve() +binary = pathlib.Path(args.binary).resolve() +source = pathlib.Path(args.source).resolve() +fixture = pathlib.Path(args.fixture).resolve() +manifest = (build / 'build.ninja').read_text(encoding='utf-8') +match = re.search(r'^build (\S*?/sources/main\.cpp\.(?:obj|o)):', manifest, re.MULTILINE) +if not match: + sys.exit('Cannot find the application main object in the Ninja build.') +main_object = match.group(1) +probe_object = main_object.replace('main.cpp.', 'image_dark_integration_probe.cpp.') +probe_binary = binary.with_name('image_dark_integration_probe' + binary.suffix) +def escape(path): + return pathlib.Path(path).as_posix().replace('$', '$$').replace(':', '$:').replace(' ', '$ ') +main_source = source.parents[2] / 'sources' / 'main.cpp' +manifest = manifest.replace(main_object, probe_object) +manifest = manifest.replace(escape(main_source), escape(source)) +if binary.suffix: + manifest = manifest.replace(binary.name, probe_binary.name) +else: + old_target = escape(binary.name if binary.parent == build else binary) + new_target = escape(probe_binary.name if probe_binary.parent == build else probe_binary) + manifest = manifest.replace('build ' + old_target + ':', 'build ' + new_target + ':') + old_file = binary.name if binary.parent == build else binary.as_posix() + new_file = probe_binary.name if probe_binary.parent == build else probe_binary.as_posix() + manifest = re.sub(r'^ TARGET_FILE = ' + re.escape(old_file) + r'$', + ' TARGET_FILE = ' + new_file, manifest, flags=re.MULTILINE) +probe_manifest = build / 'image-dark-probe.ninja' +probe_manifest.write_text(manifest, encoding='utf-8') +ninja = shutil.which('ninja') +if not ninja: + sys.exit('Ninja is required for the integration probe.') +target = probe_binary.name if probe_binary.parent == build else str(probe_binary) +subprocess.run([ninja, '-C', str(build), '-f', probe_manifest.name, target], check=True) +output = build / 'image-dark-integration' +output.mkdir(exist_ok=True) +report = output / 'results.txt' +result = subprocess.run([str(probe_binary), str(fixture), str(report), str(output)], timeout=90) +if report.exists(): + print(report.read_text(encoding='utf-8', errors='replace')) +if result.returncode == 0: + def print_streams(path): + streams = re.findall(rb'\nstream\r?\n(.*?)\nendstream', path.read_bytes(), re.DOTALL) + decoded = [] + for stream in streams: + try: + data = zlib.decompress(stream) + except zlib.error: + data = stream + # Qt assigns every PDF a fresh XMP DocumentID (and dates). + # Metadata is not printing content and must not be compared + # as if it were a graphics/image/font stream. + if b' +#include +#include +#include "darkimagerendering.h" +#include "palettegraphicsview.h" +#include "qetpalette.h" + +class Raster : public QGraphicsItem { +public: + QPixmap pixmap; + bool adapt = false; + explicit Raster(QGraphicsItem *parent = nullptr) : QGraphicsItem(parent) { + QImage image(40, 30, QImage::Format_ARGB32); + image.fill(QColor(197, 81, 33)); + for (int y = 0; y < 30; ++y) + for (int x = 10; x < 20; ++x) image.setPixelColor(x, y, QColor(197,81,33,128)); + for (int y = 0; y < 30; ++y) + for (int x = 30; x < 40; ++x) image.setPixelColor(x, y, Qt::transparent); + pixmap = QPixmap::fromImage(image); + } + QRectF boundingRect() const override { return QRectF(0,0,40,40); } + void paint(QPainter *p, const QStyleOptionGraphicsItem *, QWidget *) override { + DarkImageRendering::paintPixmap(p, pixmap, adapt); + p->fillRect(QRectF(0,34,8,4), Qt::black); // caption/decorations remain adapted + } +}; + +class tst_darkimagelayers : public QObject { + Q_OBJECT + static QColor sample(const QImage &image, const PaletteGraphicsView &view, QPointF scene) { + const QPoint point = view.mapFromScene(scene); + const qreal dpr = image.devicePixelRatio(); + return image.pixelColor(qRound(point.x()*dpr), qRound(point.y()*dpr)); + } + static void setup(PaletteGraphicsView &view) { + view.setFrameShape(QFrame::NoFrame); + view.setAlignment(Qt::AlignLeft | Qt::AlignTop); + view.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + view.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + view.resize(100,80); view.show(); + } +private slots: + void cleanup() { + QApplication::setPalette(QET::Palette::fusionLight()); + PaletteGraphicsView::setCustomBackgroundColor(false); + } + void modes_data() { + QTest::addColumn("dark"); QTest::addColumn("adapt"); QTest::addColumn("custom"); + QTest::newRow("light-original") << false << false << false; + QTest::newRow("light-adapt") << false << true << false; + QTest::newRow("dark-original") << true << false << false; + QTest::newRow("dark-adapt") << true << true << false; + QTest::newRow("light-custom-original") << false << false << true; + QTest::newRow("light-custom-adapt") << false << true << true; + QTest::newRow("dark-custom-original") << true << false << true; + QTest::newRow("dark-custom-adapt") << true << true << true; + } + void modes() { + QFETCH(bool,dark); QFETCH(bool,adapt); QFETCH(bool,custom); + PaletteGraphicsView::setCustomBackgroundColor(custom); + const QPalette palette = dark ? QET::Palette::fusionDark() : QET::Palette::fusionLight(); + QApplication::setPalette(palette); + const QColor background = custom ? QColor(80,90,100) : QColor(Qt::white); + QGraphicsScene scene(0,0,100,80); scene.setBackgroundBrush(background); + auto *raster = new Raster; raster->adapt = adapt; raster->setPos(10,10); scene.addItem(raster); + scene.addRect(32,10,5,20,QPen(Qt::NoPen),QBrush(Qt::black))->setZValue(1); + PaletteGraphicsView view(&scene); setup(view); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + const QImage shown = view.viewport()->grab().toImage(); + QImage expected(100,80,QImage::Format_RGB32); + expected.fill(custom ? background : (dark && !adapt ? palette.color(QPalette::Base) : QColor(Qt::white))); + QPainter p(&expected); + if (dark && custom && adapt) { + QImage adapted = raster->pixmap.toImage(); + QET::Palette::invertLightnessLayer(adapted,palette.color(QPalette::Base),palette.color(QPalette::Text)); + p.drawImage(QPoint(10,10),adapted); + } else p.drawPixmap(QPoint(10,10),raster->pixmap); + p.fillRect(QRect(32,10,5,20), dark && !adapt && !custom ? palette.color(QPalette::Text) : QColor(Qt::black)); + p.end(); + if (dark && adapt && !custom) QET::Palette::invertLightness(expected,palette.color(QPalette::Base),palette.color(QPalette::Text)); + for (const QPoint point : {QPoint(15,15),QPoint(25,15),QPoint(45,15),QPoint(34,15)}) + QCOMPARE(sample(shown,view,point), expected.pixelColor(point)); + QCOMPARE(sample(shown,view,QPoint(13,45)), dark && !custom ? palette.color(QPalette::Text) : QColor(Qt::black)); + } + void parentClippingRotationOpacityAndOverlap() { + const auto palette = QET::Palette::fusionDark(); QApplication::setPalette(palette); + QGraphicsScene scene(0,0,100,80); scene.setBackgroundBrush(Qt::white); + auto *parent = scene.addRect(0,0,25,40,QPen(Qt::NoPen),QBrush(Qt::NoBrush)); + parent->setFlag(QGraphicsItem::ItemClipsChildrenToShape); parent->setPos(35,15); parent->setRotation(12); + auto *first = new Raster(parent); first->setOpacity(0.5); + auto *second = new Raster; second->setPos(36,30); scene.addItem(second); second->setZValue(2); + QImage blue(40,30,QImage::Format_ARGB32); blue.fill(QColor(20,80,210,128)); second->pixmap=QPixmap::fromImage(blue); + PaletteGraphicsView view(&scene); setup(view); QVERIFY(QTest::qWaitForWindowExposed(&view)); + const auto shown = view.viewport()->grab().toImage(); + // Original pixels blend once over the dark sheet, then the upper + // original image blends once over those pixels. No inverse transform. + QImage reference(1,1,QImage::Format_RGB32); reference.fill(palette.color(QPalette::Base)); + QPainter p(&reference); p.setOpacity(0.5); p.fillRect(reference.rect(),QColor(197,81,33)); p.end(); + QCOMPARE(sample(shown,view,first->mapToScene(QPointF(5,5))),reference.pixelColor(0,0)); + QPainter upper(&reference); upper.fillRect(reference.rect(),QColor(20,80,210,128)); upper.end(); + QCOMPARE(sample(shown,view,first->mapToScene(QPointF(5,20))),reference.pixelColor(0,0)); + // Pixel in the child's transparent stripe / beyond the parent's clip. + QCOMPARE(sample(shown,view,first->mapToScene(QPointF(28,5))),palette.color(QPalette::Base)); + first->setPos(3,0); scene.update(); QApplication::processEvents(); + QVERIFY(!view.viewport()->grab().isNull()); + } + void alphaMapping() { + QImage image(2,1,QImage::Format_ARGB32_Premultiplied); + image.setPixelColor(0,0,QColor(0,0,0,128)); image.setPixelColor(1,0,Qt::transparent); + QET::Palette::invertLightnessLayer(image,QColor(30,30,30),QColor(220,220,220)); + QCOMPARE(image.pixelColor(0,0),QColor(220,220,220,128)); + QCOMPARE(image.pixelColor(1,0).alpha(),0); + } + void exportUnaffected() { + QApplication::setPalette(QET::Palette::fusionDark()); + QGraphicsScene scene(0,0,100,80); scene.setBackgroundBrush(Qt::white); + auto *raster=new Raster; raster->setPos(10,10); scene.addItem(raster); + QImage results[2]; + for(int i=0;i<2;++i) { + raster->adapt=i; results[i]=QImage(100,80,QImage::Format_RGB32); results[i].fill(Qt::white); + QPainter p(&results[i]); scene.render(&p,QRectF(0,0,100,80),scene.sceneRect()); + } + QCOMPARE(results[0],results[1]); + QCOMPARE(results[0].pixelColor(15,15),QColor(197,81,33)); + } +}; +QTEST_MAIN(tst_darkimagelayers) +#include "tst_darkimagelayers.moc" From 83f72c95c6d2740397a6ffa16565de82d803e956 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 08:13:52 +1300 Subject: [PATCH 12/26] Fix DXF export of circles, arcs and the title block at the right (#1339) Circles and arcs: drawArcEllipse() split each curve into quarters and fitted a circular ARC through a point half way between the curve and its chord, so every circle came out as a flattened diamond. A circle or an arc of one is now one exact CIRCLE or ARC. R10 has no ELLIPSE entity, so a true ellipse is a polyline through the curve, one point every 5 degrees. The export also used a different scale on each axis, which made round things oval; it now uses one, the largest that fits the sheet. Title block at the right: the DXF laid the horizontal template out in the narrow vertical strip, squashing it into the top corner. It is now laid out horizontally and turned a quarter, as draw() turns the painter. Co-Authored-By: Claude Opus 5.5 --- sources/bordertitleblock.cpp | 21 ++- sources/createdxf.cpp | 210 ++++++++++--------------- sources/createdxf.h | 14 ++ sources/dxfexport.cpp | 9 +- sources/titleblocktemplate.cpp | 45 ++++-- sources/titleblocktemplate.h | 6 +- sources/titleblocktemplaterenderer.cpp | 7 +- sources/titleblocktemplaterenderer.h | 4 +- tests/qttest/CMakeLists.txt | 11 ++ tests/qttest/fixtures/dxf_curves.qet | 56 +++++++ tests/qttest/tst_dxfcurves.cpp | 95 +++++++++++ 11 files changed, 325 insertions(+), 153 deletions(-) create mode 100644 tests/qttest/fixtures/dxf_curves.qet create mode 100644 tests/qttest/tst_dxfcurves.cpp diff --git a/sources/bordertitleblock.cpp b/sources/bordertitleblock.cpp index f63877eb4..6ab3e0171 100644 --- a/sources/bordertitleblock.cpp +++ b/sources/bordertitleblock.cpp @@ -708,13 +708,26 @@ void BorderTitleBlock::drawDxf( // render the titleblock, using the TitleBlockTemplate object if (display_titleblock_) { Createdxf::layer = DxfExport::Layer::TitleBlock; - //qp -> translate(titleblock_rect_.topLeft()); - QRectF rect = titleBlockRect(); + //Laid out horizontally, then turned like the QPainter in + //draw(): a quarter turn counter-clockwise around its top + //left, which for a title block at the right is the bottom + //right of the diagram (issue #1339). + QRectF rect = titleBlockRectForQPainter(); + QTransform dxf_transform; + if (m_edge != Qt::BottomEdge) + { + const QPointF pivot(rect.left() * Createdxf::xScale, + Createdxf::sheetHeight + - rect.top() * Createdxf::yScale); + dxf_transform.translate(pivot.x(), pivot.y()); + dxf_transform.rotate(90); + dxf_transform.translate(-pivot.x(), -pivot.y()); + } m_titleblock_template_renderer -> renderDxf(rect, rect.width(), file_path, - color); - //qp -> translate(-titleblock_rect_.topLeft()); + color, + dxf_transform); } // Transform back to QET scale diff --git a/sources/createdxf.cpp b/sources/createdxf.cpp index 5135274ee..bf24c1add 100644 --- a/sources/createdxf.cpp +++ b/sources/createdxf.cpp @@ -22,6 +22,9 @@ #include #include "dxfexport.h" +#include +#include + const double Createdxf::sheetWidth = 4000; const double Createdxf::sheetHeight = 2700; @@ -504,6 +507,24 @@ int Createdxf::dxfColor(QPen pen) { return Createdxf::dxfColor(pen.color()); } +/** + @brief Createdxf::drawArcEllipse + Draw an arc of an ellipse, or a whole ellipse, in DXF units. + @param x, y : top left of the ellipse's bounding rect (y is the top, the + DXF Y axis going up) + @param w, h : width and height of that rect + @param startAngle, spanAngle : in degrees, as QPainter::drawArc() takes + them: counter-clockwise from 3 o'clock + @param hotspot_x, hotspot_y, rotation_angle : the whole shape is turned + clockwise by rotation_angle degrees around the hotspot, as a rotated + symbol is + A circle, or an arc of one, is written as one exact CIRCLE or ARC. R10, + the version this file declares, has no ELLIPSE entity, so a true + ellipse is written as a polyline through points of the curve, one every + 5 degrees. It used to be four ARCs fitted through a point between the + curve and its chord, which drew every circle as a flattened diamond + (issue #1339). +*/ void Createdxf::drawArcEllipse( const QString &file_path, qreal x, @@ -517,138 +538,46 @@ void Createdxf::drawArcEllipse( qreal rotation_angle, const int &colorcode) { - // vector of parts of arc (stored as a pair of startAngle and spanAngle) for each quadrant. - QVector< QPair > arc_parts_vector; + if (qFuzzyIsNull(spanAngle) || w <= 0 || h <= 0) + return; - if (spanAngle > 0) { - qreal start = startAngle; - qreal span; - int i; - for ( i = startAngle; i < startAngle+spanAngle; i++ ) { - int absolute_theta = (i > 0) ? i : -i; - if (absolute_theta == 0 || absolute_theta == 90 || - absolute_theta == 180 || absolute_theta == 270 || - absolute_theta == 360) { - span = i - start; - QPair newPart(start,span); - arc_parts_vector.push_back(newPart); - start = i; - } - } - if (start != i) { - span = i - start; - QPair newPart(start,span); - arc_parts_vector.push_back(newPart); - } - } else { - qreal start = startAngle; - qreal span; - int i; - for ( i = startAngle; i > startAngle+spanAngle; i-- ) { - int absolute_theta = (i > 0) ? i : -i; - if (absolute_theta == 0 || absolute_theta == 90 || - absolute_theta == 180 || absolute_theta == 270 || - absolute_theta == 360) { - span = i - start; - QPair newPart(start,span); - arc_parts_vector.push_back(newPart); - start = i; - } - } - if (start != i) { - span = i - start; - QPair newPart(start,span); - arc_parts_vector.push_back(newPart); + const qreal a = w/2; + const qreal b = h/2; + const QPointF center = DxfExport::rotation_transformed( + x + a, y - b, hotspot_x, hotspot_y, rotation_angle); + const bool full_turn = qAbs(spanAngle) >= 360; + + if (qAbs(a - b) <= 1e-6 * qMax(a, b)) + { + if (full_turn) { + drawCircle(file_path, a, center.x(), center.y(), colorcode); + return; } + //A DXF ARC always runs counter-clockwise from 50 to 51. + qreal start = startAngle - rotation_angle; + if (spanAngle < 0) + start += spanAngle; + start = std::fmod(start, 360.0); + if (start < 0) + start += 360; + drawArc(file_path, center.x(), center.y(), a, + start, start + qAbs(spanAngle), colorcode); + return; } - for (int i = 0; i < arc_parts_vector.size(); i++) { - - QPair arc = arc_parts_vector[i]; - if (arc.second == 0) - continue; - qreal arc_startAngle = arc.first * 3.142/180; - qreal arc_spanAngle = arc.second * 3.142/180; - - qreal a = w/2; - qreal b = h/2; - - qreal x1 = x + w/2 + a*cos(arc_startAngle); - qreal y1 = y - h/2 + b*sin(arc_startAngle); - qreal x2 = x + w/2 + a*cos(arc_startAngle + arc_spanAngle); - qreal y2 = y - h/2 + b*sin(arc_startAngle + arc_spanAngle); - - - qreal mid_ellipse_x = x + w/2 + a*cos(arc_startAngle + arc_spanAngle/2); - qreal mid_ellipse_y = y - h/2 + b*sin(arc_startAngle + arc_spanAngle/2); - qreal mid_line_x = (x1+x2)/2; - qreal mid_line_y = (y1+y2)/2; - - qreal x3 = (mid_ellipse_x + mid_line_x)/2; - qreal y3 = (mid_ellipse_y + mid_line_y)/2; - - // find circumcenter of points (x1,y1), (x3,y3) and (x2,y2) - qreal a1 = 2*x2 - 2*x1; - qreal b1 = 2*y2 - 2*y1; - qreal c1 = x1*x1 + y1*y1 - x2*x2 - y2*y2; - - qreal a2 = 2*x3 - 2*x1; - qreal b2 = 2*y3 - 2*y1; - qreal c2 = x1*x1 + y1*y1 - x3*x3 - y3*y3; - - qreal center_x = (b1*c2 - b2*c1) / (a1*b2 - a2*b1); - qreal center_y = (a1*c2 - a2*c1) / (b1*a2 - b2*a1); - - qreal radius = sqrt( (x1-center_x)*(x1-center_x) + (y1-center_y)*(y1-center_y) ); - - if ( x1 > center_x && y1 > center_y ) - arc_startAngle = asin( (y1 - center_y) / radius ); - else if ( x1 > center_x && y1 < center_y ) - arc_startAngle = 3.142*2 - asin( (center_y - y1) / radius ); - else if ( x1 < center_x && y1 < center_y ) - arc_startAngle = 3.142 + asin( (center_y - y1) / radius ); - else - arc_startAngle = 3.142 - asin( (y1 - center_y) / radius ); - - qreal arc_endAngle; - - if ( x2 > center_x && y2 > center_y ) - arc_endAngle = asin( (y2 - center_y) / radius ); - else if ( x2 > center_x && y2 < center_y ) - arc_endAngle = 3.142*2 - asin( (center_y - y2) / radius ); - else if ( x2 < center_x && y2 < center_y ) - arc_endAngle = 3.142 + asin( (center_y - y2) / radius ); - else - arc_endAngle = 3.142 - asin( (y2 - center_y) / radius ); - - if (arc_endAngle < arc_startAngle) { - qreal temp = arc_startAngle; - arc_startAngle = arc_endAngle; - arc_endAngle = temp; - } - - QPointF transformed_point = DxfExport::rotation_transformed( - center_x, - center_y, - hotspot_x, - hotspot_y, - rotation_angle); - center_x = transformed_point.x(); - center_y = transformed_point.y(); - arc_endAngle *= 180/3.142; - arc_startAngle *= 180/3.142; - arc_endAngle -= rotation_angle; - arc_startAngle -= rotation_angle; - - drawArc( - file_path, - center_x, - center_y, - radius, - arc_startAngle, - arc_endAngle, - colorcode); + const qreal span = full_turn ? 360 : spanAngle; + const int steps = qMax(2, qCeil(qAbs(span) / 5)); + QPolygonF poly; + poly.reserve(steps + 1); + for (int i = 0 ; i <= steps ; ++i) + { + const qreal theta = qDegreesToRadians(startAngle + span * i / steps); + poly << DxfExport::rotation_transformed( + x + a + a*std::cos(theta), + y - b + b*std::sin(theta), + hotspot_x, hotspot_y, rotation_angle); } + drawPolyline(file_path, poly, colorcode, true); } @@ -809,6 +738,31 @@ void Createdxf::drawTextAligned( double xAlign, double xScaleW, int colour) +{ + drawTextAligned(fileName, text, x, y, height, rotation, oblique, + hAlign, vAlign, xAlign, y, xScaleW, colour); +} + +/** + @brief Createdxf::drawTextAligned + As above, with the second alignment point given in full (@a xAlign, + @a yAlign) instead of on the same horizontal line as the insertion + point, which is what a rotated aligned text needs. +*/ +void Createdxf::drawTextAligned( + const QString& fileName, + const QString& text, + double x, + double y, + double height, + double rotation, + double oblique, + int hAlign, + int vAlign, + double xAlign, + double yAlign, + double xScaleW, + int colour) { if (!fileName.isEmpty()) { QFile file(fileName); @@ -869,7 +823,7 @@ void Createdxf::drawTextAligned( To_Dxf << 11 << "\r\n"; // XYZ To_Dxf << xAlign << "\r\n"; // X in UCS (User Coordinate System)coordinates To_Dxf << 21 << "\r\n"; - To_Dxf << y << "\r\n"; // Y in UCS (User Coordinate System)coordinates + To_Dxf << yAlign << "\r\n"; // Y in UCS (User Coordinate System)coordinates To_Dxf << 31 << "\r\n"; To_Dxf << 0.0 << "\r\n"; // Z in UCS (User Coordinate System)coordinates } diff --git a/sources/createdxf.h b/sources/createdxf.h index 5fb042e82..5e4a18d23 100644 --- a/sources/createdxf.h +++ b/sources/createdxf.h @@ -141,6 +141,20 @@ class Createdxf double xAlign, double xScale, int colour); + static void drawTextAligned( + const QString& fileName, + const QString& text, + double x, + double y, + double height, + double rotation, + double oblique, + int hAlign, + int vAlign, + double xAlign, + double yAlign, + double xScale, + int colour); static void drawPolyline( const QString &filepath, diff --git a/sources/dxfexport.cpp b/sources/dxfexport.cpp index 6db86c5b4..3e587660c 100644 --- a/sources/dxfexport.cpp +++ b/sources/dxfexport.cpp @@ -80,8 +80,13 @@ void DxfExport::write(Diagram *diagram, int width, int height, width -= 2*Diagram::margin; height -= 2*Diagram::margin; - Createdxf::xScale = Createdxf::sheetWidth / double(width); - Createdxf::yScale = Createdxf::sheetHeight / double(height); + //One scale for both axes, the largest that fits the sheet: two + //scales stretched the drawing and turned every circle into an oval + //(issue #1339). + const double scale = qMin(Createdxf::sheetWidth / double(width), + Createdxf::sheetHeight / double(height)); + Createdxf::xScale = scale; + Createdxf::yScale = scale; Createdxf::dxfBegin(file_path, Layer::all()); diff --git a/sources/titleblocktemplate.cpp b/sources/titleblocktemplate.cpp index 69214702e..f3b488592 100644 --- a/sources/titleblocktemplate.cpp +++ b/sources/titleblocktemplate.cpp @@ -1646,12 +1646,16 @@ void TitleBlockTemplate::render(QPainter &painter, Width of the titleblock to render @param file_path : @param color : + @param dxf_transform : + Applied to every DXF point after the title block is laid out, as the + QPainter is rotated for a title block at the right of the folio */ void TitleBlockTemplate::renderDxf(QRectF &title_block_rect, const DiagramContext &diagram_context, int titleblock_width, QString &file_path, - int color) const + int color, + const QTransform &dxf_transform) const { QList widths = columnsWidth(titleblock_width); @@ -1663,12 +1667,13 @@ void TitleBlockTemplate::renderDxf(QRectF &title_block_rect, *Createdxf::yScale; double recWidth = title_block_rect.width() * Createdxf::xScale; double recHeight = title_block_rect.height() * Createdxf::yScale; - Createdxf::drawRectangle(file_path, - xCoord, - yCoord, - recWidth, - recHeight, - color); + Createdxf::drawPolyline(file_path, + dxf_transform.map(QPolygonF(QRectF(xCoord, + yCoord, + recWidth, + recHeight))), + color, + true); // run through each individual cell for (int j = 0 ; j < rows_heights_.count() ; ++ j) { @@ -1702,7 +1707,10 @@ void TitleBlockTemplate::renderDxf(QRectF &title_block_rect, y = yCoord + recHeight - h - y*Createdxf::yScale; w *= Createdxf::xScale; - Createdxf::drawRectangle(file_path, x, y, w, h, color); + Createdxf::drawPolyline(file_path, + dxf_transform.map(QPolygonF(QRectF(x, y, w, h))), + color, + true); if (cells_[i][j] -> type() == TitleBlockCell::TextCell) { QString final_text = @@ -1715,7 +1723,8 @@ void TitleBlockTemplate::renderDxf(QRectF &title_block_rect, y, w, h, - color); + color, + dxf_transform); } } } @@ -1967,6 +1976,7 @@ void TitleBlockTemplate::renderTextCell(QPainter &painter, @param w @param h @param color + @param dxf_transform : see renderDxf() */ void TitleBlockTemplate::renderTextCellDxf( QString &file_path, @@ -1976,7 +1986,8 @@ void TitleBlockTemplate::renderTextCellDxf( qreal y, qreal w, qreal h, - int color) const + int color, + const QTransform &dxf_transform) const { if (text.isEmpty()) return; QFont text_font = TitleBlockTemplate::fontForCell(cell); @@ -2038,6 +2049,8 @@ void TitleBlockTemplate::renderTextCellDxf( const QStringList lines = text.split(QRegularExpression(QStringLiteral("\r\n|\r|\n"))); const qreal line_spacing = textHeight * Createdxf::yScale * 1.6; const int last = lines.size() - 1; + const qreal rotation = qRadiansToDegrees( + qAtan2(dxf_transform.m12(), dxf_transform.m11())); for (int i = 0 ; i < lines.size() ; ++i) { const QString &line = lines.at(i); @@ -2064,17 +2077,21 @@ void TitleBlockTemplate::renderTextCellDxf( } // x offset value below currently set heuristically based on appearance... + const QPointF insert = dxf_transform.map( + QPointF(x - 2*Createdxf::xScale, y1 + offset)); + const QPointF align = dxf_transform.map(QPointF(x2, y1 + offset)); Createdxf::drawTextAligned( file_path, line, - x - 2*Createdxf::xScale, - y1 + offset, + insert.x(), + insert.y(), textHeight*Createdxf::yScale, - 0, + rotation, 0, hAlign, vAlign, - x2, + align.x(), + align.y(), ratio, color); } diff --git a/sources/titleblocktemplate.h b/sources/titleblocktemplate.h index 8bc1821d6..55f918352 100644 --- a/sources/titleblocktemplate.h +++ b/sources/titleblocktemplate.h @@ -176,7 +176,8 @@ class TitleBlockTemplate : public QObject { const DiagramContext &, int, QString &, - int) const; + int, + const QTransform & = QTransform()) const; void renderCell(QPainter &, const TitleBlockCell &, const DiagramContext &, @@ -245,7 +246,8 @@ class TitleBlockTemplate : public QObject { qreal, qreal, qreal, - int) const; + int, + const QTransform &) const; // attributes private: diff --git a/sources/titleblocktemplaterenderer.cpp b/sources/titleblocktemplaterenderer.cpp index d41508829..93233db3f 100644 --- a/sources/titleblocktemplaterenderer.cpp +++ b/sources/titleblocktemplaterenderer.cpp @@ -91,15 +91,18 @@ void TitleBlockTemplateRenderer::render(QPainter *provided_painter, @param titleblock_width @param file_path @param color + @param dxf_transform : see TitleBlockTemplate::renderDxf() */ void TitleBlockTemplateRenderer::renderDxf(QRectF &title_block_rect, int titleblock_width, QString &file_path, - int color) { + int color, + const QTransform &dxf_transform) { if (!m_titleblock_template) return; m_titleblock_template -> renderDxf(title_block_rect, m_context, titleblock_width, - file_path, color); + file_path, color, + dxf_transform); } diff --git a/sources/titleblocktemplaterenderer.h b/sources/titleblocktemplaterenderer.h index b332d4cf4..4602bc640 100644 --- a/sources/titleblocktemplaterenderer.h +++ b/sources/titleblocktemplaterenderer.h @@ -18,6 +18,7 @@ #ifndef TITLEBLOCK_TEMPLATE_RENDERER_H #define TITLEBLOCK_TEMPLATE_RENDERER_H #include +#include #include "diagramcontext.h" class TitleBlockTemplate; @@ -40,7 +41,8 @@ class TitleBlockTemplateRenderer : public QObject int height() const; void render(QPainter *, int); - void renderDxf(QRectF &, int, QString &, int); + void renderDxf(QRectF &, int, QString &, int, + const QTransform & = QTransform()); private: const TitleBlockTemplate *m_titleblock_template; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index b03046a5a..dd8244a93 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -462,6 +462,17 @@ target_link_libraries(tst_dxfwrappedtext PRIVATE Qt::Test) target_compile_definitions(tst_dxfwrappedtext PRIVATE "QET_TEST_BINARY_PATH=\"$\"") +# --export-dxf on a folio with its title block at the right (issue #1339): +# a circle and an arc stay one CIRCLE and one ARC, the title block is turned. +add_executable( + tst_dxfcurves + tst_dxfcurves.cpp) +add_test(NAME tst_dxfcurves COMMAND tst_dxfcurves) +add_dependencies(tst_dxfcurves qelectrotech) +target_link_libraries(tst_dxfcurves PRIVATE Qt::Test) +target_compile_definitions(tst_dxfcurves PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") + # --export-pdf puts an A3 folio on an A3 page whichever way it is turned: # QPageSize matches standard sheets upright only, so a wide folio is matched # upright and turned. fixtures/pdf_page_a3.qet has one folio each way. diff --git a/tests/qttest/fixtures/dxf_curves.qet b/tests/qttest/fixtures/dxf_curves.qet new file mode 100644 index 000000000..d6033b5d6 --- /dev/null +++ b/tests/qttest/fixtures/dxf_curves.qet @@ -0,0 +1,56 @@ + + + 10/2/26 + 02-10-2026 + 2026-10-02 + dxf_curves + 18:39 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Imported elements + + + + + + Circle and arc + + + + + + + + + + diff --git a/tests/qttest/tst_dxfcurves.cpp b/tests/qttest/tst_dxfcurves.cpp new file mode 100644 index 000000000..c5f558077 --- /dev/null +++ b/tests/qttest/tst_dxfcurves.cpp @@ -0,0 +1,95 @@ +/* + 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 on a folio whose title block is at the right (issue #1339): + a symbol's circle is one CIRCLE and its arc one ARC with the symbol's own + angles, which needs the same scale on both axes; the title block is + turned a quarter, as it is on screen. +*/ +class tst_dxfcurves : public QObject +{ + Q_OBJECT + + /// The entities on @a layer, each as its group codes and values. + static QList> entities(const QStringList &values, + const QString &type, + const QString &layer) + { + QList> found; + for (int i = 0 ; i + 1 < values.size() ; i += 2) + { + if (values.at(i).trimmed() != QLatin1String("0") + || values.at(i + 1).trimmed() != type) + continue; + QMultiHash entity; + for (int j = i + 2 ; j + 1 < values.size() + && values.at(j).trimmed() != QLatin1String("0") ; j += 2) + entity.insert(values.at(j).trimmed().toInt(), values.at(j + 1).trimmed()); + if (entity.value(8) == layer) + found << entity; + } + return found; + } + +private slots: + void curvesAndTitleBlock() + { + const QString project = QFINDTESTDATA("fixtures/dxf_curves.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')); + const QString symbols = QStringLiteral("QET_SYMBOLS"); + + //The 20 px circle: one CIRCLE, nothing approximated + const auto circles = entities(values, QStringLiteral("CIRCLE"), symbols); + QCOMPARE(circles.size(), 1); + QVERIFY(entities(values, QStringLiteral("POLYLINE"), symbols).isEmpty()); + + //The arc from 30 to 150 degrees, same radius as the circle + const auto arcs = entities(values, QStringLiteral("ARC"), symbols); + QCOMPARE(arcs.size(), 1); + QCOMPARE(arcs.first().value(50).toDouble(), 30.0); + QCOMPARE(arcs.first().value(51).toDouble(), 150.0); + QCOMPARE(arcs.first().value(40).toDouble(), circles.first().value(40).toDouble()); + + //Every title block text turned a quarter, as drawn on screen + const auto texts = entities(values, QStringLiteral("TEXT"), QStringLiteral("QET_TITLEBLOCK")); + QVERIFY(!texts.isEmpty()); + for (const auto &text : texts) + QCOMPARE(text.value(50).toDouble(), 90.0); + } +}; + +QTEST_APPLESS_MAIN(tst_dxfcurves) +#include "tst_dxfcurves.moc" From c3ed2daca895936f1a642a3f4102c586b2955a47 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 09:50:18 +1300 Subject: [PATCH 13/26] Add an option to export symbols as DXF blocks (#1339) With "DXF : symboles en blocs" ticked in the export dialog, or --dxf-blocks on the command line, each symbol definition a folio uses is written once as a DXF block and every placed symbol as an INSERT of it. A CAD program then selects, counts and replaces a symbol as one object. The block is drawn by the same code as a symbol drawn in full, as if the symbol sat unturned at the folio's origin, which is DXF (0, sheetHeight): that is the block's base point, so the INSERT carries the symbol's position and quarter-turn and nothing else. Blocks have to come before any entity, so the export collects them first and dxfBegin() writes them in its BLOCKS section. Entities in a block keep their own layers. Off by default; with it off the file is unchanged. Co-Authored-By: Claude Opus 5.5 --- sources/cli_export.cpp | 8 +- sources/cli_export.h | 4 +- sources/createdxf.cpp | 158 +++++++++++++- sources/createdxf.h | 31 ++- sources/dxfexport.cpp | 295 +++++++++++++++++---------- sources/exportproperties.cpp | 5 + sources/exportproperties.h | 1 + sources/exportpropertieswidget.cpp | 10 + sources/exportpropertieswidget.h | 1 + tests/qttest/CMakeLists.txt | 11 + tests/qttest/fixtures/dxf_blocks.qet | 62 ++++++ tests/qttest/tst_dxfblocks.cpp | 96 +++++++++ 12 files changed, 567 insertions(+), 115 deletions(-) create mode 100644 tests/qttest/fixtures/dxf_blocks.qet create mode 100644 tests/qttest/tst_dxfblocks.cpp diff --git a/sources/cli_export.cpp b/sources/cli_export.cpp index 6dc295196..ec4185a86 100644 --- a/sources/cli_export.cpp +++ b/sources/cli_export.cpp @@ -331,7 +331,8 @@ int exportImages(QETProject &project, const QString &format, /// One DXF file per diagram, written by the same code as the export dialog /// (DxfExport) with the dialog's default options -- the export settings of /// the preferences -- so both give the same file. -int exportDxf(QETProject &project, const QString &out_dir, bool showTerminals) +int exportDxf(QETProject &project, const QString &out_dir, bool showTerminals, + bool dxfBlocks) { const QList diagrams = project.diagrams(); if (diagrams.isEmpty()) { @@ -344,6 +345,8 @@ int exportDxf(QETProject &project, const QString &out_dir, bool showTerminals) properties.format = QStringLiteral("DXF"); if (showTerminals) properties.draw_terminals = true; + if (dxfBlocks) + properties.dxf_blocks = true; int index = 0; bool has_images = false; @@ -982,6 +985,7 @@ int run(const QStringList &args) // collected below. QStringList filtered = args; const bool showTerminals = filtered.removeAll("--show-terminals") > 0; + const bool dxfBlocks = filtered.removeAll("--dxf-blocks") > 0; // --no-slaves and --no-junctions leave rows out of --export-bom. const bool includeSlaves = filtered.removeAll("--no-slaves") == 0; const bool includeJunctions = filtered.removeAll("--no-junctions") == 0; @@ -1038,7 +1042,7 @@ int run(const QStringList &args) if (format == "pdf") return exportPdf(project, output, showTerminals); if (format == "dxf") - return exportDxf(project, output, showTerminals); + return exportDxf(project, output, showTerminals, dxfBlocks); if (format == "cables" || format == "wires") return exportCsv(project, format, output); if (format == "bom") diff --git a/sources/cli_export.h b/sources/cli_export.h index e9323a035..1838fa347 100644 --- a/sources/cli_export.h +++ b/sources/cli_export.h @@ -45,7 +45,7 @@ namespace CLIExport { qelectrotech --export-pdf [--show-terminals] qelectrotech --export-png [--show-terminals] qelectrotech --export-svg [--show-terminals] - qelectrotech --export-dxf [--show-terminals] + qelectrotech --export-dxf [--show-terminals] [--dxf-blocks] qelectrotech --export-cables qelectrotech --export-wires qelectrotech --export-bom [--no-slaves] [--no-junctions] @@ -66,6 +66,8 @@ namespace CLIExport { does; off by default, matching the GUI export dialog's default. For DXF, draws the terminal markers. Has no effect on the list export modes. + --dxf-blocks: write each symbol as a DXF block, placed with one + INSERT per use, so a CAD program selects it as one object. cables: wiring list (one row per conductor) as CSV. wires: list of distinct wire numbers as CSV. bom: bill of materials (one row per element) as CSV. diff --git a/sources/createdxf.cpp b/sources/createdxf.cpp index bf24c1add..bb4e3223c 100644 --- a/sources/createdxf.cpp +++ b/sources/createdxf.cpp @@ -68,7 +68,16 @@ Createdxf::~Createdxf() /* Header section of every DXF file. @param layers : the layers the entities will be written on, declared in the LAYER table beside layer "0", which every DXF has. */ -void Createdxf::dxfBegin (const QString& fileName, const QStringList &layers) +/** + @brief Createdxf::dxfBegin + Write the header, the tables with @a layers, and the BLOCKS section, + then open the ENTITIES section. @a writeBlocks, if given, is called + inside the BLOCKS section to write the block definitions + (dxfBlockBegin(), the entities, dxfBlockEnd()): a block has to be + defined before any entity, so before anything is drawn. +*/ +void Createdxf::dxfBegin (const QString& fileName, const QStringList &layers, + const std::function &writeBlocks) { layer = QStringLiteral("0"); @@ -276,17 +285,162 @@ void Createdxf::dxfBegin (const QString& fileName, const QStringList &layers) To_Dxf << "SECTION" << "\r\n"; To_Dxf << 2 << "\r\n"; To_Dxf << "BLOCKS" << "\r\n"; + file.close(); + } + if (writeBlocks) + writeBlocks(); + QFile entities(fileName); + if (entities.open(QFile::Append)) { + QTextStream To_Dxf(&entities); To_Dxf << 0 << "\r\n"; To_Dxf << "ENDSEC" << "\r\n"; To_Dxf << 0 << "\r\n"; To_Dxf << "SECTION" << "\r\n"; To_Dxf << 2 << "\r\n"; To_Dxf << "ENTITIES" << "\r\n"; - file.close(); + entities.close(); } } } +/** + @brief Createdxf::dxfBlockBegin + Start the definition of block @a name, with its base point at @a x, + @a y. Only inside dxfBegin()'s writeBlocks; every entity written until + dxfBlockEnd() belongs to the block. + @param hasAttributes : the block has attribute definitions +*/ +void Createdxf::dxfBlockBegin(const QString &fileName, + const QString &name, + double x, + double y, + bool hasAttributes) +{ + QFile file(fileName); + if (!file.open(QFile::Append)) + return; + QTextStream To_Dxf(&file); + To_Dxf << 0 << "\r\n"; + To_Dxf << "BLOCK" << "\r\n"; + To_Dxf << 8 << "\r\n"; + To_Dxf << "0" << "\r\n"; + To_Dxf << 2 << "\r\n"; + To_Dxf << name << "\r\n"; + To_Dxf << 70 << "\r\n"; + To_Dxf << (hasAttributes ? 2 : 0) << "\r\n"; + To_Dxf << 10 << "\r\n"; + To_Dxf << x << "\r\n"; + To_Dxf << 20 << "\r\n"; + To_Dxf << y << "\r\n"; + To_Dxf << 30 << "\r\n"; + To_Dxf << 0.0 << "\r\n"; + file.close(); +} + +/** + @brief Createdxf::dxfBlockEnd + End the block started by dxfBlockBegin(). +*/ +void Createdxf::dxfBlockEnd(const QString &fileName) +{ + QFile file(fileName); + if (!file.open(QFile::Append)) + return; + QTextStream To_Dxf(&file); + To_Dxf << 0 << "\r\n"; + To_Dxf << "ENDBLK" << "\r\n"; + To_Dxf << 8 << "\r\n"; + To_Dxf << "0" << "\r\n"; + file.close(); +} + +/** + @brief Createdxf::drawInsert + Place block @a name at @a x, @a y, turned counter-clockwise by + @a rotation degrees, on the current layer, followed by its + @a attributes. +*/ +void Createdxf::drawInsert(const QString &fileName, + const QString &name, + double x, + double y, + double rotation, + const QList &attributes) +{ + QFile file(fileName); + if (!file.open(QFile::Append)) + return; + QTextStream To_Dxf(&file); + To_Dxf << 0 << "\r\n"; + To_Dxf << "INSERT" << "\r\n"; + To_Dxf << 8 << "\r\n"; + To_Dxf << layer << "\r\n"; + if (!attributes.isEmpty()) { + To_Dxf << 66 << "\r\n"; + To_Dxf << 1 << "\r\n"; + } + To_Dxf << 2 << "\r\n"; + To_Dxf << name << "\r\n"; + To_Dxf << 10 << "\r\n"; + To_Dxf << x << "\r\n"; + To_Dxf << 20 << "\r\n"; + To_Dxf << y << "\r\n"; + To_Dxf << 30 << "\r\n"; + To_Dxf << 0.0 << "\r\n"; + To_Dxf << 50 << "\r\n"; + To_Dxf << rotation << "\r\n"; + for (const Attribute &attribute : attributes) { + To_Dxf << 0 << "\r\n"; + To_Dxf << "ATTRIB" << "\r\n"; + To_Dxf << 8 << "\r\n"; + To_Dxf << layer << "\r\n"; + To_Dxf << 62 << "\r\n"; + To_Dxf << entityColour(attribute.colour) << "\r\n"; + To_Dxf << 10 << "\r\n"; + To_Dxf << attribute.x << "\r\n"; + To_Dxf << 20 << "\r\n"; + To_Dxf << attribute.y << "\r\n"; + To_Dxf << 30 << "\r\n"; + To_Dxf << 0.0 << "\r\n"; + To_Dxf << 40 << "\r\n"; + To_Dxf << attribute.height << "\r\n"; + To_Dxf << 1 << "\r\n"; + To_Dxf << singleLine(attribute.text) << "\r\n"; + To_Dxf << 2 << "\r\n"; + To_Dxf << attribute.tag << "\r\n"; + To_Dxf << 70 << "\r\n"; + To_Dxf << (attribute.invisible ? 1 : 0) << "\r\n"; + To_Dxf << 50 << "\r\n"; + To_Dxf << attribute.rotation << "\r\n"; + To_Dxf << 41 << "\r\n"; + To_Dxf << attribute.xScaleW << "\r\n"; + } + if (!attributes.isEmpty()) { + To_Dxf << 0 << "\r\n"; + To_Dxf << "SEQEND" << "\r\n"; + To_Dxf << 8 << "\r\n"; + To_Dxf << layer << "\r\n"; + } + file.close(); +} + +/** + @brief Createdxf::blockName + @return @a name as a block or attribute name an R10 reader accepts: + upper case, only A-Z, 0-9, _, - and $, at most 31 characters +*/ +QString Createdxf::blockName(const QString &name) +{ + QString result; + for (const QChar c : name.toUpper()) { + const ushort u = c.unicode(); + const bool ok = (u >= 'A' && u <= 'Z') || (u >= '0' && u <= '9') + || u == '_' || u == '-' || u == '$'; + result += ok ? c : QChar('_'); + } + return result.left(31); +} + /** @brief Createdxf::dxfEnd End Section of every DXF File diff --git a/sources/createdxf.h b/sources/createdxf.h index 5e4a18d23..87d15c6b9 100644 --- a/sources/createdxf.h +++ b/sources/createdxf.h @@ -22,6 +22,8 @@ #include #include +#include + /** @brief The Createdxf class This class exports the project to DXF Format @@ -31,8 +33,35 @@ class Createdxf public: Createdxf(); ~Createdxf(); - static void dxfBegin (const QString&, const QStringList &layers = QStringList()); + static void dxfBegin (const QString&, const QStringList &layers = QStringList(), + const std::function &writeBlocks = {}); static void dxfEnd(const QString&); + static void dxfBlockBegin(const QString &fileName, + const QString &name, + double x, + double y, + bool hasAttributes = false); + static void dxfBlockEnd(const QString &fileName); + /// One attribute of a placed block, in DXF units + struct Attribute + { + QString tag; + QString text; + double x = 0; + double y = 0; + double height = 0; + double rotation = 0; + double xScaleW = 1; + int colour = 0; + bool invisible = false; + }; + static void drawInsert(const QString &fileName, + const QString &name, + double x, + double y, + double rotation, + const QList &attributes = {}); + static QString blockName(const QString &name); // you can add more functions to create more drawings. static void drawCircle( const QString&, diff --git a/sources/dxfexport.cpp b/sources/dxfexport.cpp index 3e587660c..c04cdc71c 100644 --- a/sources/dxfexport.cpp +++ b/sources/dxfexport.cpp @@ -42,6 +42,125 @@ #include #include +namespace { + /** + Draw the drawing of @a elmt's definition: its static texts, lines, + rectangles, circles, polygons and arcs, and its terminals when + @a draw_terminals, as if it were placed at @a elem_pos_x, + @a elem_pos_y and turned @a rotation_angle degrees. Used for every + symbol drawn in full, and once per block for a block's content. + */ + void drawSymbol(const QString &file_path, Element *elmt, + qreal elem_pos_x, qreal elem_pos_y, + double rotation_angle, bool draw_terminals) + { + using namespace DxfExport; + ElementPictureFactory::primitives primitives = ElementPictureFactory::instance()->getPrimitives(elmt->location()); + + Createdxf::layer = Layer::SymbolTexts; + for(QGraphicsSimpleTextItem *text : primitives.m_texts) + { + qreal fontSize = text->font().pointSizeF(); + if (fontSize < 0) + fontSize = text->font().pixelSize(); + + qreal x = elem_pos_x + text->pos().x(); + qreal y = elem_pos_y + text->pos().y(); + + qreal angle = text -> rotation() + rotation_angle; + qreal angler = angle * M_PI/180; + int xdir = -sin(angler); + int ydir = -cos(angler); + + QPointF transformed_point = DxfExport::rotation_transformed(x, y, elem_pos_x, elem_pos_y, -rotation_angle); + x = transformed_point.x() - ydir * fontSize * 0.5; + y = transformed_point.y() - xdir * fontSize * 0.5; + QStringList lines = text->text().split('\n'); + qreal offset = fontSize * 1.6; + for (QString line : lines) + { + if (line.size() > 0 && line != "_" ) { + Createdxf::drawText(file_path, line, QPointF(x, y), fontSize, 360 - angle, 0, 0.72); + } + x += offset * xdir; + y -= offset * ydir; + } + } + + Createdxf::layer = Layer::Symbols; + for (QLineF line : primitives.m_lines) + { + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); + QLineF l = t.map(line); + Createdxf::drawLine(file_path, l, 0); + } + + for (QRectF rect : primitives.m_rectangles) + { + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); + QRectF r = t.mapRect(rect); + Createdxf::drawRectangle(file_path,r,0); + } + + for (QRectF circle_rect : primitives.m_circles) + { + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); + QPointF c = t.map(QPointF(circle_rect.center().x(),circle_rect.center().y())); + Createdxf::drawCircle(file_path,c,circle_rect.width()/2,0); + } + + for (QVector polygon : primitives.m_polygons) + { + if (polygon.size() == 0) + continue; + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); + QPolygonF poly = t.map(polygon); + if(poly.isClosed()) + Createdxf::drawPolygon(file_path,poly,0); + else + Createdxf::drawPolyline(file_path,poly,0); + } + + // Draw arcs and ellipses + for (QVector arc : primitives.m_arcs) + { + if (arc.size() == 0) + continue; + qreal x = (elem_pos_x + arc.at(0)); + qreal y = (elem_pos_y + arc.at(1)); + qreal w = arc.at(2); + qreal h = arc.at(3); + qreal startAngle = arc.at(4); + qreal spanAngle = arc .at(5); + QRectF r(x,y,w,h); + QPointF hotspot(elem_pos_x,elem_pos_y); + Createdxf::drawArcEllipse(file_path, r, startAngle, spanAngle, hotspot, rotation_angle, 0); + } + if (draw_terminals) { + Createdxf::layer = Layer::Terminals; + // Draw terminals + QList list_terminals = elmt->terminals(); + QColor col("red"); + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); + foreach(Terminal *tp, list_terminals) { + QPointF c = t.map(QPointF(tp->dock_elmt_.x(),tp->dock_elmt_.y())); + Createdxf::drawCircle(file_path,c,3.0,Createdxf::dxfColor(col)); + } + } + } + + /// Nothing would be drawn for @a elmt (its definition is missing) + bool drawsNothing(Element *elmt, bool draw_terminals) + { + const ElementPictureFactory::primitives p = + ElementPictureFactory::instance()->getPrimitives(elmt->location()); + return p.m_lines.isEmpty() && p.m_rectangles.isEmpty() + && p.m_circles.isEmpty() && p.m_polygons.isEmpty() + && p.m_arcs.isEmpty() && p.m_texts.isEmpty() + && (!draw_terminals || elmt->terminals().isEmpty()); + } +} + /** @brief DxfExport::folioSize @return the size of @a diagram as exported with @a properties: its border @@ -88,19 +207,6 @@ void DxfExport::write(Diagram *diagram, int width, int height, Createdxf::xScale = scale; Createdxf::yScale = scale; - Createdxf::dxfBegin(file_path, Layer::all()); - - //Each kind of content on its own layer (discussion #1071). The - //border's title block switches to Layer::TitleBlock itself, see - //BorderTitleBlock::drawDxf(). - Createdxf::layer = Layer::Border; - //Add project elements (lines, rectangles, circles, texts) to dxf file - if (properties.draw_border) { - QRectF rect(Diagram::margin,Diagram::margin,width,height); - Createdxf::drawRectangle(file_path,rect,0); - } - diagram -> border_and_titleblock.drawDxf(file_path, 0); - // Build the lists of elements. QList list_elements; QList list_conductors; @@ -169,6 +275,58 @@ void DxfExport::write(Diagram *diagram, int width, int height, } } + //Symbols as blocks: one block per symbol definition the folio + //uses, named after its file and numbered in the order first met, + //so a repeat export is identical. A block has to be written before + //any entity, so they are all collected first. + QHash block_names; // location -> block name + QList block_models; // one placed symbol per block + if (properties.dxf_blocks) { + QSet used; + for (Element *elmt : std::as_const(list_elements)) { + const QString key = elmt -> location().toString(); + if (block_names.contains(key) + || drawsNothing(elmt, properties.draw_terminals)) + continue; + QString file_name = elmt -> location().fileName(); + if (file_name.endsWith(QLatin1String(".elmt"))) + file_name.chop(5); + const QString base = Createdxf::blockName( + QStringLiteral("QET_") + file_name); + QString name = base; + for (int i = 2 ; used.contains(name) ; ++i) { + const QString suffix = QStringLiteral("_%1").arg(i); + name = base.left(31 - suffix.size()) + suffix; + } + used << name; + block_names.insert(key, name); + block_models << elmt; + } + } + + //A block is drawn as if its symbol sat unturned at the folio's + //origin, which is DXF (0, sheetHeight); that is its base point. + Createdxf::dxfBegin(file_path, Layer::all(), [&]() { + for (Element *model : std::as_const(block_models)) { + Createdxf::dxfBlockBegin(file_path, + block_names.value(model -> location().toString()), + 0, Createdxf::sheetHeight); + drawSymbol(file_path, model, 0, 0, 0, properties.draw_terminals); + Createdxf::dxfBlockEnd(file_path); + } + }); + + //Each kind of content on its own layer (discussion #1071). The + //border's title block switches to Layer::TitleBlock itself, see + //BorderTitleBlock::drawDxf(). + Createdxf::layer = Layer::Border; + //Add project elements (lines, rectangles, circles, texts) to dxf file + if (properties.draw_border) { + QRectF rect(Diagram::margin,Diagram::margin,width,height); + Createdxf::drawRectangle(file_path,rect,0); + } + diagram -> border_and_titleblock.drawDxf(file_path, 0); + // Draw shapes Createdxf::layer = Layer::Shapes; foreach (QetShapeItem *qsi, list_shapes) qsi->toDXF(file_path, qsi->pen()); @@ -179,105 +337,24 @@ void DxfExport::write(Diagram *diagram, int width, int height, gti->toDXF(file_path); } - //Draw elements + //Draw elements: each one in full, or as an INSERT of its block foreach(Element *elmt, list_elements) { - double rotation_angle = elmt -> orientation() * 90; + const double rotation_angle = elmt -> orientation() * 90; + const qreal elem_pos_x = elmt -> pos().x(); + const qreal elem_pos_y = elmt -> pos().y(); - qreal elem_pos_x = elmt -> pos().x(); - qreal elem_pos_y = elmt -> pos().y();// - (diagram -> margin / 2); - - ElementPictureFactory::primitives primitives = ElementPictureFactory::instance()->getPrimitives(elmt->location()); - - Createdxf::layer = Layer::SymbolTexts; - for(QGraphicsSimpleTextItem *text : primitives.m_texts) - { - qreal fontSize = text->font().pointSizeF(); - if (fontSize < 0) - fontSize = text->font().pixelSize(); - - qreal x = elem_pos_x + text->pos().x(); - qreal y = elem_pos_y + text->pos().y(); - - qreal angle = text -> rotation() + rotation_angle; - qreal angler = angle * M_PI/180; - int xdir = -sin(angler); - int ydir = -cos(angler); - - QPointF transformed_point = rotation_transformed(x, y, elem_pos_x, elem_pos_y, -rotation_angle); - x = transformed_point.x() - ydir * fontSize * 0.5; - y = transformed_point.y() - xdir * fontSize * 0.5; - QStringList lines = text->text().split('\n'); - qreal offset = fontSize * 1.6; - for (QString line : lines) - { - if (line.size() > 0 && line != "_" ) { - Createdxf::drawText(file_path, line, QPointF(x, y), fontSize, 360 - angle, 0, 0.72); - } - x += offset * xdir; - y -= offset * ydir; - } - } - - Createdxf::layer = Layer::Symbols; - for (QLineF line : primitives.m_lines) - { - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); - QLineF l = t.map(line); - Createdxf::drawLine(file_path, l, 0); - } - - for (QRectF rect : primitives.m_rectangles) - { - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); - QRectF r = t.mapRect(rect); - Createdxf::drawRectangle(file_path,r,0); - } - - for (QRectF circle_rect : primitives.m_circles) - { - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); - QPointF c = t.map(QPointF(circle_rect.center().x(),circle_rect.center().y())); - Createdxf::drawCircle(file_path,c,circle_rect.width()/2,0); - } - - for (QVector polygon : primitives.m_polygons) - { - if (polygon.size() == 0) - continue; - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); - QPolygonF poly = t.map(polygon); - if(poly.isClosed()) - Createdxf::drawPolygon(file_path,poly,0); - else - Createdxf::drawPolyline(file_path,poly,0); - } - - // Draw arcs and ellipses - for (QVector arc : primitives.m_arcs) - { - if (arc.size() == 0) - continue; - qreal x = (elem_pos_x + arc.at(0)); - qreal y = (elem_pos_y + arc.at(1)); - qreal w = arc.at(2); - qreal h = arc.at(3); - qreal startAngle = arc.at(4); - qreal spanAngle = arc .at(5); - QRectF r(x,y,w,h); - QPointF hotspot(elem_pos_x,elem_pos_y); - Createdxf::drawArcEllipse(file_path, r, startAngle, spanAngle, hotspot, rotation_angle, 0); - } - if (properties.draw_terminals) { - Createdxf::layer = Layer::Terminals; - // Draw terminals - QList list_terminals = elmt->terminals(); - QColor col("red"); - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); - foreach(Terminal *tp, list_terminals) { - QPointF c = t.map(QPointF(tp->dock_elmt_.x(),tp->dock_elmt_.y())); - Createdxf::drawCircle(file_path,c,3.0,Createdxf::dxfColor(col)); - } + const QString block = block_names.value(elmt -> location().toString()); + if (block.isEmpty()) { + drawSymbol(file_path, elmt, elem_pos_x, elem_pos_y, + rotation_angle, properties.draw_terminals); + } else { + Createdxf::layer = Layer::Symbols; + //QElectroTech turns a symbol clockwise, DXF counter-clockwise + Createdxf::drawInsert(file_path, block, + elem_pos_x * Createdxf::xScale, + Createdxf::sheetHeight - elem_pos_y * Createdxf::yScale, + std::fmod(360 - rotation_angle, 360)); } } diff --git a/sources/exportproperties.cpp b/sources/exportproperties.cpp index 7e85a4b2d..3f11f213e 100644 --- a/sources/exportproperties.cpp +++ b/sources/exportproperties.cpp @@ -37,6 +37,7 @@ ExportProperties::ExportProperties() : draw_terminal_names(false), draw_bg_transparent(false), draw_colored_conductors(true), + dxf_blocks(false), exported_area(QET::BorderArea) { } @@ -77,6 +78,8 @@ void ExportProperties::toSettings(QSettings &settings, draw_bg_transparent); settings.setValue(prefix % "drawcoloredconductors", draw_colored_conductors); + settings.setValue(prefix % "dxfblocks", + dxf_blocks); settings.setValue(prefix % "area", QET::diagramAreaToString(exported_area)); } @@ -115,6 +118,8 @@ void ExportProperties::fromSettings(QSettings &settings, draw_colored_conductors = settings.value( prefix % "drawcoloredconductors", true ).toBool(); + dxf_blocks = settings.value(prefix % "dxfblocks", + false).toBool(); exported_area = QET::diagramAreaFromString( settings.value( diff --git a/sources/exportproperties.h b/sources/exportproperties.h index af34f6868..c9489496a 100644 --- a/sources/exportproperties.h +++ b/sources/exportproperties.h @@ -50,6 +50,7 @@ class ExportProperties { bool draw_terminal_names; ///< Whether to render terminal names/labels bool draw_bg_transparent; ///< Whether to use transparency for SVG-Export bool draw_colored_conductors; ///< Whether to render conductors colors + bool dxf_blocks; ///< DXF: each symbol as a block, placed with an INSERT QET::DiagramArea exported_area; ///< Area of diagrams to be rendered }; #endif diff --git a/sources/exportpropertieswidget.cpp b/sources/exportpropertieswidget.cpp index 8eccc0805..e95a5cb6d 100644 --- a/sources/exportpropertieswidget.cpp +++ b/sources/exportpropertieswidget.cpp @@ -66,6 +66,7 @@ ExportProperties ExportPropertiesWidget::exportProperties() const export_properties.draw_terminal_names = draw_terminal_names -> isChecked(); export_properties.draw_bg_transparent = draw_bg_transparent -> isChecked(); export_properties.draw_colored_conductors = draw_colored_conductors -> isChecked(); + export_properties.dxf_blocks = dxf_blocks -> isChecked(); export_properties.exported_area = export_border -> isChecked() ? QET::BorderArea : QET::ElementsArea; return(export_properties); @@ -89,6 +90,7 @@ void ExportPropertiesWidget::setExportProperties(const ExportProperties &export_ draw_terminal_names -> setChecked(export_properties.draw_terminal_names); draw_bg_transparent -> setChecked(export_properties.draw_bg_transparent); draw_colored_conductors -> setChecked(export_properties.draw_colored_conductors); + dxf_blocks -> setChecked(export_properties.dxf_blocks); if (export_properties.exported_area == QET::BorderArea) { export_border -> setChecked(true); @@ -219,6 +221,11 @@ void ExportPropertiesWidget::build() // use transparent background for SVG-Export draw_bg_transparent = new QCheckBox(tr("SVG: fond transparent"), groupbox_options); optionshlayout -> addWidget(draw_bg_transparent, 4, 0); + + // each symbol as a DXF block (issue #1339) + dxf_blocks = new QCheckBox(tr("DXF : symboles en blocs"), groupbox_options); + dxf_blocks -> setToolTip(tr("Chaque symbole devient un bloc DXF, sélectionnable d'un clic dans un logiciel de CAO")); + optionshlayout -> addWidget(dxf_blocks, 4, 1); vboxLayout -> addWidget(groupbox_options); @@ -234,6 +241,7 @@ void ExportPropertiesWidget::build() setTabOrder(draw_titleblock, draw_terminals); setTabOrder(draw_terminals, draw_colored_conductors); setTabOrder(draw_colored_conductors, draw_bg_transparent); + setTabOrder(draw_bg_transparent, dxf_blocks); // connexion du bouton permettant le choix du repertoire connect(button_browse, &QPushButton::released, this, &ExportPropertiesWidget::slot_chooseADirectory); @@ -249,6 +257,7 @@ void ExportPropertiesWidget::build() connect(draw_terminal_names, &QCheckBox::stateChanged, this, &ExportPropertiesWidget::optionChanged); connect(draw_bg_transparent, &QCheckBox::stateChanged, this, &ExportPropertiesWidget::optionChanged); connect(draw_colored_conductors, &QCheckBox::stateChanged, this, &ExportPropertiesWidget::optionChanged); + connect(dxf_blocks, &QCheckBox::stateChanged, this, &ExportPropertiesWidget::optionChanged); #else connect(draw_grid, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); connect(draw_border, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); @@ -257,5 +266,6 @@ void ExportPropertiesWidget::build() connect(draw_terminal_names, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); connect(draw_bg_transparent, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); connect(draw_colored_conductors, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); + connect(dxf_blocks, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); #endif } diff --git a/sources/exportpropertieswidget.h b/sources/exportpropertieswidget.h index ae0c97acd..f00032c65 100644 --- a/sources/exportpropertieswidget.h +++ b/sources/exportpropertieswidget.h @@ -65,6 +65,7 @@ class ExportPropertiesWidget : public QWidget { QCheckBox *draw_terminal_names; QCheckBox *draw_bg_transparent; QCheckBox *draw_colored_conductors; + QCheckBox *dxf_blocks; QRadioButton *export_border; QRadioButton *export_elements; QButtonGroup *exported_content_choices; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index dd8244a93..b44bc3c77 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -473,6 +473,17 @@ target_link_libraries(tst_dxfcurves PRIVATE Qt::Test) target_compile_definitions(tst_dxfcurves PRIVATE "QET_TEST_BINARY_PATH=\"$\"") +# --export-dxf --dxf-blocks: a symbol placed twice is one BLOCK and two +# INSERTs (issue #1339). +add_executable( + tst_dxfblocks + tst_dxfblocks.cpp) +add_test(NAME tst_dxfblocks COMMAND tst_dxfblocks) +add_dependencies(tst_dxfblocks qelectrotech) +target_link_libraries(tst_dxfblocks PRIVATE Qt::Test) +target_compile_definitions(tst_dxfblocks PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") + # --export-pdf puts an A3 folio on an A3 page whichever way it is turned: # QPageSize matches standard sheets upright only, so a wide folio is matched # upright and turned. fixtures/pdf_page_a3.qet has one folio each way. diff --git a/tests/qttest/fixtures/dxf_blocks.qet b/tests/qttest/fixtures/dxf_blocks.qet new file mode 100644 index 000000000..ffd6f9c4f --- /dev/null +++ b/tests/qttest/fixtures/dxf_blocks.qet @@ -0,0 +1,62 @@ + + + 10/2/26 + 02-10-2026 + 2026-10-02 + dxf_blocks + 18:39 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Imported elements + + + + + + Circle and arc + + + + + + + + + + diff --git a/tests/qttest/tst_dxfblocks.cpp b/tests/qttest/tst_dxfblocks.cpp new file mode 100644 index 000000000..bc409c85d --- /dev/null +++ b/tests/qttest/tst_dxfblocks.cpp @@ -0,0 +1,96 @@ +/* + 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 --dxf-blocks (issue #1339): a symbol placed twice is one + BLOCK and two INSERTs, the second turned a quarter; without the switch + there is neither. +*/ +class tst_dxfblocks : public QObject +{ + Q_OBJECT + + /// The group code / value pairs of the exported folio + static QList> exportPairs(const QStringList &extra) + { + const QString project = QFINDTESTDATA("fixtures/dxf_blocks.qet"); + QTemporaryDir dir; + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), + QStringList{QStringLiteral("--export-dxf"), project, dir.path()} + extra); + if (!proc.waitForFinished(60000) || proc.exitCode() != 0) + return {}; + QFile file(dir.filePath(QStringLiteral("01_diagram.dxf"))); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return {}; + const QStringList values = QString::fromUtf8(file.readAll()).split(QLatin1Char('\n')); + QList> pairs; + for (int i = 0 ; i + 1 < values.size() ; i += 2) + pairs << qMakePair(values.at(i).trimmed(), values.at(i + 1).trimmed()); + return pairs; + } + + /// The values of code @a code in each entity of type @a type + static QStringList values(const QList> &pairs, + const QString &type, const QString &code) + { + QStringList found; + for (int i = 0 ; i < pairs.size() ; ++i) { + if (pairs.at(i).first != QLatin1String("0") || pairs.at(i).second != type) + continue; + for (int j = i + 1 ; j < pairs.size() && pairs.at(j).first != QLatin1String("0") ; ++j) + if (pairs.at(j).first == code) + found << pairs.at(j).second; + } + return found; + } + +private slots: + void blocks() + { + const auto pairs = exportPairs({QStringLiteral("--dxf-blocks")}); + QVERIFY2(!pairs.isEmpty(), "--export-dxf --dxf-blocks failed"); + + QCOMPARE(values(pairs, QStringLiteral("BLOCK"), QStringLiteral("2")), + QStringList{QStringLiteral("QET_CURVES")}); + QCOMPARE(values(pairs, QStringLiteral("INSERT"), QStringLiteral("2")), + (QStringList{QStringLiteral("QET_CURVES"), QStringLiteral("QET_CURVES")})); + QCOMPARE(values(pairs, QStringLiteral("INSERT"), QStringLiteral("50")), + (QStringList{QStringLiteral("0"), QStringLiteral("270")})); + //The circle is drawn once, inside the block + QCOMPARE(values(pairs, QStringLiteral("CIRCLE"), QStringLiteral("8")).size(), 1); + } + + void flatWithoutSwitch() + { + const auto pairs = exportPairs({}); + QVERIFY2(!pairs.isEmpty(), "--export-dxf failed"); + QVERIFY(values(pairs, QStringLiteral("BLOCK"), QStringLiteral("2")).isEmpty()); + QVERIFY(values(pairs, QStringLiteral("INSERT"), QStringLiteral("2")).isEmpty()); + QCOMPARE(values(pairs, QStringLiteral("CIRCLE"), QStringLiteral("8")).size(), 2); + } +}; + +QTEST_APPLESS_MAIN(tst_dxfblocks) +#include "tst_dxfblocks.moc" From 9699b314b0a383331005b598aaa3154f26cbc29a Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 09:56:26 +1300 Subject: [PATCH 14/26] Add an option to export a symbol's texts as DXF block attributes (#1339) With symbols as blocks and the new "DXF : textes des symboles en attributs" option (--dxf-attributes on the command line), a symbol's own texts (its label, function, comment...) are attributes of its INSERT instead of loose texts, so a CAD program moves them with the symbol and can list or edit them. Each is written exactly where, and as, the text was. A text showing an information field is tagged with its name (LABEL, FUNCTION...), a typed text TEXT1, TEXT2...; a text's second line gets _2. The block defines each tag once, placed where the first symbol has it. Its own option, off by default, because LibreCAD (2.2.1.5) does not show attributes: the labels would vanish there. AutoCAD shows them. The text geometry moves into textLines(), shared with the text loop, so the attributes and the plain texts cannot drift apart. Co-Authored-By: Claude Opus 5.5 --- sources/cli_export.cpp | 9 +- sources/cli_export.h | 5 +- sources/createdxf.cpp | 43 ++++++- sources/createdxf.h | 3 + sources/dxfexport.cpp | 181 ++++++++++++++++++++++----- sources/exportproperties.cpp | 5 + sources/exportproperties.h | 1 + sources/exportpropertieswidget.cpp | 12 ++ sources/exportpropertieswidget.h | 1 + tests/qttest/fixtures/dxf_blocks.qet | 20 ++- tests/qttest/tst_dxfblocks.cpp | 29 ++++- 11 files changed, 266 insertions(+), 43 deletions(-) diff --git a/sources/cli_export.cpp b/sources/cli_export.cpp index ec4185a86..fb2d94869 100644 --- a/sources/cli_export.cpp +++ b/sources/cli_export.cpp @@ -332,7 +332,7 @@ int exportImages(QETProject &project, const QString &format, /// (DxfExport) with the dialog's default options -- the export settings of /// the preferences -- so both give the same file. int exportDxf(QETProject &project, const QString &out_dir, bool showTerminals, - bool dxfBlocks) + bool dxfBlocks, bool dxfAttributes) { const QList diagrams = project.diagrams(); if (diagrams.isEmpty()) { @@ -345,8 +345,10 @@ int exportDxf(QETProject &project, const QString &out_dir, bool showTerminals, properties.format = QStringLiteral("DXF"); if (showTerminals) properties.draw_terminals = true; - if (dxfBlocks) + if (dxfBlocks || dxfAttributes) properties.dxf_blocks = true; + if (dxfAttributes) + properties.dxf_attributes = true; int index = 0; bool has_images = false; @@ -986,6 +988,7 @@ int run(const QStringList &args) QStringList filtered = args; const bool showTerminals = filtered.removeAll("--show-terminals") > 0; const bool dxfBlocks = filtered.removeAll("--dxf-blocks") > 0; + const bool dxfAttributes = filtered.removeAll("--dxf-attributes") > 0; // --no-slaves and --no-junctions leave rows out of --export-bom. const bool includeSlaves = filtered.removeAll("--no-slaves") == 0; const bool includeJunctions = filtered.removeAll("--no-junctions") == 0; @@ -1042,7 +1045,7 @@ int run(const QStringList &args) if (format == "pdf") return exportPdf(project, output, showTerminals); if (format == "dxf") - return exportDxf(project, output, showTerminals, dxfBlocks); + return exportDxf(project, output, showTerminals, dxfBlocks, dxfAttributes); if (format == "cables" || format == "wires") return exportCsv(project, format, output); if (format == "bom") diff --git a/sources/cli_export.h b/sources/cli_export.h index 1838fa347..efb3ad59e 100644 --- a/sources/cli_export.h +++ b/sources/cli_export.h @@ -45,7 +45,7 @@ namespace CLIExport { qelectrotech --export-pdf [--show-terminals] qelectrotech --export-png [--show-terminals] qelectrotech --export-svg [--show-terminals] - qelectrotech --export-dxf [--show-terminals] [--dxf-blocks] + qelectrotech --export-dxf [--show-terminals] [--dxf-blocks] [--dxf-attributes] qelectrotech --export-cables qelectrotech --export-wires qelectrotech --export-bom [--no-slaves] [--no-junctions] @@ -68,6 +68,9 @@ namespace CLIExport { the list export modes. --dxf-blocks: write each symbol as a DXF block, placed with one INSERT per use, so a CAD program selects it as one object. + --dxf-attributes: as --dxf-blocks, and a symbol's texts (its label, + function...) are attributes of its INSERT. LibreCAD does not + show attributes. cables: wiring list (one row per conductor) as CSV. wires: list of distinct wire numbers as CSV. bom: bill of materials (one row per element) as CSV. diff --git a/sources/createdxf.cpp b/sources/createdxf.cpp index bb4e3223c..36c42fbf3 100644 --- a/sources/createdxf.cpp +++ b/sources/createdxf.cpp @@ -393,7 +393,7 @@ void Createdxf::drawInsert(const QString &fileName, To_Dxf << 0 << "\r\n"; To_Dxf << "ATTRIB" << "\r\n"; To_Dxf << 8 << "\r\n"; - To_Dxf << layer << "\r\n"; + To_Dxf << (attribute.layer.isEmpty() ? layer : attribute.layer) << "\r\n"; To_Dxf << 62 << "\r\n"; To_Dxf << entityColour(attribute.colour) << "\r\n"; To_Dxf << 10 << "\r\n"; @@ -424,6 +424,47 @@ void Createdxf::drawInsert(const QString &fileName, file.close(); } +/** + @brief Createdxf::drawAttdef + Define an attribute of the block being written (between + dxfBlockBegin() and dxfBlockEnd()): its tag, where it sits and how it + looks; @a attribute's text is the default value. +*/ +void Createdxf::drawAttdef(const QString &fileName, const Attribute &attribute) +{ + QFile file(fileName); + if (!file.open(QFile::Append)) + return; + QTextStream To_Dxf(&file); + To_Dxf << 0 << "\r\n"; + To_Dxf << "ATTDEF" << "\r\n"; + To_Dxf << 8 << "\r\n"; + To_Dxf << (attribute.layer.isEmpty() ? layer : attribute.layer) << "\r\n"; + To_Dxf << 62 << "\r\n"; + To_Dxf << entityColour(attribute.colour) << "\r\n"; + To_Dxf << 10 << "\r\n"; + To_Dxf << attribute.x << "\r\n"; + To_Dxf << 20 << "\r\n"; + To_Dxf << attribute.y << "\r\n"; + To_Dxf << 30 << "\r\n"; + To_Dxf << 0.0 << "\r\n"; + To_Dxf << 40 << "\r\n"; + To_Dxf << attribute.height << "\r\n"; + To_Dxf << 1 << "\r\n"; + To_Dxf << singleLine(attribute.text) << "\r\n"; + To_Dxf << 3 << "\r\n"; + To_Dxf << attribute.tag << "\r\n"; + To_Dxf << 2 << "\r\n"; + To_Dxf << attribute.tag << "\r\n"; + To_Dxf << 70 << "\r\n"; + To_Dxf << (attribute.invisible ? 1 : 0) << "\r\n"; + To_Dxf << 50 << "\r\n"; + To_Dxf << attribute.rotation << "\r\n"; + To_Dxf << 41 << "\r\n"; + To_Dxf << attribute.xScaleW << "\r\n"; + file.close(); +} + /** @brief Createdxf::blockName @return @a name as a block or attribute name an R10 reader accepts: diff --git a/sources/createdxf.h b/sources/createdxf.h index 87d15c6b9..47d3cd24c 100644 --- a/sources/createdxf.h +++ b/sources/createdxf.h @@ -54,6 +54,7 @@ class Createdxf double xScaleW = 1; int colour = 0; bool invisible = false; + QString layer; ///< empty: the INSERT's layer }; static void drawInsert(const QString &fileName, const QString &name, @@ -61,6 +62,8 @@ class Createdxf double y, double rotation, const QList &attributes = {}); + static void drawAttdef(const QString &fileName, + const Attribute &attribute); static QString blockName(const QString &name); // you can add more functions to create more drawings. static void drawCircle( diff --git a/sources/dxfexport.cpp b/sources/dxfexport.cpp index c04cdc71c..db46ffd59 100644 --- a/sources/dxfexport.cpp +++ b/sources/dxfexport.cpp @@ -149,6 +149,65 @@ namespace { } } + /// One line of a text as the export writes it: where it starts, in + /// scene coordinates, its size, and its angle (degrees, clockwise) + struct TextLine + { + QString text; + QPointF pos; + qreal font_size; + qreal angle; + }; + + /// The lines of @a dti that are written, one under the other as drawn + QList textLines(DiagramTextItem *dti) + { + qreal fontSize = dti -> font().pointSizeF(); + if (fontSize < 0) + fontSize = dti -> font().pixelSize(); + + qreal angle = dti -> rotation(); + + QGraphicsItem *parent = dti->parentItem(); + while (parent) { + angle += parent->rotation(); + parent = parent->parentItem(); + } + + qreal angler = angle * M_PI/180; + int xdir = -sin(angler); + int ydir = -cos(angler); + qreal x = (dti->scenePos().x()) + + xdir * fontSize * 1.8 + - ydir * fontSize; + qreal y = dti->scenePos().y() + - ydir * fontSize * 1.8 + - xdir * fontSize * 0.9; + //As drawn: a text with a width is wrapped + QStringList lines = TextLines::layoutLines(dti -> document()); + qreal offset = fontSize * 1.6; + QList result; + foreach (QString line, lines) { + if (line.size() > 0 && line != "_" ) + result << TextLine{line, QPointF(x, y), fontSize, angle}; + x += offset * xdir; + y -= offset * ydir; + } + return result; + } + + /// @a tag, or @a tag with _2, _3... if @a used has it, at most 31 + /// characters + QString uniqueTag(const QString &tag, const QSet &used) + { + QString unique = tag; + for (int i = 2 ; used.contains(unique) ; ++i) { + const QString suffix = QStringLiteral("_%1").arg(i); + unique = tag.left(31 - suffix.size()) + suffix; + } + return unique; + } + /// Nothing would be drawn for @a elmt (its definition is missing) bool drawsNothing(Element *elmt, bool draw_terminals) { @@ -304,14 +363,92 @@ void DxfExport::write(Diagram *diagram, int width, int height, } } + //With dxf_attributes, a symbol's own texts (its label, its + //function...) become attributes of its INSERT, written where the + //text would be, so a CAD program moves them with the symbol and + //lists them. Off by default: LibreCAD does not show attributes, + //so the labels would vanish there. Tagged + //with the information they show (LABEL, FUNCTION...), or TEXT1, + //TEXT2... for a typed text; a second line gets _2. The block + //defines each tag once, where the first symbol has it. + QHash> attributes; + QHash> attribute_definitions; // block -> ATTDEFs + QSet attribute_texts; + { + QHash> used_tags; + QHash typed_texts; + QHash> defined_tags; + for (DiagramTextItem *dti : std::as_const(list_texts)) { + if (!properties.dxf_attributes) + break; + auto *deti = qgraphicsitem_cast(dti); + Element *elmt = deti ? deti -> parentElement() : nullptr; + const QString block = elmt + ? block_names.value(elmt -> location().toString()) + : QString(); + if (block.isEmpty()) + continue; + + const QString base = + deti -> textFrom() == DynamicElementTextItem::ElementInfo + && !deti -> infoName().isEmpty() + ? Createdxf::blockName(deti -> infoName()) + : QStringLiteral("TEXT%1").arg(++typed_texts[elmt]); + //From the folio into the block: undo the INSERT + const QPointF insert(elmt -> pos().x() * Createdxf::xScale, + Createdxf::sheetHeight - elmt -> pos().y() * Createdxf::yScale); + const double turn = std::fmod(360 - elmt -> orientation() * 90, 360); + const QTransform to_block = QTransform() + .translate(0, Createdxf::sheetHeight) + .rotate(-turn) + .translate(-insert.x(), -insert.y()); + + const QList lines = textLines(dti); + for (int i = 0 ; i < lines.size() ; ++i) { + const TextLine &line = lines.at(i); + const QString tag = uniqueTag( + i == 0 ? base : base + QStringLiteral("_%1").arg(i + 1), + used_tags.value(elmt)); + used_tags[elmt] << tag; + + Createdxf::Attribute attribute; + attribute.tag = tag; + attribute.text = line.text; + attribute.x = line.pos.x() * Createdxf::xScale; + attribute.y = Createdxf::sheetHeight - line.pos.y() * Createdxf::yScale; + attribute.height = line.font_size * Createdxf::yScale; + attribute.rotation = 360 - line.angle; + attribute.xScaleW = 0.72; + attribute.colour = Createdxf::dxfColor(dti -> color()); + attribute.layer = Layer::SymbolTexts; + attributes[elmt] << attribute; + + if (!defined_tags[block].contains(tag)) { + defined_tags[block] << tag; + Createdxf::Attribute definition = attribute; + const QPointF in_block = to_block.map(QPointF(attribute.x, attribute.y)); + definition.x = in_block.x(); + definition.y = in_block.y(); + definition.rotation = std::fmod(attribute.rotation - turn + 720, 360); + definition.text.clear(); + attribute_definitions[block] << definition; + } + } + attribute_texts << dti; + } + } + //A block is drawn as if its symbol sat unturned at the folio's //origin, which is DXF (0, sheetHeight); that is its base point. Createdxf::dxfBegin(file_path, Layer::all(), [&]() { for (Element *model : std::as_const(block_models)) { - Createdxf::dxfBlockBegin(file_path, - block_names.value(model -> location().toString()), - 0, Createdxf::sheetHeight); + const QString block = block_names.value(model -> location().toString()); + const QList definitions = attribute_definitions.value(block); + Createdxf::dxfBlockBegin(file_path, block, 0, Createdxf::sheetHeight, + !definitions.isEmpty()); drawSymbol(file_path, model, 0, 0, 0, properties.draw_terminals); + for (const Createdxf::Attribute &definition : definitions) + Createdxf::drawAttdef(file_path, definition); Createdxf::dxfBlockEnd(file_path); } }); @@ -354,7 +491,8 @@ void DxfExport::write(Diagram *diagram, int width, int height, Createdxf::drawInsert(file_path, block, elem_pos_x * Createdxf::xScale, Createdxf::sheetHeight - elem_pos_y * Createdxf::yScale, - std::fmod(360 - rotation_angle, 360)); + std::fmod(360 - rotation_angle, 360), + attributes.value(elmt)); } } @@ -417,39 +555,14 @@ void DxfExport::write(Diagram *diagram, int width, int height, //Draw text items foreach(DiagramTextItem *dti, list_texts) { + //Already written as an attribute of its symbol's INSERT + if (attribute_texts.contains(dti)) + continue; //A free text, or a symbol's own text (its label and the like) Createdxf::layer = qgraphicsitem_cast(dti) ? Layer::Texts : Layer::SymbolTexts; - qreal fontSize = dti -> font().pointSizeF(); - if (fontSize < 0) - fontSize = dti -> font().pixelSize(); - - qreal angle = dti -> rotation(); - - QGraphicsItem *parent = dti->parentItem(); - while (parent) { - angle += parent->rotation(); - parent = parent->parentItem(); - } - - qreal angler = angle * M_PI/180; - int xdir = -sin(angler); - int ydir = -cos(angler); - qreal x = (dti->scenePos().x()) - + xdir * fontSize * 1.8 - - ydir * fontSize; - qreal y = dti->scenePos().y() - - ydir * fontSize * 1.8 - - xdir * fontSize * 0.9; - //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 != "_" ) - Createdxf::drawText(file_path, line, QPointF(x, y), fontSize, 360-angle, Createdxf::dxfColor(dti->color()), 0.72 ); - x += offset * xdir; - y -= offset * ydir; - } + for (const TextLine &line : textLines(dti)) + Createdxf::drawText(file_path, line.text, line.pos, line.font_size, 360-line.angle, Createdxf::dxfColor(dti->color()), 0.72 ); } //Draw the slave cross-reference labels diff --git a/sources/exportproperties.cpp b/sources/exportproperties.cpp index 3f11f213e..cd53bc806 100644 --- a/sources/exportproperties.cpp +++ b/sources/exportproperties.cpp @@ -38,6 +38,7 @@ ExportProperties::ExportProperties() : draw_bg_transparent(false), draw_colored_conductors(true), dxf_blocks(false), + dxf_attributes(false), exported_area(QET::BorderArea) { } @@ -80,6 +81,8 @@ void ExportProperties::toSettings(QSettings &settings, draw_colored_conductors); settings.setValue(prefix % "dxfblocks", dxf_blocks); + settings.setValue(prefix % "dxfattributes", + dxf_attributes); settings.setValue(prefix % "area", QET::diagramAreaToString(exported_area)); } @@ -120,6 +123,8 @@ void ExportProperties::fromSettings(QSettings &settings, true ).toBool(); dxf_blocks = settings.value(prefix % "dxfblocks", false).toBool(); + dxf_attributes = settings.value(prefix % "dxfattributes", + false).toBool(); exported_area = QET::diagramAreaFromString( settings.value( diff --git a/sources/exportproperties.h b/sources/exportproperties.h index c9489496a..c8feca461 100644 --- a/sources/exportproperties.h +++ b/sources/exportproperties.h @@ -51,6 +51,7 @@ class ExportProperties { bool draw_bg_transparent; ///< Whether to use transparency for SVG-Export bool draw_colored_conductors; ///< Whether to render conductors colors bool dxf_blocks; ///< DXF: each symbol as a block, placed with an INSERT + bool dxf_attributes; ///< DXF, with dxf_blocks: a symbol's texts as attributes of its INSERT QET::DiagramArea exported_area; ///< Area of diagrams to be rendered }; #endif diff --git a/sources/exportpropertieswidget.cpp b/sources/exportpropertieswidget.cpp index e95a5cb6d..0ce0dac46 100644 --- a/sources/exportpropertieswidget.cpp +++ b/sources/exportpropertieswidget.cpp @@ -67,6 +67,7 @@ ExportProperties ExportPropertiesWidget::exportProperties() const export_properties.draw_bg_transparent = draw_bg_transparent -> isChecked(); export_properties.draw_colored_conductors = draw_colored_conductors -> isChecked(); export_properties.dxf_blocks = dxf_blocks -> isChecked(); + export_properties.dxf_attributes = dxf_attributes -> isChecked(); export_properties.exported_area = export_border -> isChecked() ? QET::BorderArea : QET::ElementsArea; return(export_properties); @@ -91,6 +92,8 @@ void ExportPropertiesWidget::setExportProperties(const ExportProperties &export_ draw_bg_transparent -> setChecked(export_properties.draw_bg_transparent); draw_colored_conductors -> setChecked(export_properties.draw_colored_conductors); dxf_blocks -> setChecked(export_properties.dxf_blocks); + dxf_attributes -> setChecked(export_properties.dxf_attributes); + dxf_attributes -> setEnabled(export_properties.dxf_blocks); if (export_properties.exported_area == QET::BorderArea) { export_border -> setChecked(true); @@ -226,6 +229,12 @@ void ExportPropertiesWidget::build() dxf_blocks = new QCheckBox(tr("DXF : symboles en blocs"), groupbox_options); dxf_blocks -> setToolTip(tr("Chaque symbole devient un bloc DXF, sélectionnable d'un clic dans un logiciel de CAO")); optionshlayout -> addWidget(dxf_blocks, 4, 1); + + // with blocks, a symbol's texts as attributes of its block + dxf_attributes = new QCheckBox(tr("DXF : textes des symboles en attributs"), groupbox_options); + dxf_attributes -> setToolTip(tr("Les textes d'un symbole (repère, fonction...) deviennent des attributs de son bloc. LibreCAD n'affiche pas les attributs.")); + optionshlayout -> addWidget(dxf_attributes, 5, 1); + connect(dxf_blocks, &QCheckBox::toggled, dxf_attributes, &QCheckBox::setEnabled); vboxLayout -> addWidget(groupbox_options); @@ -242,6 +251,7 @@ void ExportPropertiesWidget::build() setTabOrder(draw_terminals, draw_colored_conductors); setTabOrder(draw_colored_conductors, draw_bg_transparent); setTabOrder(draw_bg_transparent, dxf_blocks); + setTabOrder(dxf_blocks, dxf_attributes); // connexion du bouton permettant le choix du repertoire connect(button_browse, &QPushButton::released, this, &ExportPropertiesWidget::slot_chooseADirectory); @@ -258,6 +268,7 @@ void ExportPropertiesWidget::build() connect(draw_bg_transparent, &QCheckBox::stateChanged, this, &ExportPropertiesWidget::optionChanged); connect(draw_colored_conductors, &QCheckBox::stateChanged, this, &ExportPropertiesWidget::optionChanged); connect(dxf_blocks, &QCheckBox::stateChanged, this, &ExportPropertiesWidget::optionChanged); + connect(dxf_attributes, &QCheckBox::stateChanged, this, &ExportPropertiesWidget::optionChanged); #else connect(draw_grid, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); connect(draw_border, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); @@ -267,5 +278,6 @@ void ExportPropertiesWidget::build() connect(draw_bg_transparent, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); connect(draw_colored_conductors, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); connect(dxf_blocks, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); + connect(dxf_attributes, &QCheckBox::checkStateChanged, this, &ExportPropertiesWidget::optionChanged); #endif } diff --git a/sources/exportpropertieswidget.h b/sources/exportpropertieswidget.h index f00032c65..5b96e4273 100644 --- a/sources/exportpropertieswidget.h +++ b/sources/exportpropertieswidget.h @@ -66,6 +66,7 @@ class ExportPropertiesWidget : public QWidget { QCheckBox *draw_bg_transparent; QCheckBox *draw_colored_conductors; QCheckBox *dxf_blocks; + QCheckBox *dxf_attributes; QRadioButton *export_border; QRadioButton *export_elements; QButtonGroup *exported_content_choices; diff --git a/tests/qttest/fixtures/dxf_blocks.qet b/tests/qttest/fixtures/dxf_blocks.qet index ffd6f9c4f..a1118d8e8 100644 --- a/tests/qttest/fixtures/dxf_blocks.qet +++ b/tests/qttest/fixtures/dxf_blocks.qet @@ -29,13 +29,29 @@ - + + K1 + + + + K1 + label + + - + + K2 + + + + K2 + label + + diff --git a/tests/qttest/tst_dxfblocks.cpp b/tests/qttest/tst_dxfblocks.cpp index bc409c85d..5857f96c9 100644 --- a/tests/qttest/tst_dxfblocks.cpp +++ b/tests/qttest/tst_dxfblocks.cpp @@ -21,8 +21,9 @@ /** --export-dxf --dxf-blocks (issue #1339): a symbol placed twice is one - BLOCK and two INSERTs, the second turned a quarter; without the switch - there is neither. + BLOCK and two INSERTs, the second turned a quarter, and each symbol's + label, with --dxf-attributes, is an attribute of its INSERT; + without the switches there is neither. */ class tst_dxfblocks : public QObject { @@ -82,6 +83,29 @@ private slots: QCOMPARE(values(pairs, QStringLiteral("CIRCLE"), QStringLiteral("8")).size(), 1); } + void labelsAsAttributes() + { + const auto pairs = exportPairs({QStringLiteral("--dxf-attributes")}); + QVERIFY2(!pairs.isEmpty(), "--export-dxf --dxf-attributes failed"); + + QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("2")), + (QStringList{QStringLiteral("LABEL"), QStringLiteral("LABEL")})); + QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("1")), + (QStringList{QStringLiteral("K1"), QStringLiteral("K2")})); + //Defined once in the block, and no longer a loose text + QCOMPARE(values(pairs, QStringLiteral("ATTDEF"), QStringLiteral("2")), + QStringList{QStringLiteral("LABEL")}); + QVERIFY(!values(pairs, QStringLiteral("TEXT"), QStringLiteral("1")).contains(QStringLiteral("K1"))); + } + + void labelsStayTextsWithBlocksOnly() + { + const auto pairs = exportPairs({QStringLiteral("--dxf-blocks")}); + QVERIFY2(!pairs.isEmpty(), "--export-dxf --dxf-blocks failed"); + QVERIFY(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("2")).isEmpty()); + QVERIFY(values(pairs, QStringLiteral("TEXT"), QStringLiteral("1")).contains(QStringLiteral("K1"))); + } + void flatWithoutSwitch() { const auto pairs = exportPairs({}); @@ -89,6 +113,7 @@ private slots: QVERIFY(values(pairs, QStringLiteral("BLOCK"), QStringLiteral("2")).isEmpty()); QVERIFY(values(pairs, QStringLiteral("INSERT"), QStringLiteral("2")).isEmpty()); QCOMPARE(values(pairs, QStringLiteral("CIRCLE"), QStringLiteral("8")).size(), 2); + QVERIFY(values(pairs, QStringLiteral("TEXT"), QStringLiteral("1")).contains(QStringLiteral("K1"))); } }; From aa6abe6e814a9066c1ae4fd84eecefa92f47708c Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 10:04:49 +1300 Subject: [PATCH 15/26] Export a symbol's part data as hidden attributes of its DXF block (#1339) With symbols as blocks, every non-empty information field of a symbol (label, manufacturer, reference, supplier, quantity...) is written as a hidden attribute of its INSERT, tagged with the field's name. A CAD program can then list the parts from the drawing, as with AutoCAD's attribute extraction; hidden, they change nothing on screen, in any reader. A field already written as a visible attribute (with --dxf-attributes) is not repeated. The label formula is left out: it is how the label is made, not part data. Co-Authored-By: Claude Opus 5.5 --- sources/dxfexport.cpp | 44 ++++++++++++++++++++++++++++ tests/qttest/fixtures/dxf_blocks.qet | 1 + tests/qttest/tst_dxfblocks.cpp | 44 ++++++++++++++++++++++------ 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/sources/dxfexport.cpp b/sources/dxfexport.cpp index db46ffd59..397c6d30d 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 "qetinformation.h" #include "textlines.h" #include @@ -436,6 +437,49 @@ void DxfExport::write(Diagram *diagram, int width, int height, } attribute_texts << dti; } + + //The rest of what is known about a symbol (manufacturer, + //reference, supplier, quantity...) as hidden attributes, so a + //CAD program can list the parts from the drawing. A field + //already written as a visible attribute (dxf_attributes) is not + //repeated; the label formula is how the label is made, not + //part data. + for (Element *elmt : std::as_const(list_elements)) { + const QString block = block_names.value(elmt -> location().toString()); + if (block.isEmpty()) + continue; + const DiagramContext information = elmt -> elementInformations(); + const QPointF insert(elmt -> pos().x() * Createdxf::xScale, + Createdxf::sheetHeight - elmt -> pos().y() * Createdxf::yScale); + for (const QString &key : QETInformation::elementInfoKeys()) { + if (key == QETInformation::ELMT_FORMULA) + continue; + const QString value = information.value(key).toString(); + const QString tag = Createdxf::blockName(key); + if (value.isEmpty() || used_tags.value(elmt).contains(tag)) + continue; + used_tags[elmt] << tag; + + Createdxf::Attribute attribute; + attribute.tag = tag; + attribute.text = value; + attribute.x = insert.x(); + attribute.y = insert.y(); + attribute.height = 9 * Createdxf::yScale; + attribute.invisible = true; + attribute.layer = Layer::SymbolTexts; + attributes[elmt] << attribute; + + if (!defined_tags[block].contains(tag)) { + defined_tags[block] << tag; + Createdxf::Attribute definition = attribute; + definition.x = 0; + definition.y = Createdxf::sheetHeight; + definition.text.clear(); + attribute_definitions[block] << definition; + } + } + } } //A block is drawn as if its symbol sat unturned at the folio's diff --git a/tests/qttest/fixtures/dxf_blocks.qet b/tests/qttest/fixtures/dxf_blocks.qet index a1118d8e8..957427f80 100644 --- a/tests/qttest/fixtures/dxf_blocks.qet +++ b/tests/qttest/fixtures/dxf_blocks.qet @@ -31,6 +31,7 @@ K1 + Schneider diff --git a/tests/qttest/tst_dxfblocks.cpp b/tests/qttest/tst_dxfblocks.cpp index 5857f96c9..308088c9c 100644 --- a/tests/qttest/tst_dxfblocks.cpp +++ b/tests/qttest/tst_dxfblocks.cpp @@ -22,8 +22,8 @@ /** --export-dxf --dxf-blocks (issue #1339): a symbol placed twice is one BLOCK and two INSERTs, the second turned a quarter, and each symbol's - label, with --dxf-attributes, is an attribute of its INSERT; - without the switches there is neither. + information is a hidden attribute of it; with --dxf-attributes its + label is a visible one. Without the switches there is neither. */ class tst_dxfblocks : public QObject { @@ -88,13 +88,14 @@ private slots: const auto pairs = exportPairs({QStringLiteral("--dxf-attributes")}); QVERIFY2(!pairs.isEmpty(), "--export-dxf --dxf-attributes failed"); - QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("2")), - (QStringList{QStringLiteral("LABEL"), QStringLiteral("LABEL")})); - QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("1")), - (QStringList{QStringLiteral("K1"), QStringLiteral("K2")})); + QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("2")) + .count(QStringLiteral("LABEL")), 2); + const QStringList texts = values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("1")); + QVERIFY(texts.contains(QStringLiteral("K1"))); + QVERIFY(texts.contains(QStringLiteral("K2"))); //Defined once in the block, and no longer a loose text - QCOMPARE(values(pairs, QStringLiteral("ATTDEF"), QStringLiteral("2")), - QStringList{QStringLiteral("LABEL")}); + QCOMPARE(values(pairs, QStringLiteral("ATTDEF"), QStringLiteral("2")) + .count(QStringLiteral("LABEL")), 1); QVERIFY(!values(pairs, QStringLiteral("TEXT"), QStringLiteral("1")).contains(QStringLiteral("K1"))); } @@ -102,10 +103,35 @@ private slots: { const auto pairs = exportPairs({QStringLiteral("--dxf-blocks")}); QVERIFY2(!pairs.isEmpty(), "--export-dxf --dxf-blocks failed"); - QVERIFY(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("2")).isEmpty()); + //Only hidden attributes (70 = 1); the labels are texts + QVERIFY(!values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("70")).contains(QStringLiteral("0"))); QVERIFY(values(pairs, QStringLiteral("TEXT"), QStringLiteral("1")).contains(QStringLiteral("K1"))); } + void dataAsHiddenAttributes() + { + //The label is a text, so it is part data too: K1's label and + //manufacturer, K2's label, all hidden (70 = 1) + auto pairs = exportPairs({QStringLiteral("--dxf-blocks")}); + QVERIFY2(!pairs.isEmpty(), "--export-dxf --dxf-blocks failed"); + QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("2")), + (QStringList{QStringLiteral("LABEL"), QStringLiteral("MANUFACTURER"), + QStringLiteral("LABEL")})); + QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("70")), + (QStringList{QStringLiteral("1"), QStringLiteral("1"), QStringLiteral("1")})); + QVERIFY(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("1")) + .contains(QStringLiteral("Schneider"))); + + //With the labels as attributes, the label is not repeated + pairs = exportPairs({QStringLiteral("--dxf-attributes")}); + QVERIFY2(!pairs.isEmpty(), "--export-dxf --dxf-attributes failed"); + QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("2")), + (QStringList{QStringLiteral("LABEL"), QStringLiteral("MANUFACTURER"), + QStringLiteral("LABEL")})); + QCOMPARE(values(pairs, QStringLiteral("ATTRIB"), QStringLiteral("70")), + (QStringList{QStringLiteral("0"), QStringLiteral("1"), QStringLiteral("0")})); + } + void flatWithoutSwitch() { const auto pairs = exportPairs({}); From b9d459b0e6935e412f8c69a80fc392235098af25 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 12:49:37 +1300 Subject: [PATCH 16/26] Fix composite text dialog not inserting variables under Qt 6 (#1348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slot was auto-connected by name to QComboBox::activated(QString), which Qt 6 removed, so connectSlotsByName() found no signal and picking a variable in the "Texte composé" dialog did nothing. activated(int) exists in Qt 5 and Qt 6; the slot never used its argument and reads currentData() as before. Same fix as the CompositeTextEditDialog half of #674, which was closed unmerged. Co-Authored-By: Claude Opus 5.5 --- sources/ui/compositetexteditdialog.cpp | 4 ++-- sources/ui/compositetexteditdialog.h | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sources/ui/compositetexteditdialog.cpp b/sources/ui/compositetexteditdialog.cpp index ac4e9c89b..ab98e55cd 100644 --- a/sources/ui/compositetexteditdialog.cpp +++ b/sources/ui/compositetexteditdialog.cpp @@ -78,9 +78,9 @@ void CompositeTextEditDialog::setUpComboBox(bool is_report) } } -void CompositeTextEditDialog::on_m_info_cb_activated(const QString &arg1) +void CompositeTextEditDialog::on_m_info_cb_activated(int index) { - Q_UNUSED(arg1) + Q_UNUSED(index) ui->m_plain_text_edit->insertPlainText(ui->m_info_cb->currentData().toString()); } diff --git a/sources/ui/compositetexteditdialog.h b/sources/ui/compositetexteditdialog.h index 31d0e0d65..a3de0e3f8 100644 --- a/sources/ui/compositetexteditdialog.h +++ b/sources/ui/compositetexteditdialog.h @@ -28,7 +28,7 @@ class CompositeTextEditDialog : public QDialog QString plainText() const; private slots: - void on_m_info_cb_activated(const QString &arg1); + void on_m_info_cb_activated(int index); protected: void focusInEvent(QFocusEvent *event) override; From 2e4982e8716f2643e4e18bd3527781b2f613af84 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Wed, 7 Oct 2026 01:50:05 +0200 Subject: [PATCH 17/26] Drop a check that cannot trigger in the element editor A dynamic text field is never edited inline in the element editor (it never sets text interaction flags), so the handles never had to hide for it. Co-Authored-By: Claude Fable 5.1 --- sources/editor/elementscene.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/sources/editor/elementscene.cpp b/sources/editor/elementscene.cpp index b08892cb0..2d5d035a7 100644 --- a/sources/editor/elementscene.cpp +++ b/sources/editor/elementscene.cpp @@ -1506,7 +1506,7 @@ void ElementScene::managePrimitivesGroups() /** @brief ElementScene::manageTextResizeHandles Show the corner handles that change the width of a text field when it - is the only selected item, and it is not being typed in. + is the only selected item. Called again when the undo stack changes, including from the push of a resize itself: the handles of the same text are then kept, not deleted while they are still emitting. @@ -1520,9 +1520,6 @@ void ElementScene::manageTextResizeHandles(const QList &selecte { text = static_cast(selected_items.first()); } - if (text && (text->textInteractionFlags() & Qt::TextEditable)) - text = nullptr; - if (m_text_resize_handles && m_text_resize_handles->parentItem() == text) { m_text_resize_handles->updateHandlesPos(); return; From 2c9900eca8d203d8201c5d0eca1fcea40da85c1b Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Wed, 7 Oct 2026 01:51:00 +0200 Subject: [PATCH 18/26] Make 0 the automatic width in the free text width box The box showed Auto for -1 while IndependentTextItem::setTextWidth() treats every value up to 0 as automatic. Typing 0, or the first click on the up arrow from Auto, then put a width change in the undo history that changed nothing, left the box on 0, and in live edit every later change of X, Y, angle, text or size was replaced by that empty command. Auto is 0 in the box now, like in the setter: no number is the automatic width, and the first step up is 1 px. Co-Authored-By: Claude Fable 5.1 --- sources/ui/inditextpropertieswidget.cpp | 8 +++++--- sources/ui/inditextpropertieswidget.ui | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/sources/ui/inditextpropertieswidget.cpp b/sources/ui/inditextpropertieswidget.cpp index bae484752..afb2e0157 100644 --- a/sources/ui/inditextpropertieswidget.cpp +++ b/sources/ui/inditextpropertieswidget.cpp @@ -28,12 +28,14 @@ #include /** - @return width as shown in the width spin box: whole pixels, -1 for - the automatic width ("Auto"). + @return width as shown in the width spin box: whole pixels, 0 for the + automatic width ("Auto"). The box starts at 0, so no number means Auto + and the first step up is 1 px; IndependentTextItem::setTextWidth() + treats 0 as -1, the automatic width, so the two never disagree. */ static int spinBoxWidth(qreal width) { - return width > 0 ? qRound(width) : -1; + return width > 0 ? qRound(width) : 0; } /** diff --git a/sources/ui/inditextpropertieswidget.ui b/sources/ui/inditextpropertieswidget.ui index 6bdb94422..1572b1a34 100644 --- a/sources/ui/inditextpropertieswidget.ui +++ b/sources/ui/inditextpropertieswidget.ui @@ -148,13 +148,13 @@ px - -1 + 0 10000 - -1 + 0 From 9882a79a9322c3ade7668200b2bf3586aa4d7eb4 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Wed, 7 Oct 2026 01:51:00 +0200 Subject: [PATCH 19/26] Refresh the free text handles when the text comes back to a folio itemChange() removed the corner handles when the text left the scene but did not bring them back when it returned still selected, which Qt allows (undoing a scripted delete does it) without a selection change. Refresh on every scene change. The two handle helpers are protected: nothing outside the class calls them. Co-Authored-By: Claude Fable 5.1 --- sources/qetgraphicsitem/diagramtextitem.cpp | 10 ++++++---- sources/qetgraphicsitem/diagramtextitem.h | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/sources/qetgraphicsitem/diagramtextitem.cpp b/sources/qetgraphicsitem/diagramtextitem.cpp index 01e4829d9..08fdfafb8 100644 --- a/sources/qetgraphicsitem/diagramtextitem.cpp +++ b/sources/qetgraphicsitem/diagramtextitem.cpp @@ -365,17 +365,19 @@ void DiagramTextItem::focusOutEvent(QFocusEvent *event) /** @brief DiagramTextItem::itemChange - Show or hide the resize handles with the selection. + Show or hide the resize handles with the selection and the scene. @param change @param value @return */ QVariant DiagramTextItem::itemChange(GraphicsItemChange change, const QVariant &value) { - if (change == QGraphicsItem::ItemSelectedHasChanged) + //Also when the text comes back to a scene: Qt keeps it selected + //across removeItem()/addItem() (undoing a delete) and sends no + //selection change, and its handles were removed with the scene. + if (change == QGraphicsItem::ItemSelectedHasChanged + || change == QGraphicsItem::ItemSceneHasChanged) refreshTextResizeHandles(); - else if (change == QGraphicsItem::ItemSceneHasChanged && !scene()) - removeTextResizeHandles(); return QGraphicsTextItem::itemChange(change, value); } diff --git a/sources/qetgraphicsitem/diagramtextitem.h b/sources/qetgraphicsitem/diagramtextitem.h index e43f1f1de..3a5d5ca4d 100644 --- a/sources/qetgraphicsitem/diagramtextitem.h +++ b/sources/qetgraphicsitem/diagramtextitem.h @@ -91,10 +91,10 @@ class DiagramTextItem : public QGraphicsTextItem void setPlainText(const QString &text); bool isHtml() const; + protected: void refreshTextResizeHandles(); TextResizeHandles *textResizeHandles() const {return m_resize_handles;} - protected: void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override; From 1fdb0ee32e625499951905ed998c9af4273b5530 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Wed, 7 Oct 2026 01:51:00 +0200 Subject: [PATCH 20/26] Skip tst_unreadableelement on Windows only The skip applied wherever setPermissions() failed, which on Linux and macOS would hide a real failure. Only Windows cannot take read permission away; elsewhere the call must succeed as before. Co-Authored-By: Claude Fable 5.1 --- tests/qttest/tst_unreadableelement.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/qttest/tst_unreadableelement.cpp b/tests/qttest/tst_unreadableelement.cpp index 607ac210b..2c0af7031 100644 --- a/tests/qttest/tst_unreadableelement.cpp +++ b/tests/qttest/tst_unreadableelement.cpp @@ -89,11 +89,13 @@ private slots: .contains(QStringLiteral("RESULT placed"))); QVERIFY(QFile::exists(embedded)); +#ifdef Q_OS_WIN // Qt cannot take read permission away on Windows: it only knows the // read-only flag there, so this returns false. The check needs a - // file the user cannot read, which this system cannot give it. - if (!QFile::setPermissions(m_symbol, QFileDevice::Permissions())) - QSKIP("this system cannot make the file unreadable (Windows?)"); + // file the user cannot read, which Windows cannot give it. + QSKIP("Windows cannot make the file unreadable through QFile"); +#endif + QVERIFY(QFile::setPermissions(m_symbol, QFileDevice::Permissions())); QFile probe(m_symbol); if (probe.open(QIODevice::ReadOnly)) QSKIP("permissions do not stop this user reading the file (root?)"); From e50e6a492e0a9fbd04336e2e79241f958128e1e8 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Wed, 7 Oct 2026 01:51:00 +0200 Subject: [PATCH 21/26] Put stdout of a headless run into binary mode on Windows too stderr got it in the previous commit; stdout (the JSON of --info, the messages of the exports) still ended its lines in "\r\n" on Windows while stderr ended them in "\n". Both streams now give the same bytes on every system. Co-Authored-By: Claude Fable 5.1 --- sources/main.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sources/main.cpp b/sources/main.cpp index 827c38329..09aab1f21 100644 --- a/sources/main.cpp +++ b/sources/main.cpp @@ -153,10 +153,12 @@ static void headlessMessageHandler(QtMsgType type, static void installHeadlessMessageHandler() { #ifdef Q_OS_WIN - // stderr is in text mode on Windows and turns "\n" into "\r\n": a - // caller would read lines ending in '\r' where the other systems give - // none, and what the script logs with qet.log() already ends in "\n" - // alone. Binary mode gives one line ending for both. + // stdout and stderr are in text mode on Windows and turn "\n" into + // "\r\n": a caller would read lines ending in '\r' where the other + // systems give none. Binary mode gives both streams the same line + // ending on every system, for the JSON of --info, the messages of the + // exports and what a script logs. + _setmode(_fileno(stdout), _O_BINARY); _setmode(_fileno(stderr), _O_BINARY); #endif qInstallMessageHandler(headlessMessageHandler); From 2752e6705c3e5901ca63f42d975ee6ef9ead5b10 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 17:36:22 +1300 Subject: [PATCH 22/26] Folio properties: paper size presets A0-A5, landscape or portrait A "Format" choice in the folio dimensions box sets the columns and rows so the whole folio, title block included, has the size of an ISO A sheet at the 96 dpi the PDF export uses. A4 landscape keeps 60 px columns and 80 px rows and comes out as an A4 page in the PDF. Nothing new is stored: the choice only fills in the columns and rows. A folio whose size already matches a paper shows that paper; any other shows "Custom". Changing the title block template after picking a paper fits the folio again; a template too tall for the paper falls back to "Custom". The same box is in Folio properties and in the new-folio defaults of the project and application settings. Co-Authored-By: Claude Opus 5.5 --- sources/ui/borderpropertieswidget.cpp | 224 ++++++++++++++++++++++ sources/ui/borderpropertieswidget.h | 18 +- sources/ui/borderpropertieswidget.ui | 19 ++ sources/ui/configpage/configpages.cpp | 4 + sources/ui/diagrampropertiesdialog.cpp | 4 + sources/ui/titleblockpropertieswidget.cpp | 33 ++++ sources/ui/titleblockpropertieswidget.h | 3 + 7 files changed, 303 insertions(+), 2 deletions(-) diff --git a/sources/ui/borderpropertieswidget.cpp b/sources/ui/borderpropertieswidget.cpp index f59ddc9e6..7c29e1182 100644 --- a/sources/ui/borderpropertieswidget.cpp +++ b/sources/ui/borderpropertieswidget.cpp @@ -18,8 +18,85 @@ #include "borderpropertieswidget.h" #include "../diagram.h" +#include "../qetapp.h" +#include "../titleblocktemplate.h" #include "ui_borderpropertieswidget.h" +#include +#include + +namespace { + /// ISO 216 A sizes in mm, landscape + struct PaperSize { const char *name; int width_mm; int height_mm; }; + const PaperSize paper_sizes[] = { + {"A0", 1189, 841}, + {"A1", 841, 594}, + {"A2", 594, 420}, + {"A3", 420, 297}, + {"A4", 297, 210}, + {"A5", 210, 148} + }; + + /// The PDF export draws one scene unit as one pixel at 96 dpi + int mmToPx(int mm) { return qRound(mm * 96.0 / 25.4); } + + /// The PDF export rounds a page within 3 pt of a paper size to that + /// size, and 4 px left over already rounds to 3.0 pt and misses + const int exact_fit = 3; + + /// Smallest column width or row height a paper size may give + const int min_cell = 10; + + /** + @brief fitCells + Split @p available pixels into cells close to @p current pixels. + First choice: cells within 15 %, then 30 %, of the current size + that leave at most exact_fit pixels, the closest to the current + size. Failing that, cells within 15 % that leave the fewest pixels. + @return the number of cells and their size + */ + QPair fitCells(int available, int current, int min_size, int max_count) + { + current = qMax(current, min_size); + auto size_for = [&](int count, double spread) { + const int size = available / count; + return (size < min_size || qAbs(size - current) > current * spread) + ? 0 : size; + }; + + for (double spread : {0.15, 0.30}) + { + QPair best(0, 0); + for (int count = 1 ; count <= max_count ; ++count) + { + const int size = size_for(count, spread); + if (size && available - count * size <= exact_fit && + (!best.first || qAbs(size - current) < qAbs(best.second - current))) + best = qMakePair(count, size); + } + if (best.first) return best; + } + + QPair best(0, 0); + std::tuple best_score(available, available); + for (int count = 1 ; count <= max_count ; ++count) + { + const int size = size_for(count, 0.15); + const std::tuple score(available - count * size, + qAbs(size - current)); + if (size && score < best_score) { + best_score = score; + best = qMakePair(count, size); + } + } + if (best.first) return best; + + //No size near the current one when the cell count is capped + const int count = qBound(1, qRound(double(available) / current), max_count); + return qMakePair(count, available / count); + } +} + /** @brief BorderPropertiesWidget::BorderPropertiesWidget default constructor @@ -31,6 +108,30 @@ BorderPropertiesWidget::BorderPropertiesWidget(const BorderProperties &bp, QWidg ui(new Ui::BorderPropertiesWidget) { ui->setupUi(this); + + ui->m_paper_cb->addItem(tr("Personnalisé")); + for (const PaperSize &paper : paper_sizes) + ui->m_paper_cb->addItem(QString::fromLatin1(paper.name)); + ui->m_orientation_cb->addItem(tr("Paysage")); + ui->m_orientation_cb->addItem(tr("Portrait")); + + if (TitleBlockTemplate *tbt = QETApp::defaultTitleBlockTemplate()) + m_titleblock_height = tbt->height(); + + //activated() is only emitted for a choice the user makes + auto paper_picked = [this]() { + m_paper_picked = ui->m_paper_cb->currentIndex() > 0; + applyPaperSize(); + }; + connect(ui->m_paper_cb, qOverload(&QComboBox::activated), + this, paper_picked); + connect(ui->m_orientation_cb, qOverload(&QComboBox::activated), + this, paper_picked); + for (QSpinBox *sb : {ui->m_colums_count_sp, ui->m_columns_width_sp, + ui->m_rows_count_sp, ui->m_rows_height_sp}) + connect(sb, qOverload(&QSpinBox::valueChanged), + this, &BorderPropertiesWidget::showMatchingPaperSize); + setProperties(bp); } @@ -57,6 +158,7 @@ void BorderPropertiesWidget::setProperties(const BorderProperties &bp) ui -> m_rows_count_sp ->setValue (m_properties.rows_count); ui -> m_rows_height_sp ->setValue (m_properties.rows_height); ui -> m_display_rows_cb ->setChecked (m_properties.display_rows); + showMatchingPaperSize(); } /** @@ -83,3 +185,125 @@ void BorderPropertiesWidget::setReadOnly(const bool &ro) { ui->border_gb->setDisabled(ro); } + +/** + @brief BorderPropertiesWidget::setTitleBlockSize + Tell the widget the title block the folio uses: its height counts in + the size of the page. If the user picked a paper size in this widget, + the columns and rows are fitted again so the folio keeps that size; + otherwise they are left alone. + @param height : height of the title block template + @param edge : edge of the folio the title block sits on +*/ +void BorderPropertiesWidget::setTitleBlockSize(int height, Qt::Edge edge) +{ + m_titleblock_height = height; + m_titleblock_edge = edge; + if (m_paper_picked) + applyPaperSize(); + else + showMatchingPaperSize(); +} + +/** + @brief BorderPropertiesWidget::applyPaperSize + Set the columns and rows so the folio, title block included, has the + size of the chosen paper. The column width and row height stay close + to the current ones. +*/ +void BorderPropertiesWidget::applyPaperSize() +{ + const int index = ui->m_paper_cb->currentIndex() - 1; + ui->m_orientation_cb->setEnabled(index >= 0); + if (index < 0) return; + + int columns, column_width, rows, row_height; + if (!fitPaperSize(index, ui->m_orientation_cb->currentIndex() == 0, + columns, column_width, rows, row_height)) + { + //The title block leaves no room on this paper + showMatchingPaperSize(); + return; + } + + m_updating = true; + ui->m_colums_count_sp->setValue(columns); + ui->m_columns_width_sp->setValue(column_width); + ui->m_rows_count_sp->setValue(rows); + ui->m_rows_height_sp->setValue(row_height); + m_updating = false; +} + +/** + @brief BorderPropertiesWidget::fitPaperSize + Compute the columns and rows that give the folio, title block + included, the size of a paper, with cells close to the current ones. + @param index : index of the paper in paper_sizes + @param landscape : true for landscape, false for portrait + @return false if the paper is too small for the title block +*/ +bool BorderPropertiesWidget::fitPaperSize(int index, bool landscape, + int &columns, int &column_width, + int &rows, int &row_height) const +{ + const PaperSize &paper = paper_sizes[index]; + const int page_width = mmToPx(landscape ? paper.width_mm : paper.height_mm); + const int page_height = mmToPx(landscape ? paper.height_mm : paper.width_mm); + + //The page is one pixel larger than the border, for its line + int width = page_width - 1 - qRound(m_properties.rows_header_width); + int height = page_height - 1 - qRound(m_properties.columns_header_height); + if (m_titleblock_edge == Qt::BottomEdge) + height -= m_titleblock_height; + else + width -= m_titleblock_height; + if (width < min_cell || height < min_cell) return false; + + const QPair c = fitCells(width, + ui->m_columns_width_sp->value(), + min_cell, + ui->m_colums_count_sp->maximum()); + const QPair r = fitCells(height, + ui->m_rows_height_sp->value(), + min_cell, + ui->m_rows_count_sp->maximum()); + columns = c.first; + column_width = c.second; + rows = r.first; + row_height = r.second; + return true; +} + +/** + @brief BorderPropertiesWidget::showMatchingPaperSize + Show the paper size the folio has, or "Custom" if it has none: the + folio has a paper size when choosing it would change nothing. +*/ +void BorderPropertiesWidget::showMatchingPaperSize() +{ + if (m_updating) return; + m_paper_picked = false; + + int found = 0; + int orientation = ui->m_orientation_cb->currentIndex(); + for (int i = 0 ; i < int(std::size(paper_sizes)) && !found ; ++i) + { + for (int o = 0 ; o < 2 ; ++o) + { + int columns, column_width, rows, row_height; + if (fitPaperSize(i, o == 0, columns, column_width, rows, row_height) && + columns == ui->m_colums_count_sp->value() && + column_width == ui->m_columns_width_sp->value() && + rows == ui->m_rows_count_sp->value() && + row_height == ui->m_rows_height_sp->value()) + { + found = i + 1; + orientation = o; + break; + } + } + } + ui->m_paper_cb->setCurrentIndex(found); + ui->m_orientation_cb->setCurrentIndex(orientation); + ui->m_orientation_cb->setEnabled(found > 0); +} diff --git a/sources/ui/borderpropertieswidget.h b/sources/ui/borderpropertieswidget.h index 01669c761..1042e23c4 100644 --- a/sources/ui/borderpropertieswidget.h +++ b/sources/ui/borderpropertieswidget.h @@ -41,12 +41,26 @@ class BorderPropertiesWidget : public QWidget void setProperties(const BorderProperties &bp); const BorderProperties &properties(); void setReadOnly (const bool &ro); - - private slots: + void setTitleBlockSize(int height, Qt::Edge edge); private: + void applyPaperSize(); + void showMatchingPaperSize(); + bool fitPaperSize(int index, bool landscape, + int &columns, int &column_width, + int &rows, int &row_height) const; + Ui::BorderPropertiesWidget *ui; BorderProperties m_properties; + /// height of the title block the folio is drawn with, and + /// the edge it sits on: it counts in the size of the page + int m_titleblock_height = 0; + Qt::Edge m_titleblock_edge = Qt::BottomEdge; + /// true while the widget itself changes the spin boxes + bool m_updating = false; + /// true once the user picked a paper size, until the columns or + /// rows are edited by hand + bool m_paper_picked = false; }; #endif // BORDERPROPERTIESWIDGET_H diff --git a/sources/ui/borderpropertieswidget.ui b/sources/ui/borderpropertieswidget.ui index 7fcee9bfd..63021ab12 100644 --- a/sources/ui/borderpropertieswidget.ui +++ b/sources/ui/borderpropertieswidget.ui @@ -80,7 +80,24 @@ + + + + Format : + + + + + + Règle les colonnes et les lignes pour que le folio, cartouche compris, ait la taille de ce format de papier, à 96 points par pouce comme l'export PDF. + + + + + + + Qt::Vertical @@ -105,6 +122,8 @@ m_rows_height_sp m_display_columns_cb m_display_rows_cb + m_paper_cb + m_orientation_cb diff --git a/sources/ui/configpage/configpages.cpp b/sources/ui/configpage/configpages.cpp index b9ac8562c..fb3ffc687 100644 --- a/sources/ui/configpage/configpages.cpp +++ b/sources/ui/configpage/configpages.cpp @@ -139,6 +139,10 @@ NewDiagramPage::NewDiagramPage(QETProject *project, } connect(ipw, &TitleBlockPropertiesWidget::openAutoNumFolioEditor, this, &NewDiagramPage::changeToAutoFolioTab); + bpw -> setTitleBlockSize(ipw -> currentTitleBlockHeight(), + ipw -> currentTitleBlockEdge()); + connect(ipw, &TitleBlockPropertiesWidget::titleBlockSizeChanged, + bpw, &BorderPropertiesWidget::setTitleBlockSize); // main tab widget QTabWidget *tab_widget = new QTabWidget(this); diff --git a/sources/ui/diagrampropertiesdialog.cpp b/sources/ui/diagrampropertiesdialog.cpp index cc3046b9c..15386bbfe 100644 --- a/sources/ui/diagrampropertiesdialog.cpp +++ b/sources/ui/diagrampropertiesdialog.cpp @@ -65,6 +65,10 @@ DiagramPropertiesDialog::DiagramPropertiesDialog(Diagram *diagram, QWidget *pare titleblock_infos -> setReadOnly(diagram_is_read_only); connect(titleblock_infos, &TitleBlockPropertiesWidget::openAutoNumFolioEditor, this, &DiagramPropertiesDialog::editAutoFolioNum); + border_infos -> setTitleBlockSize(titleblock_infos -> currentTitleBlockHeight(), + titleblock_infos -> currentTitleBlockEdge()); + connect(titleblock_infos, &TitleBlockPropertiesWidget::titleBlockSizeChanged, + border_infos, &BorderPropertiesWidget::setTitleBlockSize); //titleblock_infos->setMinimumSize(590,480); //Minimum Size needed for correct display //Conductor widget diff --git a/sources/ui/titleblockpropertieswidget.cpp b/sources/ui/titleblockpropertieswidget.cpp index 88bdd4842..7b83c9155 100644 --- a/sources/ui/titleblockpropertieswidget.cpp +++ b/sources/ui/titleblockpropertieswidget.cpp @@ -336,6 +336,30 @@ QString TitleBlockPropertiesWidget::currentTitleBlockTemplateName() const return QString(); } +/** + @brief TitleBlockPropertiesWidget::currentTitleBlockHeight + @return the height of the current title block template +*/ +int TitleBlockPropertiesWidget::currentTitleBlockHeight() const +{ + const TitleBlockTemplate *tbt = nullptr; + if (!currentTitleBlockTemplateName().isEmpty()) + tbt = templateForIndex(ui -> m_tbt_cb -> currentIndex()); + if (!tbt) + tbt = QETApp::defaultTitleBlockTemplate(); + return tbt ? tbt -> height() : 0; +} + +/** + @brief TitleBlockPropertiesWidget::currentTitleBlockEdge + @return the edge of the folio the title block is displayed at +*/ +Qt::Edge TitleBlockPropertiesWidget::currentTitleBlockEdge() const +{ + return ui -> m_display_at_cb -> currentIndex() == 0 ? Qt::BottomEdge + : Qt::RightEdge; +} + /** @brief TitleBlockPropertiesWidget::addCollection add a collection of title block available in the combo box @@ -388,6 +412,15 @@ void TitleBlockPropertiesWidget::initDialog( this, &TitleBlockPropertiesWidget::changeCurrentTitleBlockTemplate); + auto size_changed = [this]() { + emit titleBlockSizeChanged(currentTitleBlockHeight(), + currentTitleBlockEdge()); + }; + connect(ui->m_tbt_cb, qOverload(&QComboBox::currentIndexChanged), + this, size_changed); + connect(ui->m_display_at_cb, qOverload(&QComboBox::currentIndexChanged), + this, size_changed); + if (project!= nullptr){ keys_2 = project -> folioAutoNum().keys(); foreach (QString str, keys_2) { ui -> auto_page_cb -> addItem(str); } diff --git a/sources/ui/titleblockpropertieswidget.h b/sources/ui/titleblockpropertieswidget.h index 127f04c9c..4804f9837 100644 --- a/sources/ui/titleblockpropertieswidget.h +++ b/sources/ui/titleblockpropertieswidget.h @@ -70,6 +70,8 @@ class TitleBlockPropertiesWidget : public QWidget void setTitleBlockTemplatesVisible(const bool &visible); void setReadOnly (const bool &ro); + int currentTitleBlockHeight() const; + Qt::Edge currentTitleBlockEdge() const; static void addTemplateVariables( DiagramContext &context, @@ -96,6 +98,7 @@ class TitleBlockPropertiesWidget : public QWidget signals: void set_auto_page_num() const; void openAutoNumFolioEditor (QString); + void titleBlockSizeChanged(int height, Qt::Edge edge); private: Ui::TitleBlockPropertiesWidget *ui; From 5f21d4fce26bd461b6cb0f123295655958d0daa3 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 18:07:03 +1300 Subject: [PATCH 23/26] Paper size presets: never fit fewer than 3 columns or rows fitCells() tried counts from 1, but BorderTitleBlock raises any column or row count below 3 to 3. With very large cells (A5 portrait with columns of about 200 px or more) the preset picked 2 columns; the folio then got 3, came out wider than the sheet, and the dropdown fell back to "Custom". Start the search, and the capped fallback, at 3, and refuse a paper that leaves no room for 3 cells of the smallest size. A5 portrait with 300 px columns now gives 3 x 179 px instead of 2 x 269 px; A4 landscape with the default cells is unchanged (19 x 58 px). ctest 65/65. Co-Authored-By: Claude Opus 5.5 --- sources/ui/borderpropertieswidget.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/sources/ui/borderpropertieswidget.cpp b/sources/ui/borderpropertieswidget.cpp index 7c29e1182..944503404 100644 --- a/sources/ui/borderpropertieswidget.cpp +++ b/sources/ui/borderpropertieswidget.cpp @@ -47,6 +47,10 @@ namespace { /// Smallest column width or row height a paper size may give const int min_cell = 10; + /// Fewest columns or rows a folio may have: BorderTitleBlock raises + /// any lower count to 3 (MIN_COLUMN_COUNT, MIN_ROW_COUNT) + const int min_count = 3; + /** @brief fitCells Split @p available pixels into cells close to @p current pixels. @@ -67,7 +71,7 @@ namespace { for (double spread : {0.15, 0.30}) { QPair best(0, 0); - for (int count = 1 ; count <= max_count ; ++count) + for (int count = min_count ; count <= max_count ; ++count) { const int size = size_for(count, spread); if (size && available - count * size <= exact_fit && @@ -79,7 +83,7 @@ namespace { QPair best(0, 0); std::tuple best_score(available, available); - for (int count = 1 ; count <= max_count ; ++count) + for (int count = min_count ; count <= max_count ; ++count) { const int size = size_for(count, 0.15); const std::tuple score(available - count * size, @@ -92,7 +96,7 @@ namespace { if (best.first) return best; //No size near the current one when the cell count is capped - const int count = qBound(1, qRound(double(available) / current), max_count); + const int count = qBound(min_count, qRound(double(available) / current), max_count); return qMakePair(count, available / count); } } @@ -257,7 +261,8 @@ bool BorderPropertiesWidget::fitPaperSize(int index, bool landscape, height -= m_titleblock_height; else width -= m_titleblock_height; - if (width < min_cell || height < min_cell) return false; + if (width < min_cell * min_count || height < min_cell * min_count) + return false; const QPair c = fitCells(width, ui->m_columns_width_sp->value(), From 3a61513f29d2cd97bad57a78229919d6046a7a45 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 18:34:51 +1300 Subject: [PATCH 24/26] Let the user choose what each folio editor toolbar holds Configure QElectroTech > Toolbar contents: pick a toolbar, drag or add commands from the list on the left, remove them with Delete, reorder, add separators, and add, rename or delete toolbars of one's own. Reset to defaults gives a toolbar its original contents back. Each toolbar is stored as a list of command ids in QSettings (diagrameditor/toolbars/), the same pattern the shortcut bar uses. A toolbar the user never changed stores nothing and is built exactly as before; an id no command carries any more is skipped. The four buttons that are widgets rather than commands (handle size, text grid, background colour, conductor colour) get ids of their own and can each be on one toolbar at a time. Stored scripts can go on any toolbar. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 4 + sources/diagramtoolbarsettings.cpp | 352 +++++++++++++++ sources/diagramtoolbarsettings.h | 79 ++++ sources/qetapp.cpp | 2 + sources/qetdiagrameditor.cpp | 114 +++-- sources/qetdiagrameditor.h | 5 + .../configpage/toolbarcommandsconfigpage.cpp | 423 ++++++++++++++++++ .../ui/configpage/toolbarcommandsconfigpage.h | 78 ++++ tests/qttest/CMakeLists.txt | 20 + tests/qttest/tst_diagramtoolbarsettings.cpp | 245 ++++++++++ 10 files changed, 1283 insertions(+), 39 deletions(-) create mode 100644 sources/diagramtoolbarsettings.cpp create mode 100644 sources/diagramtoolbarsettings.h create mode 100644 sources/ui/configpage/toolbarcommandsconfigpage.cpp create mode 100644 sources/ui/configpage/toolbarcommandsconfigpage.h create mode 100644 tests/qttest/tst_diagramtoolbarsettings.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 334d7e715..7a6e99362 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -301,6 +301,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/shortcutmanager.h ${QET_DIR}/sources/shortcutbarsettings.cpp ${QET_DIR}/sources/shortcutbarsettings.h + ${QET_DIR}/sources/diagramtoolbarsettings.cpp + ${QET_DIR}/sources/diagramtoolbarsettings.h ${QET_DIR}/sources/toolbarsettings.cpp ${QET_DIR}/sources/toolbarsettings.h ${QET_DIR}/sources/diagramcontexttoolbar.cpp @@ -858,6 +860,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.h ${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.cpp ${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.h + ${QET_DIR}/sources/ui/configpage/toolbarcommandsconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/toolbarcommandsconfigpage.h ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.cpp ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.h diff --git a/sources/diagramtoolbarsettings.cpp b/sources/diagramtoolbarsettings.cpp new file mode 100644 index 000000000..df95b01ff --- /dev/null +++ b/sources/diagramtoolbarsettings.cpp @@ -0,0 +1,352 @@ +/* + 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 "diagramtoolbarsettings.h" + +#include "shortcutmanager.h" + +#include +#include +#include +#include + +namespace { + const QString CONTENTS = QStringLiteral("diagrameditor/toolbars/"); + const QString CUSTOM_NAMES = QStringLiteral("diagrameditor/custom_toolbars/names"); + const QString CUSTOM_TITLES = QStringLiteral("diagrameditor/custom_toolbars/titles"); + //Set on a window by markWindow() + const char *const MARKED = "qetDiagramToolbars"; +} + +/** + @return every toolbar, built-in first, then the user's own in the order + they were added +*/ +QList DiagramToolbarSettings::toolbars() +{ + QList list; + for (const QString &name : builtInNames()) { + list << Toolbar{name, builtInTitle(name), false}; + } + return list << customToolbars(); +} + +/** + @return the object names of the toolbars the diagram editor always has. + The scripts toolbar is not one: it is filled from the scripts folder. +*/ +QStringList DiagramToolbarSettings::builtInNames() +{ + return {QStringLiteral("toolbar"), + QStringLiteral("display"), + QStringLiteral("diagram"), + QStringLiteral("adding"), + QStringLiteral("diagram_depth_toolbar")}; +} + +QString DiagramToolbarSettings::builtInTitle(const QString &name) +{ + if (name == QLatin1String("toolbar")) + return QCoreApplication::translate("QETDiagramEditor", "Outils"); + if (name == QLatin1String("display")) + return QCoreApplication::translate("QETDiagramEditor", "Affichage"); + if (name == QLatin1String("diagram")) + return QCoreApplication::translate("QETDiagramEditor", "Schéma"); + if (name == QLatin1String("adding")) + return QCoreApplication::translate("QETDiagramEditor", "Ajouter"); + if (name == QLatin1String("diagram_depth_toolbar")) + return QCoreApplication::translate("QETDiagramEditor", "Profondeur", "toolbar title"); + return QString(); +} + +/** + @return the toolbars the user added, in the order they were added +*/ +QList DiagramToolbarSettings::customToolbars() +{ + QSettings settings; + const QStringList names = settings.value(CUSTOM_NAMES).toStringList(); + const QStringList titles = settings.value(CUSTOM_TITLES).toStringList(); + QList list; + for (int i = 0 ; i < names.size() ; ++i) { + list << Toolbar{names.at(i), titles.value(i, names.at(i)), true}; + } + return list; +} + +/** + @brief DiagramToolbarSettings::setCustomToolbars + Store the user's own toolbars. The contents of one that is no longer + in @a toolbars are removed with it. +*/ +void DiagramToolbarSettings::setCustomToolbars(const QList &toolbars) +{ + QStringList names, titles; + for (const Toolbar &toolbar : toolbars) { + names << toolbar.name; + titles << toolbar.title; + } + for (const Toolbar &old : customToolbars()) { + if (!names.contains(old.name)) { + removeIds(old.name); + } + } + QSettings settings; + if (names.isEmpty()) { + settings.remove(CUSTOM_NAMES); + settings.remove(CUSTOM_TITLES); + } else { + settings.setValue(CUSTOM_NAMES, names); + settings.setValue(CUSTOM_TITLES, titles); + } +} + +/** + @return an object name for a new toolbar that none of @a existing has + and none had before (stored positions stay with the toolbar they + belonged to) +*/ +QString DiagramToolbarSettings::newCustomName(const QList &existing) +{ + QStringList taken; + for (const Toolbar &toolbar : existing) taken << toolbar.name; + for (const Toolbar &toolbar : customToolbars()) taken << toolbar.name; + for (int i = 1 ; ; ++i) { + const QString name = QStringLiteral("custom_%1").arg(i); + if (!taken.contains(name)) return name; + } +} + +/** + @return the ids toolbar @a name holds: the user's list if they saved + one, the defaults otherwise. A saved empty list stays empty. +*/ +QStringList DiagramToolbarSettings::ids(const QString &name) +{ + QSettings settings; + const QString key = CONTENTS + name; + if (!settings.contains(key)) { + return defaultIds(name); + } + return settings.value(key).toStringList(); +} + +/** + @return what toolbar @a name holds for a new user: the toolbars as + they were built before they could be changed. A user's own toolbar + starts empty. +*/ +QStringList DiagramToolbarSettings::defaultIds(const QString &name) +{ + const QString sep = separatorId(); + if (name == QLatin1String("toolbar")) { + return {QStringLiteral("diagrameditor.new_file"), + QStringLiteral("diagrameditor.open_file"), + QStringLiteral("diagrameditor.save_file"), + QStringLiteral("diagrameditor.save_file_as"), + QStringLiteral("diagrameditor.close_file"), + QStringLiteral("diagrameditor.print"), + QStringLiteral("diagrameditor.export_to_pdf"), + sep, + QStringLiteral("diagrameditor.undo"), + QStringLiteral("diagrameditor.redo"), + sep, + QStringLiteral("diagrameditor.cut"), + QStringLiteral("diagrameditor.copy"), + QStringLiteral("diagrameditor.paste"), + QStringLiteral("diagrameditor.duplicate"), + sep, + QStringLiteral("diagrameditor.delete_selection"), + QStringLiteral("diagrameditor.rotate_selection")}; + } + if (name == QLatin1String("display")) { + return {QStringLiteral("diagrameditor.mode_selection"), + QStringLiteral("diagrameditor.mode_visualise"), + sep, + QStringLiteral("widget:handler_size"), + sep, + QStringLiteral("diagrameditor.draw_grid"), + QStringLiteral("widget:text_grid"), + QStringLiteral("diagrameditor.draw_guides"), + QStringLiteral("widget:background_color"), + sep, + QStringLiteral("diagrameditor.zoom_content"), + QStringLiteral("diagrameditor.zoom_fit"), + QStringLiteral("diagrameditor.zoom_reset")}; + } + if (name == QLatin1String("diagram")) { + return {QStringLiteral("diagrameditor.edit_diagram_properties"), + QStringLiteral("diagrameditor.conductor_reset"), + QStringLiteral("diagrameditor.auto_conductor"), + QStringLiteral("diagrameditor.auto_break_conductor"), + QStringLiteral("widget:conductor_color")}; + } + if (name == QLatin1String("adding")) { + //add_pdf is skipped when built without QtPdf, as before + return {QStringLiteral("diagrameditor.add_text"), + QStringLiteral("diagrameditor.add_image"), + QStringLiteral("diagrameditor.add_pdf"), + QStringLiteral("diagrameditor.add_line"), + QStringLiteral("diagrameditor.add_rectangle"), + QStringLiteral("diagrameditor.add_ellipse"), + QStringLiteral("diagrameditor.add_arc"), + QStringLiteral("diagrameditor.add_polyline"), + QStringLiteral("diagrameditor.add_path"), + QStringLiteral("diagrameditor.add_fillet"), + QStringLiteral("diagrameditor.add_terminal_strip")}; + } + if (name == QLatin1String("diagram_depth_toolbar")) { + return {QStringLiteral("depth.forward"), + QStringLiteral("depth.raise"), + QStringLiteral("depth.lower"), + QStringLiteral("depth.backward")}; + } + return {}; +} + +/** + @brief DiagramToolbarSettings::setIds + Save @a ids for toolbar @a name. Saving the defaults removes the key, so + a later change of defaults still reaches this user. +*/ +void DiagramToolbarSettings::setIds(const QString &name, const QStringList &ids) +{ + if (ids == defaultIds(name)) { + removeIds(name); + } else { + QSettings().setValue(CONTENTS + name, ids); + } +} + +void DiagramToolbarSettings::removeIds(const QString &name) +{ + QSettings().remove(CONTENTS + name); +} + +QString DiagramToolbarSettings::separatorId() +{ + return QStringLiteral("separator"); +} + +/** + @return the toolbar buttons that are widgets, not commands. Each can be + on one toolbar at a time. +*/ +QStringList DiagramToolbarSettings::widgetIds() +{ + return {QStringLiteral("widget:handler_size"), + QStringLiteral("widget:text_grid"), + QStringLiteral("widget:background_color"), + QStringLiteral("widget:conductor_color")}; +} + +bool DiagramToolbarSettings::isWidget(const QString &id) +{ + return id.startsWith(QLatin1String("widget:")); +} + +QString DiagramToolbarSettings::widgetTitle(const QString &id) +{ + if (id == QLatin1String("widget:handler_size")) + return QCoreApplication::translate("DiagramToolbarSettings", "Taille des poignées"); + if (id == QLatin1String("widget:text_grid")) + return QCoreApplication::translate("DiagramToolbarSettings", "Grille des textes"); + if (id == QLatin1String("widget:background_color")) + return QCoreApplication::translate("DiagramToolbarSettings", "Couleur de fond du folio"); + if (id == QLatin1String("widget:conductor_color")) + return QCoreApplication::translate("DiagramToolbarSettings", "Couleur des conducteurs"); + return id; +} + +/** + @return every command that can go on a toolbar: the diagram editor's, + its stored scripts', and the depth commands it shares with the element + editor +*/ +QStringList DiagramToolbarSettings::availableCommandIds() +{ + QStringList ids; + for (const ShortcutManager::ShortcutInfo &info : + ShortcutManager::instance().allShortcuts()) + { + if (info.id.startsWith(QLatin1String("diagrameditor.")) + || info.id.startsWith(QLatin1String("depth."))) { + ids << info.id; + } + } + return ids; +} + +/** + @brief DiagramToolbarSettings::fill + Empty @a toolbar and put the commands of @a ids on it, in order. + @a resolve gives the window's action for an id, a widget id included; + an id it has no action for is skipped. A separator never starts or + ends the toolbar and never follows another: those are left out, as + Qt would otherwise draw them. +*/ +void DiagramToolbarSettings::fill(QToolBar *toolbar, const QStringList &ids, + const std::function &resolve) +{ + //Separators belong to the toolbar that made them; commands do not + const QList old_actions = toolbar->actions(); + toolbar->clear(); + for (QAction *action : old_actions) { + if (action->isSeparator() && action->parent() == toolbar) delete action; + } + bool pending_separator = false; + for (const QString &id : ids) + { + if (id == separatorId()) { + pending_separator = !toolbar->actions().isEmpty(); + continue; + } + QAction *action = resolve(id); + if (!action) { + continue; + } + if (pending_separator) { + toolbar->addSeparator(); + pending_separator = false; + } + toolbar->addAction(action); + } +} + +/** + @brief DiagramToolbarSettings::markWindow + Mark @a window as one whose toolbars applyToAll() rebuilds. It must + have a rebuildToolBars() slot or invokable. +*/ +void DiagramToolbarSettings::markWindow(QObject *window) +{ + window->setProperty(MARKED, true); +} + +/** + @brief DiagramToolbarSettings::applyToAll + Rebuild the toolbars of every open window marked by markWindow(), + after the settings change. +*/ +void DiagramToolbarSettings::applyToAll() +{ + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (widget->property(MARKED).toBool()) { + QMetaObject::invokeMethod(widget, "rebuildToolBars"); + } + } +} diff --git a/sources/diagramtoolbarsettings.h b/sources/diagramtoolbarsettings.h new file mode 100644 index 000000000..d240821c9 --- /dev/null +++ b/sources/diagramtoolbarsettings.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 DIAGRAMTOOLBARSETTINGS_H +#define DIAGRAMTOOLBARSETTINGS_H + +#include +#include +#include + +#include + +class QAction; +class QToolBar; + +/** + @brief What each of the diagram editor's toolbars holds. + + A toolbar is a list of ids, in order: ShortcutManager command ids, the + separator id, and the ids of the few toolbar buttons that are widgets + rather than commands (the colour buttons, the text grid, the handle + size). Stored in QSettings, one key per toolbar, by the toolbar's + object name; a toolbar the user never changed uses the defaults below, + which are the toolbars as they were before they could be changed. + + The user can also add toolbars of their own. They start empty. + + An id no command carries any more is skipped, so a list saved by + another version still opens. +*/ +class DiagramToolbarSettings +{ + public: + struct Toolbar { + QString name; ///< object name, used for storage and by QMainWindow::saveState() + QString title; ///< shown to the user + bool custom = false; + }; + + static QList toolbars(); + static QStringList builtInNames(); + static QString builtInTitle(const QString &name); + static QList customToolbars(); + static void setCustomToolbars(const QList &toolbars); + static QString newCustomName(const QList &existing); + + static QStringList ids(const QString &name); + static QStringList defaultIds(const QString &name); + static void setIds(const QString &name, const QStringList &ids); + static void removeIds(const QString &name); + + static QString separatorId(); + static QStringList widgetIds(); + static bool isWidget(const QString &id); + static QString widgetTitle(const QString &id); + static QStringList availableCommandIds(); + + static void fill(QToolBar *toolbar, const QStringList &ids, + const std::function &resolve); + + static void markWindow(QObject *window); + static void applyToAll(); +}; + +#endif // DIAGRAMTOOLBARSETTINGS_H diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index ccd4981c7..d8efc99d1 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -43,6 +43,7 @@ #include "ui/configpage/shortcutsconfigpage.h" #include "ui/configpage/shortcutbarconfigpage.h" #include "ui/configpage/toolbarsconfigpage.h" +#include "ui/configpage/toolbarcommandsconfigpage.h" #include "machine_info.h" #include "TerminalStrip/ui/terminalstripeditorwindow.h" #include "qetversion.h" @@ -2227,6 +2228,7 @@ void QETApp::configureQET() cd.addPage(new ShortcutsConfigPage()); cd.addPage(new ShortcutBarConfigPage()); cd.addPage(new ToolbarsConfigPage()); + cd.addPage(new ToolbarCommandsConfigPage()); #ifdef QET_SPACEMOUSE_SUPPORT cd.addPage(new SpaceMouseConfigPage()); #endif diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 6df5fbd25..5e72f57c2 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -27,9 +27,11 @@ #endif #include #include +#include #include "ElementsCollection/elementscollectionwidget.h" #include "ElementsCollection/elementpickerpopup.h" #include "shortcutbarsettings.h" +#include "diagramtoolbarsettings.h" #include "qetgraphicsitem/conductor.h" #include "itemgroups.h" #include "commandsearchpopup.h" @@ -1316,50 +1318,27 @@ void QETDiagramEditor::setUpToolBar() diagram_tool_bar = new QToolBar(tr("Schéma"), this); diagram_tool_bar -> setObjectName("diagram"); - main_tool_bar -> addActions(m_file_actions_group.actions()); - main_tool_bar -> addAction(m_print); - main_tool_bar -> addAction(m_export_to_pdf); - main_tool_bar -> addSeparator(); - main_tool_bar -> addAction(undo); - main_tool_bar -> addAction(redo); - main_tool_bar -> addSeparator(); - main_tool_bar -> addAction(m_cut); - main_tool_bar -> addAction(m_copy); - main_tool_bar -> addAction(m_paste); - main_tool_bar -> addAction(m_duplicate); - main_tool_bar -> addSeparator(); - main_tool_bar -> addAction(m_delete_selection); - main_tool_bar -> addAction(m_rotate_selection); - - // Modes selection / visualisation et zoom - view_tool_bar -> addAction(m_mode_selection); - view_tool_bar -> addAction(m_mode_visualise); - view_tool_bar -> addSeparator(); - view_tool_bar -> addWidget(new DiagramEditorHandlerSizeWidget(this)); - view_tool_bar -> addSeparator(); - view_tool_bar -> addAction(m_draw_grid); - view_tool_bar -> addWidget(m_text_grid_button); - view_tool_bar -> addAction(m_draw_guides); - view_tool_bar -> addWidget(m_background_color_button); - view_tool_bar -> addSeparator(); - view_tool_bar -> addActions(m_zoom_action_toolBar); - - diagram_tool_bar -> addAction (m_edit_diagram_properties); - diagram_tool_bar -> addAction (m_conductor_reset); - diagram_tool_bar -> addAction (m_auto_conductor); - diagram_tool_bar -> addAction (m_auto_break_conductor); - //Sits with the conductor actions it works alongside: it colours - //the selected conductors and sets the colour of the next one drawn. - m_conductor_color_button = new ConductorColorToolButton(this, this); - diagram_tool_bar -> addWidget (m_conductor_color_button); - m_add_item_tool_bar = new QToolBar(tr("Ajouter"), this); m_add_item_tool_bar->setObjectName("adding"); - m_add_item_tool_bar->addActions(m_add_item_actions_group.actions()); m_depth_tool_bar = new QToolBar(tr("Profondeur", "toolbar title")); m_depth_tool_bar->setObjectName("diagram_depth_toolbar"); - m_depth_tool_bar->addActions(m_depth_action_group->actions()); + + //The toolbar buttons that are widgets, not commands. Each is held by + //an action of this window, so it survives being taken off a toolbar. + auto add_widget = [this](const QString &id, QWidget *widget) { + auto *action = new QWidgetAction(this); + action->setDefaultWidget(widget); + action->setText(DiagramToolbarSettings::widgetTitle(id)); + m_toolbar_widgets.insert(id, action); + }; + add_widget(QStringLiteral("widget:handler_size"), new DiagramEditorHandlerSizeWidget(this)); + add_widget(QStringLiteral("widget:text_grid"), m_text_grid_button); + add_widget(QStringLiteral("widget:background_color"), m_background_color_button); + //Sits with the conductor actions it works alongside: it colours + //the selected conductors and sets the colour of the next one drawn. + m_conductor_color_button = new ConductorColorToolButton(this, this); + add_widget(QStringLiteral("widget:conductor_color"), m_conductor_color_button); addToolBar(Qt::TopToolBarArea, main_tool_bar); addToolBar(Qt::TopToolBarArea, view_tool_bar); @@ -1367,6 +1346,9 @@ void QETDiagramEditor::setUpToolBar() addToolBar(Qt::TopToolBarArea, m_add_item_tool_bar); addToolBar(Qt::TopToolBarArea, m_depth_tool_bar); + DiagramToolbarSettings::markWindow(this); + rebuildToolBars(); + m_scripts_tool_bar = new QToolBar(tr("Scripts", "toolbar title"), this); m_scripts_tool_bar->setObjectName("scripts"); addToolBar(Qt::TopToolBarArea, m_scripts_tool_bar); @@ -1376,6 +1358,59 @@ void QETDiagramEditor::setUpToolBar() #endif } +/** + @brief QETDiagramEditor::rebuildToolBars + Fill every toolbar from DiagramToolbarSettings, adding the user's own + toolbars that this window does not have yet and removing those that + were deleted. Called when the window is built, when the settings + change, and when the stored scripts change (a script can be on any + toolbar). +*/ +void QETDiagramEditor::rebuildToolBars() +{ + const QList custom = DiagramToolbarSettings::customToolbars(); + QStringList custom_names; + for (const DiagramToolbarSettings::Toolbar &toolbar : custom) { + custom_names << toolbar.name; + } + for (QToolBar *toolbar : m_custom_tool_bars) { + if (!custom_names.contains(toolbar->objectName())) { + removeToolBar(toolbar); + toolbar->deleteLater(); + } + } + m_custom_tool_bars.erase(std::remove_if(m_custom_tool_bars.begin(), m_custom_tool_bars.end(), + [&custom_names](QToolBar *toolbar) { return !custom_names.contains(toolbar->objectName()); }), + m_custom_tool_bars.end()); + + for (const DiagramToolbarSettings::Toolbar &toolbar : custom) + { + auto existing = std::find_if(m_custom_tool_bars.cbegin(), m_custom_tool_bars.cend(), + [&toolbar](QToolBar *t) { return t->objectName() == toolbar.name; }); + if (existing != m_custom_tool_bars.cend()) { + (*existing)->setWindowTitle(toolbar.title); + continue; + } + auto *new_tool_bar = new QToolBar(toolbar.title, this); + new_tool_bar->setObjectName(toolbar.name); + new_tool_bar->setMovable(!ToolbarSettings::locked()); + addToolBar(Qt::TopToolBarArea, new_tool_bar); + m_custom_tool_bars << new_tool_bar; + } + + auto resolve = [this](const QString &id) -> QAction * { + if (DiagramToolbarSettings::isWidget(id)) { + return m_toolbar_widgets.value(id); + } + return ShortcutManager::instance().action(id, this); + }; + const QList built_in {main_tool_bar, view_tool_bar, diagram_tool_bar, + m_add_item_tool_bar, m_depth_tool_bar}; + for (QToolBar *toolbar : built_in + m_custom_tool_bars) { + DiagramToolbarSettings::fill(toolbar, DiagramToolbarSettings::ids(toolbar->objectName()), resolve); + } +} + /** @brief QETDiagramEditor::setUpMenu */ @@ -4039,6 +4074,7 @@ void QETDiagramEditor::rebuildScriptActions() if (has_scripts != m_had_scripts) m_scripts_tool_bar->setVisible(has_scripts); m_had_scripts = has_scripts; + rebuildToolBars(); updateScriptActions(); } diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 500a90e91..0cc42c171 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -140,6 +140,7 @@ class QETDiagramEditor : public QETMainWindow void rememberPlacedElement(const ElementsLocation &location); void showElementPicker(); void showShortcutBar(); + void rebuildToolBars(); bool repeatLastCommand(); void generateTerminalBlock(); void setWindowedMode(); @@ -328,6 +329,10 @@ class QETDiagramEditor : public QETMainWindow *m_add_item_tool_bar = nullptr, *m_depth_tool_bar = nullptr, *m_scripts_tool_bar = nullptr; ///< One button per stored script + /// Toolbars the user added (DiagramToolbarSettings) + QList m_custom_tool_bars; + /// The toolbar buttons that are widgets, by DiagramToolbarSettings id + QHash m_toolbar_widgets; #ifdef QET_HAS_SCRIPTING QMenu *m_scripts_menu = nullptr; QList m_script_actions; ///< One per stored script, rebuilt when the folder changes diff --git a/sources/ui/configpage/toolbarcommandsconfigpage.cpp b/sources/ui/configpage/toolbarcommandsconfigpage.cpp new file mode 100644 index 000000000..30bb36e3c --- /dev/null +++ b/sources/ui/configpage/toolbarcommandsconfigpage.cpp @@ -0,0 +1,423 @@ +/* + 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 "toolbarcommandsconfigpage.h" + +#include "../../qeticons.h" +#include "../../shortcutmanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +ToolbarCommandsConfigPage::ToolbarCommandsConfigPage(QWidget *parent) : + ConfigPage(parent) +{ + const QStringList available = DiagramToolbarSettings::availableCommandIds(); + for (const ShortcutManager::ShortcutInfo &info : + ShortcutManager::instance().allShortcuts()) { + if (available.contains(info.id)) { + m_descriptions.insert(info.id, info.description); + } + } + m_custom = DiagramToolbarSettings::customToolbars(); + for (const DiagramToolbarSettings::Toolbar &toolbar : DiagramToolbarSettings::toolbars()) { + m_pending.insert(toolbar.name, DiagramToolbarSettings::ids(toolbar.name)); + } + + auto *explanation = new QLabel( + tr("Choisissez les commandes de chaque barre d'outils de l'éditeur " + "de schémas. Glissez une commande de la liste de gauche vers la " + "barre, ou double-cliquez dessus ; Suppr la retire."), this); + explanation->setWordWrap(true); + + m_toolbar = new QComboBox(this); + m_toolbar->setObjectName(QStringLiteral("toolbarCombo")); + auto *new_toolbar = new QPushButton(tr("Nouvelle barre…"), this); + new_toolbar->setObjectName(QStringLiteral("newToolbarButton")); + m_rename = new QPushButton(tr("Renommer…"), this); + m_delete = new QPushButton(tr("Supprimer la barre"), this); + m_delete->setObjectName(QStringLiteral("deleteToolbarButton")); + auto *reset = new QPushButton(tr("Valeurs par défaut"), this); + reset->setObjectName(QStringLiteral("resetButton")); + + m_filter = new QLineEdit(this); + m_filter->setPlaceholderText(tr("Rechercher une commande")); + m_filter->setClearButtonEnabled(true); + + m_available = new QListWidget(this); + m_available->setObjectName(QStringLiteral("availableList")); + m_available->setSelectionMode(QAbstractItemView::ExtendedSelection); + m_available->setSortingEnabled(true); + m_available->setDragDropMode(QAbstractItemView::DragOnly); + m_available->setDefaultDropAction(Qt::MoveAction); + m_chosen = new QListWidget(this); + m_chosen->setObjectName(QStringLiteral("chosenList")); + m_chosen->setSelectionMode(QAbstractItemView::ExtendedSelection); + //Takes commands dragged from the list on the left, and reorders + m_chosen->setDragDropMode(QAbstractItemView::DragDrop); + m_chosen->setDefaultDropAction(Qt::MoveAction); + + auto *add = new QPushButton(tr("Ajouter →"), this); + auto *separator = new QPushButton(tr("Séparateur"), this); + separator->setObjectName(QStringLiteral("separatorButton")); + auto *remove = new QPushButton(tr("← Retirer"), this); + auto *up = new QPushButton(tr("Monter"), this); + auto *down = new QPushButton(tr("Descendre"), this); + + auto *buttons = new QVBoxLayout(); + buttons->addStretch(); + buttons->addWidget(add); + buttons->addWidget(separator); + buttons->addWidget(remove); + buttons->addSpacing(12); + buttons->addWidget(up); + buttons->addWidget(down); + buttons->addStretch(); + + auto *grid = new QGridLayout(); + grid->addWidget(new QLabel(tr("Commandes disponibles"), this), 0, 0); + grid->addWidget(new QLabel(tr("Dans la barre, dans l'ordre"), this), 0, 2); + grid->addWidget(m_filter, 1, 0); + grid->addWidget(m_available, 2, 0); + grid->addLayout(buttons, 2, 1); + grid->addWidget(m_chosen, 1, 2, 2, 1); + + auto *toolbar_row = new QHBoxLayout(); + toolbar_row->addWidget(new QLabel(tr("Barre d'outils :"), this)); + toolbar_row->addWidget(m_toolbar, 1); + toolbar_row->addWidget(new_toolbar); + toolbar_row->addWidget(m_rename); + toolbar_row->addWidget(m_delete); + toolbar_row->addWidget(reset); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(explanation); + layout->addLayout(toolbar_row); + layout->addLayout(grid); + + connect(m_toolbar, qOverload(&QComboBox::currentIndexChanged), this, [this]() { + storeToolbar(); + showToolbar(); + }); + connect(m_filter, &QLineEdit::textChanged, this, &ToolbarCommandsConfigPage::fillAvailable); + connect(add, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::addSelected); + connect(separator, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::addSeparator); + connect(remove, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::removeSelected); + connect(up, &QPushButton::clicked, this, [this]() { moveSelected(-1); }); + connect(down, &QPushButton::clicked, this, [this]() { moveSelected(1); }); + connect(reset, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::resetToolbar); + connect(new_toolbar, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::newToolbar); + connect(m_rename, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::renameToolbar); + connect(m_delete, &QPushButton::clicked, this, &ToolbarCommandsConfigPage::deleteToolbar); + connect(m_available, &QListWidget::itemDoubleClicked, this, &ToolbarCommandsConfigPage::addSelected); + connect(m_chosen, &QListWidget::itemDoubleClicked, this, &ToolbarCommandsConfigPage::removeSelected); + //A command dropped from the left is no longer available. Later, + //not now: a dropped row is inserted before its data is set. + connect(m_chosen->model(), &QAbstractItemModel::rowsInserted, this, [this]() { + if (m_chosen->property("filling").toBool()) return; + QTimer::singleShot(0, this, [this]() { + storeToolbar(); + fillAvailable(); + }); + }); + + auto *delete_key = new QAction(m_chosen); + delete_key->setShortcut(QKeySequence::Delete); + delete_key->setShortcutContext(Qt::WidgetShortcut); + m_chosen->addAction(delete_key); + connect(delete_key, &QAction::triggered, this, &ToolbarCommandsConfigPage::removeSelected); + + fillToolbarCombo(DiagramToolbarSettings::builtInNames().constFirst()); +} + +/** + @brief ToolbarCommandsConfigPage::applyConf + Save the user's own toolbars and every toolbar's contents, then + rebuild the toolbars of the open diagram editors. +*/ +void ToolbarCommandsConfigPage::applyConf() +{ + storeToolbar(); + DiagramToolbarSettings::setCustomToolbars(m_custom); + for (const DiagramToolbarSettings::Toolbar &toolbar : DiagramToolbarSettings::toolbars()) { + DiagramToolbarSettings::setIds(toolbar.name, m_pending.value(toolbar.name)); + } + DiagramToolbarSettings::applyToAll(); +} + +QString ToolbarCommandsConfigPage::title() const +{ + return tr("Contenu des barres", "configuration page title: what each toolbar holds"); +} + +QIcon ToolbarCommandsConfigPage::icon() const +{ + return QET::Icons::ConfigureToolbars; +} + +/** + @brief ToolbarCommandsConfigPage::fillToolbarCombo + List the built-in toolbars and the user's own, and show @a current. +*/ +void ToolbarCommandsConfigPage::fillToolbarCombo(const QString ¤t) +{ + const QSignalBlocker blocker(m_toolbar); + m_toolbar->clear(); + for (const QString &name : DiagramToolbarSettings::builtInNames()) { + m_toolbar->addItem(DiagramToolbarSettings::builtInTitle(name), name); + } + for (const DiagramToolbarSettings::Toolbar &toolbar : std::as_const(m_custom)) { + m_toolbar->addItem(toolbar.title, toolbar.name); + } + const int index = m_toolbar->findData(current); + m_toolbar->setCurrentIndex(index < 0 ? 0 : index); + showToolbar(); +} + +/** + @brief ToolbarCommandsConfigPage::showToolbar + Fill both lists for the toolbar chosen in the combo box. +*/ +void ToolbarCommandsConfigPage::showToolbar() +{ + m_shown = m_toolbar->currentData().toString(); + const bool custom = !DiagramToolbarSettings::builtInNames().contains(m_shown); + m_rename->setEnabled(custom); + m_delete->setEnabled(custom); + + m_chosen->setProperty("filling", true); + m_chosen->clear(); + for (const QString &id : m_pending.value(m_shown)) { + m_chosen->addItem(makeItem(id)); + } + m_chosen->setProperty("filling", false); + fillAvailable(); +} + +/** + @brief ToolbarCommandsConfigPage::storeToolbar + Keep the shown toolbar's list, in the order on screen. +*/ +void ToolbarCommandsConfigPage::storeToolbar() +{ + if (m_shown.isEmpty()) { + return; + } + QStringList ids; + for (int i = 0 ; i < m_chosen->count() ; ++i) { + ids << m_chosen->item(i)->data(Qt::UserRole).toString(); + } + m_pending.insert(m_shown, ids); +} + +/** + @brief ToolbarCommandsConfigPage::fillAvailable + List what can still go on the shown toolbar and matches the search: a + command not already on it, and a widget button on no toolbar at all + (a widget can only be in one place). +*/ +void ToolbarCommandsConfigPage::fillAvailable() +{ + QStringList on_shown; + for (int i = 0 ; i < m_chosen->count() ; ++i) { + on_shown << m_chosen->item(i)->data(Qt::UserRole).toString(); + } + QStringList on_any = on_shown; + for (auto it = m_pending.cbegin() ; it != m_pending.cend() ; ++it) { + if (it.key() != m_shown) on_any << it.value(); + } + + QStringList ids; + for (auto it = m_descriptions.cbegin() ; it != m_descriptions.cend() ; ++it) { + if (!on_shown.contains(it.key())) ids << it.key(); + } + for (const QString &id : DiagramToolbarSettings::widgetIds()) { + if (!on_any.contains(id)) ids << id; + } + + m_available->clear(); + const QString filter = m_filter->text().trimmed(); + for (const QString &id : std::as_const(ids)) { + QListWidgetItem *item = makeItem(id); + if (filter.isEmpty() || item->text().contains(filter, Qt::CaseInsensitive)) { + m_available->addItem(item); + } else { + delete item; + } + } +} + +void ToolbarCommandsConfigPage::addSelected() +{ + const QList selected = m_available->selectedItems(); + if (selected.isEmpty()) { + return; + } + m_chosen->setProperty("filling", true); + for (QListWidgetItem *item : selected) { + m_chosen->addItem(m_available->takeItem(m_available->row(item))); + } + m_chosen->setProperty("filling", false); + storeToolbar(); + fillAvailable(); +} + +/** + @brief ToolbarCommandsConfigPage::addSeparator + Insert a separator after the selected row, or at the end. +*/ +void ToolbarCommandsConfigPage::addSeparator() +{ + const int row = m_chosen->currentRow(); + m_chosen->setProperty("filling", true); + m_chosen->insertItem(row < 0 ? m_chosen->count() : row + 1, + makeItem(DiagramToolbarSettings::separatorId())); + m_chosen->setProperty("filling", false); + storeToolbar(); +} + +void ToolbarCommandsConfigPage::removeSelected() +{ + for (QListWidgetItem *item : m_chosen->selectedItems()) { + delete m_chosen->takeItem(m_chosen->row(item)); + } + storeToolbar(); + fillAvailable(); +} + +/** + @brief ToolbarCommandsConfigPage::moveSelected + Move the selected command @a step rows, keeping it selected. +*/ +void ToolbarCommandsConfigPage::moveSelected(int step) +{ + const int row = m_chosen->currentRow(); + const int target = row + step; + if (row < 0 || target < 0 || target >= m_chosen->count()) { + return; + } + m_chosen->setProperty("filling", true); + QListWidgetItem *item = m_chosen->takeItem(row); + m_chosen->insertItem(target, item); + m_chosen->setProperty("filling", false); + m_chosen->setCurrentRow(target); + storeToolbar(); +} + +/** + @brief ToolbarCommandsConfigPage::resetToolbar + Give the shown toolbar its defaults back; the user's own toolbar is + emptied. +*/ +void ToolbarCommandsConfigPage::resetToolbar() +{ + m_pending.insert(m_shown, DiagramToolbarSettings::defaultIds(m_shown)); + //A widget the defaults put here leaves the toolbar it was moved to + for (const QString &id : DiagramToolbarSettings::defaultIds(m_shown)) { + if (!DiagramToolbarSettings::isWidget(id)) continue; + for (auto it = m_pending.begin() ; it != m_pending.end() ; ++it) { + if (it.key() != m_shown) it.value().removeAll(id); + } + } + showToolbar(); +} + +void ToolbarCommandsConfigPage::newToolbar() +{ + bool ok = false; + const QString title = QInputDialog::getText(this, tr("Nouvelle barre d'outils"), + tr("Nom de la barre d'outils :"), + QLineEdit::Normal, QString(), &ok).trimmed(); + if (!ok || title.isEmpty()) { + return; + } + storeToolbar(); + DiagramToolbarSettings::Toolbar toolbar; + toolbar.name = DiagramToolbarSettings::newCustomName(m_custom); + toolbar.title = title; + toolbar.custom = true; + m_custom << toolbar; + m_pending.insert(toolbar.name, QStringList()); + fillToolbarCombo(toolbar.name); +} + +void ToolbarCommandsConfigPage::renameToolbar() +{ + for (DiagramToolbarSettings::Toolbar &toolbar : m_custom) { + if (toolbar.name != m_shown) continue; + bool ok = false; + const QString title = QInputDialog::getText(this, tr("Renommer la barre d'outils"), + tr("Nom de la barre d'outils :"), + QLineEdit::Normal, toolbar.title, &ok).trimmed(); + if (ok && !title.isEmpty()) { + storeToolbar(); + toolbar.title = title; + fillToolbarCombo(m_shown); + } + return; + } +} + +void ToolbarCommandsConfigPage::deleteToolbar() +{ + const QString name = m_shown; + for (int i = 0 ; i < m_custom.size() ; ++i) { + if (m_custom.at(i).name == name) { + m_custom.removeAt(i); + m_pending.remove(name); + m_shown.clear(); + fillToolbarCombo(DiagramToolbarSettings::builtInNames().constFirst()); + return; + } + } +} + +/** + @brief ToolbarCommandsConfigPage::makeItem + @return a list item for @a id with the command's text and icon. An id + no live action carries (a command from a build without it, or a script + since removed) is still listed, by its id, so saving does not silently + drop it. +*/ +QListWidgetItem *ToolbarCommandsConfigPage::makeItem(const QString &id) const +{ + QString text = m_descriptions.value(id, id); + QString tip; + QIcon icon; + if (id == DiagramToolbarSettings::separatorId()) { + text = tr("─── Séparateur ───"); + } else if (DiagramToolbarSettings::isWidget(id)) { + text = DiagramToolbarSettings::widgetTitle(id); + } else if (QAction *action = ShortcutManager::instance().action(id, nullptr)) { + icon = action->icon(); + tip = action->statusTip(); + } + auto *item = new QListWidgetItem(icon, text); + item->setData(Qt::UserRole, id); + //Tells apart commands with the same name ("Ajouter une ligne") + item->setToolTip(tip); + return item; +} diff --git a/sources/ui/configpage/toolbarcommandsconfigpage.h b/sources/ui/configpage/toolbarcommandsconfigpage.h new file mode 100644 index 000000000..286978cc4 --- /dev/null +++ b/sources/ui/configpage/toolbarcommandsconfigpage.h @@ -0,0 +1,78 @@ +/* + 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 TOOLBARCOMMANDSCONFIGPAGE_H +#define TOOLBARCOMMANDSCONFIGPAGE_H + +#include "configpage.h" +#include "../../diagramtoolbarsettings.h" + +#include + +class QComboBox; +class QLineEdit; +class QListWidget; +class QListWidgetItem; +class QPushButton; + +/** + @brief The ToolbarCommandsConfigPage class + Choose what each of the diagram editor's toolbars holds, and add, + rename or delete toolbars of one's own. Commands are dragged or added + from the list on the left. Changes are kept per toolbar while the + dialog is open, then saved and applied to the open windows by + applyConf(). +*/ +class ToolbarCommandsConfigPage : public ConfigPage +{ + Q_OBJECT + + public: + explicit ToolbarCommandsConfigPage(QWidget *parent = nullptr); + + void applyConf() override; + QString title() const override; + QIcon icon() const override; + + private: + void fillToolbarCombo(const QString ¤t); + void showToolbar(); + void storeToolbar(); + void fillAvailable(); + void addSelected(); + void addSeparator(); + void removeSelected(); + void moveSelected(int step); + void resetToolbar(); + void newToolbar(); + void renameToolbar(); + void deleteToolbar(); + QListWidgetItem *makeItem(const QString &id) const; + + QComboBox *m_toolbar; + QLineEdit *m_filter; + QListWidget *m_available; + QListWidget *m_chosen; + QPushButton *m_rename; + QPushButton *m_delete; + QString m_shown; + QList m_custom; + QHash m_pending; + QHash m_descriptions; +}; + +#endif // TOOLBARCOMMANDSCONFIGPAGE_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 1a9ad8487..e37a26c43 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -298,6 +298,26 @@ set_tests_properties(tst_toolbarsettings PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM target_include_directories(tst_toolbarsettings PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_toolbarsettings PRIVATE Qt::Test Qt::Widgets Qt::Svg) +# DiagramToolbarSettings and its configuration page: unchanged toolbars +# store nothing and build as before, unknown ids and stray separators are +# skipped, the user's own toolbars are kept, a widget is in one place only. +add_executable( + tst_diagramtoolbarsettings + tst_diagramtoolbarsettings.cpp + ${QET_DIR}/sources/diagramtoolbarsettings.cpp + ${QET_DIR}/sources/diagramtoolbarsettings.h + ${QET_DIR}/sources/shortcutmanager.cpp + ${QET_DIR}/sources/ui/configpage/toolbarcommandsconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/toolbarcommandsconfigpage.h + ${QET_DIR}/sources/ui/configpage/configpage.h + ${QET_DIR}/sources/qeticons.cpp + ${QET_DIR}/sources/qetpalette.cpp + ${QET_DIR}/sources/qetstyle.cpp) +add_test(NAME tst_diagramtoolbarsettings COMMAND tst_diagramtoolbarsettings) +set_tests_properties(tst_diagramtoolbarsettings PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_diagramtoolbarsettings PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_diagramtoolbarsettings PRIVATE Qt::Test Qt::Widgets Qt::Svg) + # scriptheader.h is header-only: the // ==QETScript== block that turns a # stored script into a button; and ShortcutManager::unregisterAction(), which # takes a deleted script's command out of the lists again. diff --git a/tests/qttest/tst_diagramtoolbarsettings.cpp b/tests/qttest/tst_diagramtoolbarsettings.cpp new file mode 100644 index 000000000..9c77cc8d9 --- /dev/null +++ b/tests/qttest/tst_diagramtoolbarsettings.cpp @@ -0,0 +1,245 @@ +/* + 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 "diagramtoolbarsettings.h" +#include "shortcutmanager.h" +#include "ui/configpage/toolbarcommandsconfigpage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + A top-level window standing in for the diagram editor: counts the + calls applyToAll() makes. +*/ +class RebuildCounter : public QWidget +{ + Q_OBJECT + public: + int rebuilds = 0; + public slots: + void rebuildToolBars() { ++rebuilds; } +}; + +/** + DiagramToolbarSettings and its configuration page: an unchanged + toolbar stores nothing and is built as before, unknown ids and stray + separators are skipped, the user's own toolbars are kept and removed + with their contents, and the page saves what it shows. +*/ +class tst_diagramtoolbarsettings : public QObject +{ + Q_OBJECT + + QObject m_owner; + QHash m_actions; + + /// The action for @a id, made on first use, as the editor's resolver + QAction *resolve(const QString &id) + { + if (id.startsWith(QLatin1String("missing."))) return nullptr; + if (!m_actions.contains(id)) { + auto *action = new QAction(id, &m_owner); + m_actions.insert(id, action); + } + return m_actions.value(id); + } + + static QStringList shape(QToolBar *toolbar) + { + QStringList list; + for (QAction *action : toolbar->actions()) { + list << (action->isSeparator() ? QStringLiteral("|") : action->text()); + } + return list; + } + + static void clearSettings() + { + QSettings settings; + settings.remove(QStringLiteral("diagrameditor/toolbars")); + settings.remove(QStringLiteral("diagrameditor/custom_toolbars")); + } + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + clearSettings(); + //A few of the diagram editor's commands, as it registers them + for (const QString &id : {QStringLiteral("diagrameditor.copy"), + QStringLiteral("diagrameditor.paste"), + QStringLiteral("diagrameditor.zoom_fit"), + QStringLiteral("depth.raise"), + QStringLiteral("elementeditor.copy")}) { + ShortcutManager::instance().registerAction( + new QAction(id, &m_owner), id, QStringLiteral("test"), QKeySequence()); + } + } + + void init() { clearSettings(); } + + void defaultsStoreNothing() + { + for (const QString &name : DiagramToolbarSettings::builtInNames()) { + QVERIFY(!DiagramToolbarSettings::defaultIds(name).isEmpty()); + QCOMPARE(DiagramToolbarSettings::ids(name), DiagramToolbarSettings::defaultIds(name)); + DiagramToolbarSettings::setIds(name, DiagramToolbarSettings::defaultIds(name)); + } + QVERIFY(!QSettings().contains(QStringLiteral("diagrameditor/toolbars/toolbar"))); + + DiagramToolbarSettings::setIds(QStringLiteral("toolbar"), {QStringLiteral("diagrameditor.copy")}); + QCOMPARE(DiagramToolbarSettings::ids(QStringLiteral("toolbar")), + QStringList{QStringLiteral("diagrameditor.copy")}); + //Saving an empty toolbar keeps it empty, not back to the defaults + DiagramToolbarSettings::setIds(QStringLiteral("toolbar"), {}); + QVERIFY(DiagramToolbarSettings::ids(QStringLiteral("toolbar")).isEmpty()); + } + + /// The defaults are built exactly as the toolbars were hand-built + /// before: the main toolbar's three separators in place + void defaultsBuildAsBefore() + { + QToolBar toolbar; + DiagramToolbarSettings::fill(&toolbar, DiagramToolbarSettings::defaultIds(QStringLiteral("toolbar")), + [this](const QString &id) { return resolve(id); }); + QCOMPARE(toolbar.actions().size(), 18); + QCOMPARE(shape(&toolbar).mid(6, 2), + (QStringList{QStringLiteral("diagrameditor.export_to_pdf"), QStringLiteral("|")})); + QCOMPARE(shape(&toolbar).count(QStringLiteral("|")), 3); + } + + void fillSkipsUnknownAndStraySeparators() + { + const QString sep = DiagramToolbarSettings::separatorId(); + QToolBar toolbar; + toolbar.addAction(QStringLiteral("left over")); + DiagramToolbarSettings::fill(&toolbar, + {sep, QStringLiteral("a"), sep, sep, QStringLiteral("missing.b"), + QStringLiteral("c"), sep, QStringLiteral("missing.d"), sep}, + [this](const QString &id) { return resolve(id); }); + QCOMPARE(shape(&toolbar), (QStringList{QStringLiteral("a"), QStringLiteral("|"), QStringLiteral("c")})); + + DiagramToolbarSettings::fill(&toolbar, {}, [this](const QString &id) { return resolve(id); }); + QVERIFY(toolbar.actions().isEmpty()); + } + + void customToolbarsKeptAndRemoved() + { + using T = DiagramToolbarSettings::Toolbar; + const QString first = DiagramToolbarSettings::newCustomName({}); + QCOMPARE(first, QStringLiteral("custom_1")); + DiagramToolbarSettings::setCustomToolbars({T{first, QStringLiteral("Mine"), true}, + T{QStringLiteral("custom_2"), QStringLiteral("Other"), true}}); + DiagramToolbarSettings::setIds(first, {QStringLiteral("diagrameditor.copy")}); + QCOMPARE(DiagramToolbarSettings::customToolbars().size(), 2); + QCOMPARE(DiagramToolbarSettings::customToolbars().first().title, QStringLiteral("Mine")); + QCOMPARE(DiagramToolbarSettings::toolbars().size(), + DiagramToolbarSettings::builtInNames().size() + 2); + QCOMPARE(DiagramToolbarSettings::newCustomName({}), QStringLiteral("custom_3")); + //A new toolbar starts empty + QVERIFY(DiagramToolbarSettings::ids(QStringLiteral("custom_2")).isEmpty()); + + DiagramToolbarSettings::setCustomToolbars({T{QStringLiteral("custom_2"), QStringLiteral("Other"), true}}); + QCOMPARE(DiagramToolbarSettings::customToolbars().size(), 1); + QVERIFY(!QSettings().contains(QStringLiteral("diagrameditor/toolbars/") + first)); + + DiagramToolbarSettings::setCustomToolbars({}); + QVERIFY(!QSettings().contains(QStringLiteral("diagrameditor/custom_toolbars/names"))); + } + + void availableCommandsAreTheDiagramEditors() + { + const QStringList ids = DiagramToolbarSettings::availableCommandIds(); + QVERIFY(ids.contains(QStringLiteral("diagrameditor.copy"))); + QVERIFY(ids.contains(QStringLiteral("depth.raise"))); + QVERIFY(!ids.contains(QStringLiteral("elementeditor.copy"))); + } + + void pageSavesAndRebuilds() + { + RebuildCounter marked, unmarked; + DiagramToolbarSettings::markWindow(&marked); + + ToolbarCommandsConfigPage page; + auto *combo = page.findChild(QStringLiteral("toolbarCombo")); + auto *available = page.findChild(QStringLiteral("availableList")); + auto *chosen = page.findChild(QStringLiteral("chosenList")); + QVERIFY(combo && available && chosen); + QCOMPARE(combo->count(), DiagramToolbarSettings::builtInNames().size()); + QCOMPARE(chosen->count(), DiagramToolbarSettings::defaultIds(QStringLiteral("toolbar")).size()); + + //OK without a change stores nothing + page.applyConf(); + QVERIFY(!QSettings().contains(QStringLiteral("diagrameditor/toolbars/toolbar"))); + QCOMPARE(marked.rebuilds, 1); + QCOMPARE(unmarked.rebuilds, 0); + + //Remove everything but the first command, add a separator + while (chosen->count() > 1) delete chosen->takeItem(1); + chosen->setCurrentRow(0); + page.findChild(QStringLiteral("separatorButton"))->click(); + page.applyConf(); + QCOMPARE(DiagramToolbarSettings::ids(QStringLiteral("toolbar")), + (QStringList{QStringLiteral("diagrameditor.new_file"), DiagramToolbarSettings::separatorId()})); + QCOMPARE(marked.rebuilds, 2); + } + + /// A widget button can be in one place only: once moved to another + /// toolbar it is no longer offered, and resetting its own toolbar + /// takes it back + void widgetInOnePlace() + { + ToolbarCommandsConfigPage page; + auto *combo = page.findChild(QStringLiteral("toolbarCombo")); + auto *available = page.findChild(QStringLiteral("availableList")); + auto *chosen = page.findChild(QStringLiteral("chosenList")); + auto offered = [available](const QString &id) { + for (int i = 0 ; i < available->count() ; ++i) + if (available->item(i)->data(Qt::UserRole).toString() == id) return true; + return false; + }; + const QString colour = QStringLiteral("widget:conductor_color"); + + //On "diagram" by default, so not offered for "toolbar" + QVERIFY(!offered(colour)); + //Taken off "diagram" (switching toolbars keeps the change) + combo->setCurrentIndex(combo->findData(QStringLiteral("diagram"))); + for (int i = chosen->count() - 1 ; i >= 0 ; --i) { + if (chosen->item(i)->data(Qt::UserRole).toString() == colour) delete chosen->takeItem(i); + } + combo->setCurrentIndex(combo->findData(QStringLiteral("toolbar"))); + QVERIFY(offered(colour)); + + //Reset "diagram": the colour button goes back there + combo->setCurrentIndex(combo->findData(QStringLiteral("diagram"))); + page.findChild(QStringLiteral("resetButton"))->click(); + QCOMPARE(chosen->count(), DiagramToolbarSettings::defaultIds(QStringLiteral("diagram")).size()); + combo->setCurrentIndex(combo->findData(QStringLiteral("toolbar"))); + QVERIFY(!offered(colour)); + } +}; + +QTEST_MAIN(tst_diagramtoolbarsettings) +#include "tst_diagramtoolbarsettings.moc" From 46e041fe01c16bc7b62ed1e8aee9f8a22aac52b1 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 18:38:25 +1300 Subject: [PATCH 25/26] Mouse gestures: own command list, 4 or 8 directions The right-drag ring showed the shortcut bar's commands for the selection. A new Preferences page, "Gestes de la souris", lets the user pick the ring's commands per selection context, one per direction, and choose 4 or 8 directions. Commands are dropped from the list onto a slot of a ring editor, or placed with a button; Delete empties a slot. A context the user never edits keeps following the shortcut bar, and 8 directions stays the default, so nothing changes for anyone who does not open the page. A picked list is positional: a direction left empty, or holding a command this build lacks, stays empty instead of shifting the others. Stored like ShortcutBarSettings: diagrameditor/gestures/ id lists in QSettings, defaults not stored. tst_gesturesettings covers the defaults, positional lists, the 4/8 setting, the overlay's sectors and empty slots, and the page. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 4 + sources/diagramgestureoverlay.cpp | 23 +- sources/diagramgestureoverlay.h | 13 +- sources/diagramview.cpp | 26 +- sources/diagramview.h | 1 + sources/gesturesettings.cpp | 125 +++++ sources/gesturesettings.h | 47 ++ sources/qetapp.cpp | 2 + sources/ui/configpage/gesturesconfigpage.cpp | 475 +++++++++++++++++++ sources/ui/configpage/gesturesconfigpage.h | 112 +++++ tests/qttest/CMakeLists.txt | 23 + tests/qttest/tst_gesturesettings.cpp | 214 +++++++++ 12 files changed, 1050 insertions(+), 15 deletions(-) create mode 100644 sources/gesturesettings.cpp create mode 100644 sources/gesturesettings.h create mode 100644 sources/ui/configpage/gesturesconfigpage.cpp create mode 100644 sources/ui/configpage/gesturesconfigpage.h create mode 100644 tests/qttest/tst_gesturesettings.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 334d7e715..0955ace62 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -307,6 +307,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/diagramcontexttoolbar.h ${QET_DIR}/sources/diagramgestureoverlay.cpp ${QET_DIR}/sources/diagramgestureoverlay.h + ${QET_DIR}/sources/gesturesettings.cpp + ${QET_DIR}/sources/gesturesettings.h ${QET_DIR}/sources/commandsearchpopup.cpp ${QET_DIR}/sources/commandsearchpopup.h ${QET_DIR}/sources/titleblockcell.cpp @@ -860,6 +862,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.h ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.cpp ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.h + ${QET_DIR}/sources/ui/configpage/gesturesconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/gesturesconfigpage.h ${QET_DIR}/sources/undocommand/addelementtextcommand.cpp ${QET_DIR}/sources/undocommand/addelementtextcommand.h diff --git a/sources/diagramgestureoverlay.cpp b/sources/diagramgestureoverlay.cpp index 03d2606d1..c1bedd8d7 100644 --- a/sources/diagramgestureoverlay.cpp +++ b/sources/diagramgestureoverlay.cpp @@ -58,14 +58,16 @@ bool DiagramGestureOverlay::isEnabled() /** @brief DiagramGestureOverlay::showAt - Show the ring centred on @a center with @a actions, at most eight, - clockwise from the top. + Show the ring centred on @a center with @a sectors directions (4 or 8) + and @a actions, one per direction clockwise from the top. */ void DiagramGestureOverlay::showAt(const QPoint ¢er, - const QList &actions) + const QList &actions, + int sectors) { m_center = center; - m_actions = actions.mid(0, sectors); + m_sectors = sectors == 4 ? 4 : 8; + m_actions = actions.mid(0, m_sectors); m_active = -1; move(center - QPoint(overlay_width / 2, outer_radius + 1)); show(); @@ -88,7 +90,7 @@ int DiagramGestureOverlay::sectorAt(const QPoint &viewport_pos) const if (angle < 0) { angle += 360; } - return int(qRound(angle / (360.0 / sectors))) % sectors; + return int(qRound(angle / (360.0 / m_sectors))) % m_sectors; } /** @@ -102,7 +104,7 @@ QAction *DiagramGestureOverlay::actionAt(const QPoint &viewport_pos) const return nullptr; } QAction *action = m_actions.at(sector); - return action->isEnabled() ? action : nullptr; + return action && action->isEnabled() ? action : nullptr; } void DiagramGestureOverlay::setPointer(const QPoint &viewport_pos) @@ -120,7 +122,7 @@ void DiagramGestureOverlay::paintEvent(QPaintEvent *event) QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); const QPointF c(overlay_width / 2, outer_radius + 1); - const qreal step = 360.0 / sectors; + const qreal step = 360.0 / m_sectors; QColor base = palette().color(QPalette::Window); base.setAlpha(225); @@ -130,7 +132,7 @@ void DiagramGestureOverlay::paintEvent(QPaintEvent *event) //Highlight the sector the mouse points at, if it holds a command if (m_active >= 0 && m_active < m_actions.count() - && m_actions.at(m_active)->isEnabled()) + && m_actions.at(m_active) && m_actions.at(m_active)->isEnabled()) { QPainterPath wedge; wedge.moveTo(c); @@ -151,6 +153,9 @@ void DiagramGestureOverlay::paintEvent(QPaintEvent *event) for (int i = 0 ; i < m_actions.count() ; ++i) { QAction *action = m_actions.at(i); + if (!action) { + continue; + } const qreal a = qDegreesToRadians(i * step); const QPointF p(c.x() + icon_radius * qSin(a), c.y() - icon_radius * qCos(a)); const QRect r(int(p.x()) - icon_size / 2, int(p.y()) - icon_size / 2, @@ -167,7 +172,7 @@ void DiagramGestureOverlay::paintEvent(QPaintEvent *event) } //Name of the highlighted command, under the ring - if (m_active >= 0 && m_active < m_actions.count()) + if (m_active >= 0 && m_active < m_actions.count() && m_actions.at(m_active)) { const QString name = m_actions.at(m_active)->text().remove(QLatin1Char('&')); const QRect label(0, 2 * outer_radius + 4, width(), 20); diff --git a/sources/diagramgestureoverlay.h b/sources/diagramgestureoverlay.h index 5fca8dfc2..a2bbb4077 100644 --- a/sources/diagramgestureoverlay.h +++ b/sources/diagramgestureoverlay.h @@ -29,8 +29,9 @@ class QAction; mouse's direction highlighted. Releasing runs it, releasing near the centre cancels -- SolidWorks' mouse gestures. - Commands are placed clockwise from the top. The ring only draws; the - view tracks the mouse and asks sectorAt() on release. + Commands are placed clockwise from the top, one per direction (4 or 8). + A null entry is an empty direction. The ring only draws; the view + tracks the mouse and asks sectorAt() on release. */ class DiagramGestureOverlay : public QWidget { @@ -39,14 +40,15 @@ class DiagramGestureOverlay : public QWidget public: explicit DiagramGestureOverlay(QWidget *viewport); - void showAt(const QPoint ¢er, const QList &actions); + void showAt(const QPoint ¢er, const QList &actions, + int sectors = 8); void setPointer(const QPoint &viewport_pos); int sectorAt(const QPoint &viewport_pos) const; QAction *actionAt(const QPoint &viewport_pos) const; - static bool isEnabled(); + int sectors() const { return m_sectors; } - static const int sectors = 8; + static bool isEnabled(); protected: void paintEvent(QPaintEvent *event) override; @@ -55,6 +57,7 @@ class DiagramGestureOverlay : public QWidget QPoint m_center; QList m_actions; int m_active = -1; + int m_sectors = 8; }; #endif // DIAGRAMGESTUREOVERLAY_H diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 0e67bf5ac..88120f9a3 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -42,6 +42,7 @@ #include "diagram.h" #include "diagramcontexttoolbar.h" #include "diagramgestureoverlay.h" +#include "gesturesettings.h" #include "shortcutbarsettings.h" #include "shortcutmanager.h" #include "ElementsCollection/xmlelementcollection.h" @@ -794,7 +795,8 @@ void DiagramView::mouseMoveEvent(QMouseEvent *e) m_diagram->clearEventInterface(); m_swallow_native_menu = true; } - m_gesture_overlay->showAt(m_gesture_origin, selectionCommands()); + m_gesture_overlay->showAt(m_gesture_origin, gestureCommands(), + GestureSettings::directions()); } if (m_gesture_overlay->isVisible()) { m_gesture_overlay->setPointer(pos); @@ -981,6 +983,28 @@ QList DiagramView::selectionCommands() const return actions; } +/** + @brief DiagramView::gestureCommands + @return the gesture ring's commands for the current selection, as this + window's actions. A list the user picked keeps its directions: a + command this window lacks leaves its direction empty (nullptr). + Otherwise the ring shows the shortcut bar's commands, in order. +*/ +QList DiagramView::gestureCommands() const +{ + QETDiagramEditor *qde = diagramEditor(); + const auto context = ShortcutBarSettings::contextFor(m_diagram->selectedItems()); + if (!qde || !GestureSettings::isCustom(context)) { + return selectionCommands(); + } + QList actions; + for (const QString &id : GestureSettings::ids(context)) { + actions << (id.isEmpty() ? nullptr + : ShortcutManager::instance().action(id, qde)); + } + return actions; +} + /** @brief DiagramView::showContextToolbar After a click that leaves something selected, show the shortcut bar's diff --git a/sources/diagramview.h b/sources/diagramview.h index ebf3fd1f8..e7a99827d 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -154,6 +154,7 @@ class DiagramView : public PaletteGraphicsView void placeCellRulers(); void showContextToolbar(const QPoint &viewport_pos); QList selectionCommands() const; + QList gestureCommands() const; /// Lowest and highest allowed value of the view transform scale (m11). /// Prevents wheel-zoom from driving the transform to overflow, which diff --git a/sources/gesturesettings.cpp b/sources/gesturesettings.cpp new file mode 100644 index 000000000..298c4a3c5 --- /dev/null +++ b/sources/gesturesettings.cpp @@ -0,0 +1,125 @@ +/* + 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 "gesturesettings.h" + +#include + +namespace { +const QString directions_key = QStringLiteral("diagrameditor/gestures/directions"); + +QString settingsKey(ShortcutBarSettings::Context context) +{ + switch (context) + { + case ShortcutBarSettings::Canvas: + return QStringLiteral("diagrameditor/gestures/canvas"); + case ShortcutBarSettings::Selection: + return QStringLiteral("diagrameditor/gestures/selection"); + case ShortcutBarSettings::Conductor: + return QStringLiteral("diagrameditor/gestures/conductor"); + } + return QString(); +} +} + +/** + @return how many directions the ring has: 8 (the default) or 4 +*/ +int GestureSettings::directions() +{ + return QSettings().value(directions_key, 8).toInt() == 4 ? 4 : 8; +} + +/** + @brief GestureSettings::setDirections + Save 4 or 8 directions. 8 is the default and removes the key. +*/ +void GestureSettings::setDirections(int directions) +{ + QSettings settings; + if (directions == 4) { + settings.setValue(directions_key, 4); + } else { + settings.remove(directions_key); + } +} + +/** + @return true if the user picked the commands for @a context, false + when the ring follows the shortcut bar +*/ +bool GestureSettings::isCustom(ShortcutBarSettings::Context context) +{ + return QSettings().contains(settingsKey(context)); +} + +/** + @return the ids on the ring for @a context: the user's list if they + picked one, the defaults otherwise +*/ +QStringList GestureSettings::ids(ShortcutBarSettings::Context context) +{ + QSettings settings; + const QString key = settingsKey(context); + if (!settings.contains(key)) { + return defaultIds(context); + } + return settings.value(key).toStringList(); +} + +/** + @return the shortcut bar's commands for @a context, without its pinned + elements: what the ring has shown since it was added +*/ +QStringList GestureSettings::defaultIds(ShortcutBarSettings::Context context) +{ + QStringList ids; + for (const QString &id : ShortcutBarSettings::ids(context)) { + if (!ShortcutBarSettings::isElement(id)) { + ids << id; + } + } + return ids; +} + +/** + @brief GestureSettings::setIds + Save @a ids for @a context; trailing empty directions are dropped. + Saving the defaults removes the key, so the ring follows the shortcut + bar again. +*/ +void GestureSettings::setIds(ShortcutBarSettings::Context context, QStringList ids) +{ + while (!ids.isEmpty() && ids.constLast().isEmpty()) { + ids.removeLast(); + } + if (ids == defaultIds(context)) { + reset(context); + } else { + QSettings().setValue(settingsKey(context), ids); + } +} + +/** + @brief GestureSettings::reset + Make the ring for @a context follow the shortcut bar again. +*/ +void GestureSettings::reset(ShortcutBarSettings::Context context) +{ + QSettings().remove(settingsKey(context)); +} diff --git a/sources/gesturesettings.h b/sources/gesturesettings.h new file mode 100644 index 000000000..2089e877f --- /dev/null +++ b/sources/gesturesettings.h @@ -0,0 +1,47 @@ +/* + 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 GESTURESETTINGS_H +#define GESTURESETTINGS_H + +#include "shortcutbarsettings.h" + +#include + +/** + @brief The commands on the mouse gesture ring (right-drag on a folio). + + Until the user picks them, the ring shows the shortcut bar's commands + for the selection, as it always has. A picked list is stored in + QSettings, one key per context, and is positional: entry i is the + command in direction i, clockwise from the top, and an empty entry is + an empty direction. Pinned elements are never on the ring. +*/ +class GestureSettings +{ + public: + static int directions(); + static void setDirections(int directions); + + static bool isCustom(ShortcutBarSettings::Context context); + static QStringList ids(ShortcutBarSettings::Context context); + static QStringList defaultIds(ShortcutBarSettings::Context context); + static void setIds(ShortcutBarSettings::Context context, QStringList ids); + static void reset(ShortcutBarSettings::Context context); +}; + +#endif // GESTURESETTINGS_H diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index ccd4981c7..d2bc69535 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -41,6 +41,7 @@ #include "ui/aboutqetdialog.h" #include "ui/configpage/generalconfigurationpage.h" #include "ui/configpage/shortcutsconfigpage.h" +#include "ui/configpage/gesturesconfigpage.h" #include "ui/configpage/shortcutbarconfigpage.h" #include "ui/configpage/toolbarsconfigpage.h" #include "machine_info.h" @@ -2226,6 +2227,7 @@ void QETApp::configureQET() cd.addPage(new PrintConfigPage()); cd.addPage(new ShortcutsConfigPage()); cd.addPage(new ShortcutBarConfigPage()); + cd.addPage(new GesturesConfigPage()); cd.addPage(new ToolbarsConfigPage()); #ifdef QET_SPACEMOUSE_SUPPORT cd.addPage(new SpaceMouseConfigPage()); diff --git a/sources/ui/configpage/gesturesconfigpage.cpp b/sources/ui/configpage/gesturesconfigpage.cpp new file mode 100644 index 000000000..a14d80ef3 --- /dev/null +++ b/sources/ui/configpage/gesturesconfigpage.cpp @@ -0,0 +1,475 @@ +/* + 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 "gesturesconfigpage.h" + +#include "../../gesturesettings.h" +#include "../../qeticons.h" +#include "../../shortcutmanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +const int ring_radius = 100; +const int ring_inner = 26; +const int slot_radius = 68; +const int slot_icon = 24; + /// What QListWidget puts on a drag +const QString list_mime = QStringLiteral("application/x-qabstractitemmodeldatalist"); + +/** + @return the command id carried by a drag from the command list, or an + empty string +*/ +QString droppedId(const QMimeData *mime) +{ + if (!mime || !mime->hasFormat(list_mime)) { + return QString(); + } + QByteArray data = mime->data(list_mime); + QDataStream stream(&data, QIODevice::ReadOnly); + int row, column; + QMap roles; + stream >> row >> column >> roles; + return roles.value(Qt::UserRole).toString(); +} +} + +GestureRingEditor::GestureRingEditor(QWidget *parent) : + QWidget(parent) +{ + setAcceptDrops(true); + setFocusPolicy(Qt::StrongFocus); + setMinimumSize(sizeHint()); +} + +QSize GestureRingEditor::sizeHint() const +{ + return QSize(2 * ring_radius + 8, 2 * ring_radius + 8); +} + +void GestureRingEditor::setIds(const QStringList &ids) +{ + m_ids = ids; + update(); +} + +void GestureRingEditor::setDirections(int directions) +{ + m_directions = directions == 4 ? 4 : 8; + setCurrentSlot(qMin(m_current, m_directions - 1)); + update(); +} + +void GestureRingEditor::setDescriptions(const QHash &descriptions) +{ + m_descriptions = descriptions; + update(); +} + +void GestureRingEditor::setCurrentSlot(int slot) +{ + if (slot < 0 || slot >= m_directions) { + return; + } + const bool changed = slot != m_current; + m_current = slot; + update(); + if (changed) { + emit currentSlotChanged(slot); + } +} + +/** + @return the slot in the direction of @a pos from the centre, 0 at the + top then clockwise, or -1 at the centre or outside the ring +*/ +int GestureRingEditor::slotAt(const QPoint &pos) const +{ + const QPointF d = QPointF(pos) - QPointF(rect().center()); + const qreal distance = qHypot(d.x(), d.y()); + if (distance < ring_inner || distance > ring_radius) { + return -1; + } + qreal angle = qRadiansToDegrees(qAtan2(d.x(), -d.y())); + if (angle < 0) { + angle += 360; + } + return int(qRound(angle / (360.0 / m_directions))) % m_directions; +} + +/** + @brief GestureRingEditor::place + Put @a id in the current slot. A command already elsewhere on the ring + moves here, so a command has one direction. +*/ +void GestureRingEditor::place(const QString &id) +{ + if (id.isEmpty()) { + return; + } + const int old = m_ids.indexOf(id); + if (old >= 0) { + m_ids[old].clear(); + } + while (m_ids.count() <= m_current) { + m_ids << QString(); + } + m_ids[m_current] = id; + update(); + emit edited(); +} + +void GestureRingEditor::clearCurrent() +{ + if (m_current < m_ids.count() && !m_ids.at(m_current).isEmpty()) { + m_ids[m_current].clear(); + update(); + emit edited(); + } +} + +QString GestureRingEditor::idAt(int slot) const +{ + return slot < m_ids.count() ? m_ids.at(slot) : QString(); +} + +QPointF GestureRingEditor::slotCenter(int slot) const +{ + const qreal a = qDegreesToRadians(slot * 360.0 / m_directions); + const QPointF c = QRectF(rect()).center(); + return QPointF(c.x() + slot_radius * qSin(a), c.y() - slot_radius * qCos(a)); +} + +void GestureRingEditor::paintEvent(QPaintEvent *event) +{ + Q_UNUSED(event) + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QPointF c = QRectF(rect()).center(); + const qreal step = 360.0 / m_directions; + const QRectF outer(c.x() - ring_radius, c.y() - ring_radius, + 2 * ring_radius, 2 * ring_radius); + + QColor line = palette().color(QPalette::Text); + line.setAlpha(110); + painter.setPen(line); + painter.setBrush(palette().color(QPalette::Base)); + painter.drawEllipse(outer); + + //The chosen slot + QPainterPath wedge; + wedge.moveTo(c); + wedge.arcTo(outer, 90 - m_current * step - step / 2, step); + wedge.closeSubpath(); + painter.setPen(Qt::NoPen); + QColor highlight = palette().color(QPalette::Highlight); + if (!hasFocus()) { + highlight.setAlpha(140); + } + painter.setBrush(highlight); + painter.drawPath(wedge); + + //Lines between the slots + painter.setPen(line); + for (int i = 0 ; i < m_directions ; ++i) { + const qreal a = qDegreesToRadians(i * step + step / 2); + painter.drawLine(QPointF(c.x() + ring_inner * qSin(a), c.y() - ring_inner * qCos(a)), + QPointF(c.x() + ring_radius * qSin(a), c.y() - ring_radius * qCos(a))); + } + painter.setBrush(palette().color(QPalette::Window)); + painter.drawEllipse(c, ring_inner, ring_inner); + + for (int i = 0 ; i < m_directions ; ++i) + { + const QString id = idAt(i); + if (id.isEmpty()) { + continue; + } + const QPointF p = slotCenter(i); + const QRect r(int(p.x()) - slot_icon / 2, int(p.y()) - slot_icon / 2, + slot_icon, slot_icon); + QAction *action = ShortcutManager::instance().action(id, nullptr); + if (action && !action->icon().isNull()) { + action->icon().paint(&painter, r); + } else { + painter.setPen(palette().color(i == m_current ? QPalette::HighlightedText + : QPalette::Text)); + painter.drawText(r.adjusted(-12, 0, 12, 0), Qt::AlignCenter, + m_descriptions.value(id, id).left(3)); + } + } +} + +void GestureRingEditor::mousePressEvent(QMouseEvent *event) +{ + setCurrentSlot(slotAt(event->position().toPoint())); + event->accept(); +} + +void GestureRingEditor::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Delete || event->key() == Qt::Key_Backspace) { + clearCurrent(); + } else if (event->key() == Qt::Key_Right || event->key() == Qt::Key_Down) { + setCurrentSlot((m_current + 1) % m_directions); + } else if (event->key() == Qt::Key_Left || event->key() == Qt::Key_Up) { + setCurrentSlot((m_current + m_directions - 1) % m_directions); + } else { + QWidget::keyPressEvent(event); + } +} + +void GestureRingEditor::dragEnterEvent(QDragEnterEvent *event) +{ + if (!droppedId(event->mimeData()).isEmpty()) { + event->acceptProposedAction(); + } +} + +void GestureRingEditor::dragMoveEvent(QDragMoveEvent *event) +{ + const int slot = slotAt(event->position().toPoint()); + if (slot < 0) { + event->ignore(); + return; + } + setCurrentSlot(slot); + event->acceptProposedAction(); +} + +void GestureRingEditor::dropEvent(QDropEvent *event) +{ + const int slot = slotAt(event->position().toPoint()); + const QString id = droppedId(event->mimeData()); + if (slot < 0 || id.isEmpty()) { + event->ignore(); + return; + } + setCurrentSlot(slot); + place(id); + event->acceptProposedAction(); +} + +/** + @brief GesturesConfigPage::GesturesConfigPage + @param parent +*/ +GesturesConfigPage::GesturesConfigPage(QWidget *parent) : + ConfigPage(parent) +{ + const QStringList available = ShortcutBarSettings::availableIds(); + for (const ShortcutManager::ShortcutInfo &info : + ShortcutManager::instance().allShortcuts()) { + if (available.contains(info.id)) { + m_descriptions.insert(info.id, info.description); + } + } + for (const ShortcutBarSettings::Context c : ShortcutBarSettings::contexts()) { + m_pending.insert(c, GestureSettings::ids(c)); + m_custom.insert(c, GestureSettings::isCustom(c)); + } + + auto *explanation = new QLabel( + tr("Faites glisser avec le bouton droit sur un folio : un anneau " + "de commandes s'ouvre, relâchez vers celle à lancer. Tant que " + "vous ne changez pas l'anneau d'un contexte, il montre les " + "commandes de la barre de raccourcis. Les gestes s'activent " + "ou se désactivent dans la page Général."), this); + explanation->setWordWrap(true); + + m_directions = new QComboBox(this); + m_directions->setObjectName(QStringLiteral("directionsCombo")); + m_directions->addItem(tr("8 directions"), 8); + m_directions->addItem(tr("4 directions"), 4); + m_directions->setCurrentIndex(m_directions->findData(GestureSettings::directions())); + + m_context = new QComboBox(this); + m_context->setObjectName(QStringLiteral("contextCombo")); + for (const ShortcutBarSettings::Context c : ShortcutBarSettings::contexts()) { + m_context->addItem(ShortcutBarSettings::title(c), c); + } + + m_available = new QListWidget(this); + m_available->setObjectName(QStringLiteral("availableList")); + m_available->setSortingEnabled(true); + m_available->setDragEnabled(true); + m_available->setDragDropMode(QAbstractItemView::DragOnly); + for (auto it = m_descriptions.cbegin(); it != m_descriptions.cend(); ++it) { + QIcon icon; + if (QAction *action = ShortcutManager::instance().action(it.key(), nullptr)) { + icon = action->icon(); + } + auto *item = new QListWidgetItem(icon, it.value(), m_available); + item->setData(Qt::UserRole, it.key()); + } + + m_ring = new GestureRingEditor(this); + m_ring->setObjectName(QStringLiteral("ringEditor")); + m_ring->setDescriptions(m_descriptions); + m_ring->setDirections(GestureSettings::directions()); + + m_slot_name = new QLabel(this); + m_slot_name->setAlignment(Qt::AlignCenter); + m_slot_name->setWordWrap(true); + m_state = new QLabel(this); + m_state->setWordWrap(true); + + auto *place = new QPushButton(tr("Placer →"), this); + place->setObjectName(QStringLiteral("placeButton")); + auto *clear = new QPushButton(tr("Vider la direction"), this); + auto *follow = new QPushButton(tr("Comme la barre de raccourcis"), this); + follow->setObjectName(QStringLiteral("followBarButton")); + + auto *ring_column = new QVBoxLayout(); + ring_column->addWidget(m_ring, 0, Qt::AlignHCenter); + ring_column->addWidget(m_slot_name); + ring_column->addWidget(clear); + ring_column->addStretch(); + + auto *grid = new QGridLayout(); + grid->addWidget(new QLabel(tr("Commandes disponibles"), this), 0, 0); + grid->addWidget(new QLabel(tr("Anneau, la direction choisie en surbrillance"), this), 0, 2); + grid->addWidget(m_available, 1, 0); + grid->addWidget(place, 1, 1, Qt::AlignVCenter); + grid->addLayout(ring_column, 1, 2); + grid->setColumnStretch(0, 1); + grid->setColumnStretch(2, 1); + + auto *top_row = new QHBoxLayout(); + top_row->addWidget(new QLabel(tr("Directions :"), this)); + top_row->addWidget(m_directions); + top_row->addSpacing(12); + top_row->addWidget(new QLabel(tr("Contexte :"), this)); + top_row->addWidget(m_context, 1); + + auto *state_row = new QHBoxLayout(); + state_row->addWidget(m_state, 1); + state_row->addWidget(follow); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(explanation); + layout->addLayout(top_row); + layout->addLayout(state_row); + layout->addLayout(grid); + + connect(m_directions, qOverload(&QComboBox::currentIndexChanged), this, [this]() { + m_ring->setDirections(m_directions->currentData().toInt()); + updateState(); + }); + connect(m_context, qOverload(&QComboBox::currentIndexChanged), + this, &GesturesConfigPage::showContext); + connect(m_ring, &GestureRingEditor::edited, this, [this]() { + m_pending.insert(m_shown, m_ring->ids()); + m_custom.insert(m_shown, true); + updateState(); + }); + connect(m_ring, &GestureRingEditor::currentSlotChanged, + this, &GesturesConfigPage::updateState); + connect(place, &QPushButton::clicked, this, &GesturesConfigPage::placeSelected); + connect(m_available, &QListWidget::itemDoubleClicked, + this, &GesturesConfigPage::placeSelected); + connect(clear, &QPushButton::clicked, m_ring, &GestureRingEditor::clearCurrent); + connect(follow, &QPushButton::clicked, this, &GesturesConfigPage::followBar); + + showContext(); +} + +/** + @brief GesturesConfigPage::applyConf + Save the directions and every context's ring. +*/ +void GesturesConfigPage::applyConf() +{ + GestureSettings::setDirections(m_directions->currentData().toInt()); + for (const ShortcutBarSettings::Context c : ShortcutBarSettings::contexts()) { + if (m_custom.value(c)) { + GestureSettings::setIds(c, m_pending.value(c)); + } else { + GestureSettings::reset(c); + } + } +} + +QString GesturesConfigPage::title() const +{ + return tr("Gestes de la souris", "configuration page title"); +} + +QIcon GesturesConfigPage::icon() const +{ + return QET::Icons::ConfigureShortcuts; +} + +void GesturesConfigPage::showContext() +{ + m_shown = static_cast( + m_context->currentData().toInt()); + m_ring->setIds(m_pending.value(m_shown)); + updateState(); +} + +void GesturesConfigPage::placeSelected() +{ + if (QListWidgetItem *item = m_available->currentItem()) { + m_ring->place(item->data(Qt::UserRole).toString()); + } +} + +/** + @brief GesturesConfigPage::followBar + Make the shown context's ring follow the shortcut bar again. +*/ +void GesturesConfigPage::followBar() +{ + m_custom.insert(m_shown, false); + m_pending.insert(m_shown, GestureSettings::defaultIds(m_shown)); + m_ring->setIds(m_pending.value(m_shown)); + updateState(); +} + +/** + @brief GesturesConfigPage::updateState + Name the chosen direction's command, and say whether the shown ring is + the user's or the shortcut bar's. +*/ +void GesturesConfigPage::updateState() +{ + const int slot = m_ring->currentSlot(); + const QStringList ids = m_ring->ids(); + const QString id = slot < ids.count() ? ids.at(slot) : QString(); + m_slot_name->setText(id.isEmpty() ? tr("(direction vide)") + : m_descriptions.value(id, id)); + m_state->setText(m_custom.value(m_shown) + ? tr("Anneau personnalisé pour ce contexte.") + : tr("Cet anneau suit la barre de raccourcis.")); +} diff --git a/sources/ui/configpage/gesturesconfigpage.h b/sources/ui/configpage/gesturesconfigpage.h new file mode 100644 index 000000000..7659fca0c --- /dev/null +++ b/sources/ui/configpage/gesturesconfigpage.h @@ -0,0 +1,112 @@ +/* + 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 GESTURESCONFIGPAGE_H +#define GESTURESCONFIGPAGE_H + +#include "configpage.h" +#include "../../shortcutbarsettings.h" + +#include +#include + +class QComboBox; +class QLabel; +class QListWidget; + +/** + @brief The gesture ring as an editor: one slot per direction, clockwise + from the top. Click a slot to choose it; drop a command from the + command list on a slot to put it there; Delete empties the chosen slot. +*/ +class GestureRingEditor : public QWidget +{ + Q_OBJECT + + public: + explicit GestureRingEditor(QWidget *parent = nullptr); + + void setIds(const QStringList &ids); + QStringList ids() const { return m_ids; } + void setDirections(int directions); + void setDescriptions(const QHash &descriptions); + int currentSlot() const { return m_current; } + void setCurrentSlot(int slot); + int slotAt(const QPoint &pos) const; + void place(const QString &id); + void clearCurrent(); + + QSize sizeHint() const override; + + signals: + void edited(); + void currentSlotChanged(int slot); + + protected: + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void dragEnterEvent(QDragEnterEvent *event) override; + void dragMoveEvent(QDragMoveEvent *event) override; + void dropEvent(QDropEvent *event) override; + + private: + QString idAt(int slot) const; + QPointF slotCenter(int slot) const; + + QStringList m_ids; + QHash m_descriptions; + int m_directions = 8; + int m_current = 0; +}; + +/** + @brief The GesturesConfigPage class + Choose 4 or 8 directions for the mouse gesture ring, and which command + sits in each direction for each selection context. A context the user + does not edit keeps following the shortcut bar. Saved by applyConf(). +*/ +class GesturesConfigPage : public ConfigPage +{ + Q_OBJECT + + public: + explicit GesturesConfigPage(QWidget *parent = nullptr); + + void applyConf() override; + QString title() const override; + QIcon icon() const override; + + private: + void showContext(); + void placeSelected(); + void followBar(); + void updateState(); + + QComboBox *m_directions; + QComboBox *m_context; + QListWidget *m_available; + GestureRingEditor *m_ring; + QLabel *m_slot_name; + QLabel *m_state; + ShortcutBarSettings::Context m_shown = ShortcutBarSettings::Canvas; + QHash m_pending; + QHash m_custom; + QHash m_descriptions; +}; + +#endif // GESTURESCONFIGPAGE_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 1a9ad8487..fb4227196 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -298,6 +298,29 @@ set_tests_properties(tst_toolbarsettings PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM target_include_directories(tst_toolbarsettings PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_toolbarsettings PRIVATE Qt::Test Qt::Widgets Qt::Svg) +# GestureSettings, the gesture ring's 4 / 8 directions and empty slots, and +# its configuration page: the ring follows the shortcut bar until the user +# picks commands, then keeps each one in its direction. +add_executable( + tst_gesturesettings + tst_gesturesettings.cpp + ${QET_DIR}/sources/gesturesettings.cpp + ${QET_DIR}/sources/gesturesettings.h + ${QET_DIR}/sources/shortcutbarsettings.cpp + ${QET_DIR}/sources/diagramgestureoverlay.cpp + ${QET_DIR}/sources/diagramgestureoverlay.h + ${QET_DIR}/sources/ui/configpage/gesturesconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/gesturesconfigpage.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_gesturesettings COMMAND tst_gesturesettings) +set_tests_properties(tst_gesturesettings PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_gesturesettings PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_gesturesettings PRIVATE Qt::Test Qt::Widgets Qt::Svg Qt::Xml pugixml::pugixml) + # scriptheader.h is header-only: the // ==QETScript== block that turns a # stored script into a button; and ShortcutManager::unregisterAction(), which # takes a deleted script's command out of the lists again. diff --git a/tests/qttest/tst_gesturesettings.cpp b/tests/qttest/tst_gesturesettings.cpp new file mode 100644 index 000000000..b06930ab5 --- /dev/null +++ b/tests/qttest/tst_gesturesettings.cpp @@ -0,0 +1,214 @@ +/* + 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 "diagramgestureoverlay.h" +#include "gesturesettings.h" +#include "shortcutmanager.h" +#include "ui/configpage/gesturesconfigpage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + The mouse gesture ring's own command list: until the user picks one it + follows the shortcut bar as before; a picked list keeps its directions; + 4 or 8 directions; and the configuration page that edits it. +*/ +class tst_gesturesettings : public QObject +{ + Q_OBJECT + + QMainWindow m_window; + + QAction *addCommand(const QString &id, const QString &text) + { + auto *action = new QAction(text, &m_window); + ShortcutManager::instance().registerAction( + action, id, QStringLiteral("Éditeur de schémas"), QKeySequence()); + return action; + } + + QListWidgetItem *listItem(QListWidget *list, const QString &id) + { + for (int i = 0 ; i < list->count() ; ++i) { + if (list->item(i)->data(Qt::UserRole).toString() == id) { + return list->item(i); + } + } + return nullptr; + } + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + QSettings().remove(QStringLiteral("diagrameditor")); + addCommand(QStringLiteral("diagrameditor.copy"), QStringLiteral("Copier")); + addCommand(QStringLiteral("diagrameditor.cut"), QStringLiteral("Couper")); + addCommand(QStringLiteral("diagrameditor.paste"), QStringLiteral("Coller")); + } + + void cleanup() + { + QSettings().remove(QStringLiteral("diagrameditor")); + } + + void defaultsFollowTheBar() + { + QCOMPARE(GestureSettings::directions(), 8); + for (const auto c : ShortcutBarSettings::contexts()) { + QVERIFY(!GestureSettings::isCustom(c)); + QCOMPARE(GestureSettings::ids(c), ShortcutBarSettings::ids(c)); + } + //A changed bar still reaches the ring, without its elements + ShortcutBarSettings::setIds(ShortcutBarSettings::Selection, + {QStringLiteral("diagrameditor.copy"), + QStringLiteral("common://10_electric/relay.elmt"), + QStringLiteral("diagrameditor.cut")}); + QCOMPARE(GestureSettings::ids(ShortcutBarSettings::Selection), + QStringList({QStringLiteral("diagrameditor.copy"), + QStringLiteral("diagrameditor.cut")})); + } + + void pickedListKeepsDirections() + { + GestureSettings::setIds(ShortcutBarSettings::Canvas, + {QStringLiteral("diagrameditor.copy"), QString(), + QStringLiteral("diagrameditor.paste"), QString(), QString()}); + QVERIFY(GestureSettings::isCustom(ShortcutBarSettings::Canvas)); + QCOMPARE(GestureSettings::ids(ShortcutBarSettings::Canvas), + QStringList({QStringLiteral("diagrameditor.copy"), QString(), + QStringLiteral("diagrameditor.paste")})); + //The bar no longer drives it + ShortcutBarSettings::setIds(ShortcutBarSettings::Canvas, + {QStringLiteral("diagrameditor.cut")}); + QCOMPARE(GestureSettings::ids(ShortcutBarSettings::Canvas).first(), + QStringLiteral("diagrameditor.copy")); + //Other contexts still follow the bar + QVERIFY(!GestureSettings::isCustom(ShortcutBarSettings::Selection)); + } + + void savingTheDefaultsFollowsTheBarAgain() + { + GestureSettings::setIds(ShortcutBarSettings::Conductor, {QStringLiteral("diagrameditor.cut")}); + QVERIFY(GestureSettings::isCustom(ShortcutBarSettings::Conductor)); + GestureSettings::setIds(ShortcutBarSettings::Conductor, + GestureSettings::defaultIds(ShortcutBarSettings::Conductor)); + QVERIFY(!GestureSettings::isCustom(ShortcutBarSettings::Conductor)); + } + + void directions() + { + GestureSettings::setDirections(4); + QCOMPARE(GestureSettings::directions(), 4); + GestureSettings::setDirections(8); + QVERIFY(!QSettings().contains(QStringLiteral("diagrameditor/gestures/directions"))); + QSettings().setValue(QStringLiteral("diagrameditor/gestures/directions"), 5); + QCOMPARE(GestureSettings::directions(), 8); + } + + void overlayDirectionsAndEmptySlots() + { + QWidget viewport; + viewport.resize(400, 400); + DiagramGestureOverlay overlay(&viewport); + QAction *copy = ShortcutManager::instance().action(QStringLiteral("diagrameditor.copy"), &m_window); + QAction *paste = ShortcutManager::instance().action(QStringLiteral("diagrameditor.paste"), &m_window); + const QPoint c(200, 200); + const QPoint right(260, 200), down(200, 260); + + overlay.showAt(c, {copy, nullptr, paste}, 4); + QCOMPARE(overlay.sectors(), 4); + QCOMPARE(overlay.sectorAt(right), 1); + QCOMPARE(overlay.actionAt(right), nullptr); + QCOMPARE(overlay.actionAt(down), paste); + overlay.setPointer(right); + QVERIFY(!overlay.grab().isNull()); + + //Today's ring: eight directions + overlay.showAt(c, {copy, nullptr, paste}); + QCOMPARE(overlay.sectors(), 8); + QCOMPARE(overlay.sectorAt(right), 2); + QCOMPARE(overlay.actionAt(right), paste); + } + + void pagePlacesAndSaves() + { + { + GesturesConfigPage page; + auto *directions = page.findChild(QStringLiteral("directionsCombo")); + auto *list = page.findChild(QStringLiteral("availableList")); + auto *ring = page.findChild(QStringLiteral("ringEditor")); + QCOMPARE(directions->currentData().toInt(), 8); + QCOMPARE(ring->ids(), GestureSettings::defaultIds(ShortcutBarSettings::Canvas)); + + directions->setCurrentIndex(directions->findData(4)); + ring->setCurrentSlot(1); + list->setCurrentItem(listItem(list, QStringLiteral("diagrameditor.cut"))); + page.findChild(QStringLiteral("placeButton"))->click(); + page.applyConf(); + } + QCOMPARE(GestureSettings::directions(), 4); + QVERIFY(GestureSettings::isCustom(ShortcutBarSettings::Canvas)); + QCOMPARE(GestureSettings::ids(ShortcutBarSettings::Canvas).at(1), + QStringLiteral("diagrameditor.cut")); + //Contexts left alone still follow the bar + QVERIFY(!GestureSettings::isCustom(ShortcutBarSettings::Selection)); + + { + GesturesConfigPage page; + page.findChild(QStringLiteral("followBarButton"))->click(); + page.applyConf(); + } + QVERIFY(!GestureSettings::isCustom(ShortcutBarSettings::Canvas)); + } + + void dropOnASlot() + { + GesturesConfigPage page; + auto *list = page.findChild(QStringLiteral("availableList")); + auto *ring = page.findChild(QStringLiteral("ringEditor")); + ring->resize(ring->sizeHint()); + const QModelIndex index = list->model()->index( + list->row(listItem(list, QStringLiteral("diagrameditor.paste"))), 0); + QMimeData *mime = list->model()->mimeData({index}); + //The right-hand slot of eight + const QPointF target = QRectF(ring->rect()).center() + QPointF(68, 0); + QCOMPARE(ring->slotAt(target.toPoint()), 2); + QDropEvent drop(target, Qt::CopyAction, mime, Qt::LeftButton, Qt::NoModifier); + //Straight to the widget: QApplication drops drag events when + //no real drag is running + QVERIFY(static_cast(ring)->event(&drop)); + QVERIFY(drop.isAccepted()); + QCOMPARE(ring->currentSlot(), 2); + QCOMPARE(ring->ids().value(2), QStringLiteral("diagrameditor.paste")); + //Moved, not copied: paste was already on the default canvas ring + QCOMPARE(ring->ids().count(QStringLiteral("diagrameditor.paste")), 1); + delete mime; + } +}; + +QTEST_MAIN(tst_gesturesettings) +#include "tst_gesturesettings.moc" From 0fef6b19cea3be48e4a4a353b87166fe39855817 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 19:31:42 +1300 Subject: [PATCH 26/26] Add a Customise window: toolbars, shortcut bar, keyboard, gestures Configuration > Personnaliser... (also at the bottom of a toolbar's right-click menu) opens one window with a tab for each page that sets how the user works: Barres d'outils, Contenu des barres, Barre de raccourcis, Raccourcis and Gestes de la souris, like SolidWorks' Tools > Customize. The tabs are the same pages the configuration dialog shows, which keeps them. OK applies every tab, Cancel none. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 2 + sources/qetapp.cpp | 32 ++++++++++ sources/qetapp.h | 1 + sources/qetmainwindow.cpp | 22 +++++++ sources/qetmainwindow.h | 2 + sources/ui/customizedialog.cpp | 77 +++++++++++++++++++++++ sources/ui/customizedialog.h | 51 +++++++++++++++ tests/qttest/CMakeLists.txt | 13 ++++ tests/qttest/tst_customizedialog.cpp | 93 ++++++++++++++++++++++++++++ 9 files changed, 293 insertions(+) create mode 100644 sources/ui/customizedialog.cpp create mode 100644 sources/ui/customizedialog.h create mode 100644 tests/qttest/tst_customizedialog.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index c259b6d46..6f5cbd9b6 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -862,6 +862,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.h ${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.cpp ${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.h + ${QET_DIR}/sources/ui/customizedialog.cpp + ${QET_DIR}/sources/ui/customizedialog.h ${QET_DIR}/sources/ui/configpage/toolbarcommandsconfigpage.cpp ${QET_DIR}/sources/ui/configpage/toolbarcommandsconfigpage.h ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.cpp diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index 6d7f03356..62390bab9 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -45,6 +45,8 @@ #include "ui/configpage/shortcutbarconfigpage.h" #include "ui/configpage/toolbarsconfigpage.h" #include "ui/configpage/toolbarcommandsconfigpage.h" +#include "ui/customizedialog.h" +#include #include "machine_info.h" #include "TerminalStrip/ui/terminalstripeditorwindow.h" #include "qetversion.h" @@ -2258,6 +2260,36 @@ void QETApp::configureQET() #endif } +/** + @brief QETApp::customizeQET + Open the Customise window: the toolbar, shortcut bar, keyboard and + gesture pages of the configuration dialog, as tabs of one window. + @param tab : the tab to show first +*/ +void QETApp::customizeQET(int tab) +{ + QWidget *parent_widget = qApp->activeWindow(); + + CustomizeDialog dialog; + //Same reason as the configuration dialog (#527) + dialog.setWindowModality(Qt::ApplicationModal); + dialog.addPage(new ToolbarsConfigPage()); + dialog.addPage(new ToolbarCommandsConfigPage()); + dialog.addPage(new ShortcutBarConfigPage()); + dialog.addPage(new ShortcutsConfigPage()); + dialog.addPage(new GesturesConfigPage()); + if (auto *tabs = dialog.findChild(QStringLiteral("customizeTabs"))) { + tabs->setCurrentIndex(tab); + } + QET::trackDialogGeometry(&dialog); + + if (parent_widget) { + dialog.setParent(parent_widget, dialog.windowFlags()); + } + dialog.exec(); + dialog.setParent(nullptr, dialog.windowFlags()); +} + /** @brief QETApp::exportConfiguration Save the settings of QElectroTech to a file the user chooses, to keep diff --git a/sources/qetapp.h b/sources/qetapp.h index cd58aa671..234db5fa5 100644 --- a/sources/qetapp.h +++ b/sources/qetapp.h @@ -294,6 +294,7 @@ class QETApp : public QObject void openTitleBlockTemplate(const QString &); void openTitleBlockTemplateFiles(const QStringList &); void configureQET(); + void customizeQET(int tab = 0); /// Save the settings to a file (discussion #610) void exportConfiguration(); /// Replace the settings with a saved file, then close QElectroTech diff --git a/sources/qetmainwindow.cpp b/sources/qetmainwindow.cpp index 3ee58586a..0ff5def85 100644 --- a/sources/qetmainwindow.cpp +++ b/sources/qetmainwindow.cpp @@ -93,6 +93,11 @@ void QETMainWindow::initCommonActions() } }); + customize_action_ = new QAction(QET::Icons::ConfigureToolbars, tr("&Personnaliser..."), this); + ShortcutManager::instance().registerAction(customize_action_, "mainwindow.customize", tr("Général"), QKeySequence()); + customize_action_ -> setStatusTip(tr("Barres d'outils, barre de raccourcis, clavier et gestes de la souris, dans une seule fenêtre", "status bar tip")); + connect(customize_action_, &QAction::triggered, qet_app, [qet_app]() { qet_app->customizeQET(); }); + export_config_action_ = new QAction(QET::Icons::DocumentExport, tr("Enregistrer la configuration sous..."), this); export_config_action_ -> setStatusTip(tr("Enregistre les réglages de QElectroTech dans un fichier", "status bar tip")); connect(export_config_action_, &QAction::triggered, qet_app, &QETApp::exportConfiguration); @@ -190,6 +195,7 @@ void QETMainWindow::initCommonMenus() settings_menu_ = new QMenu(tr("&Configuration", "window menu"), this); settings_menu_ -> addAction(fullscreen_action_); settings_menu_ -> addAction(configure_action_); + settings_menu_ -> addAction(customize_action_); settings_menu_ -> addSeparator(); settings_menu_ -> addAction(export_config_action_); settings_menu_ -> addAction(import_config_action_); @@ -274,6 +280,22 @@ void QETMainWindow::updateFullScreenAction() ShortcutManager::instance().registerAction(fullscreen_action_, "mainwindow.fullscreen", tr("Général"), Qt::CTRL | Qt::SHIFT | Qt::Key_F); } +/** + @brief QETMainWindow::createPopupMenu + The menu shown on a right-click on a toolbar or a dock title, and the + Configuration > Afficher submenu: Qt's list of toolbars and docks, + then Personnaliser..., as in most applications with toolbars. +*/ +QMenu *QETMainWindow::createPopupMenu() +{ + QMenu *menu = QMainWindow::createPopupMenu(); + if (menu) { + menu -> addSeparator(); + menu -> addAction(customize_action_); + } + return menu; +} + /** Check whether a sub menu dedicated to docks and toolbars can be inserted on top of the settings menu. diff --git a/sources/qetmainwindow.h b/sources/qetmainwindow.h index fe76fb7e0..38aac40e3 100644 --- a/sources/qetmainwindow.h +++ b/sources/qetmainwindow.h @@ -43,6 +43,7 @@ class QETMainWindow : public QMainWindow { protected: void activateMenuBar(); bool event(QEvent *) override; + QMenu *createPopupMenu() override; void dragEnterEvent(QDragEnterEvent *e) override; void dropEvent(QDropEvent *e) override; virtual void firstActivation(QEvent *); @@ -56,6 +57,7 @@ class QETMainWindow : public QMainWindow { // attributes protected: QAction *configure_action_; ///< Launch the QElectroTech configuration dialog + QAction *customize_action_; ///< Launch the Customise window (toolbars, keys, gestures) QAction *export_config_action_; ///< Save the settings to a file QAction *import_config_action_; ///< Replace the settings with a saved file QAction *fullscreen_action_; ///< Toggle full screen diff --git a/sources/ui/customizedialog.cpp b/sources/ui/customizedialog.cpp new file mode 100644 index 000000000..03a0bca97 --- /dev/null +++ b/sources/ui/customizedialog.cpp @@ -0,0 +1,77 @@ +/* + 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 "customizedialog.h" + +#include "configpage/configpage.h" + +#include +#include +#include +#include + +CustomizeDialog::CustomizeDialog(QWidget *parent) : + QDialog(parent) +{ + setWindowTitle(tr("Personnaliser", "window title")); + + m_tabs = new QTabWidget(this); + m_tabs->setObjectName(QStringLiteral("customizeTabs")); + m_tabs->setDocumentMode(true); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, [this]() { + applyConf(); + accept(); + }); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(m_tabs); + layout->addWidget(buttons); + + resize(1000, 700); +} + +/** + @brief CustomizeDialog::addPage + Add @a page as a tab, named after its title. The dialog takes it. +*/ +void CustomizeDialog::addPage(ConfigPage *page) +{ + if (!page || m_pages.contains(page)) { + return; + } + m_pages << page; + m_tabs->addTab(page, page->icon(), page->title()); +} + +QList CustomizeDialog::pages() const +{ + return m_pages; +} + +/** + @brief CustomizeDialog::applyConf + Apply every page, in tab order. +*/ +void CustomizeDialog::applyConf() +{ + for (ConfigPage *page : std::as_const(m_pages)) { + page->applyConf(); + } +} diff --git a/sources/ui/customizedialog.h b/sources/ui/customizedialog.h new file mode 100644 index 000000000..a764c1530 --- /dev/null +++ b/sources/ui/customizedialog.h @@ -0,0 +1,51 @@ +/* + 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 CUSTOMIZEDIALOG_H +#define CUSTOMIZEDIALOG_H + +#include +#include + +class ConfigPage; +class QTabWidget; + +/** + @brief The CustomizeDialog class + Everything about how the user works in one window, one tab per + configuration page: toolbars, what they hold, the shortcut bar, the + keyboard and the mouse gestures, like SolidWorks' Tools > Customize. + The pages are the ones of the configuration dialog, which still shows + them too. OK applies every page, Cancel none. +*/ +class CustomizeDialog : public QDialog +{ + Q_OBJECT + + public: + explicit CustomizeDialog(QWidget *parent = nullptr); + + void addPage(ConfigPage *page); + QList pages() const; + void applyConf(); + + private: + QTabWidget *m_tabs; + QList m_pages; +}; + +#endif // CUSTOMIZEDIALOG_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 2f2e6b048..502887209 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -341,6 +341,19 @@ set_tests_properties(tst_gesturesettings PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM target_include_directories(tst_gesturesettings PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_gesturesettings PRIVATE Qt::Test Qt::Widgets Qt::Svg Qt::Xml pugixml::pugixml) +# CustomizeDialog: the configuration pages as tabs of one window; OK +# applies every page once, Cancel none. +add_executable( + tst_customizedialog + tst_customizedialog.cpp + ${QET_DIR}/sources/ui/customizedialog.cpp + ${QET_DIR}/sources/ui/customizedialog.h + ${QET_DIR}/sources/ui/configpage/configpage.h) +add_test(NAME tst_customizedialog COMMAND tst_customizedialog) +set_tests_properties(tst_customizedialog PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_customizedialog PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_customizedialog PRIVATE Qt::Test Qt::Widgets) + # scriptheader.h is header-only: the // ==QETScript== block that turns a # stored script into a button; and ShortcutManager::unregisterAction(), which # takes a deleted script's command out of the lists again. diff --git a/tests/qttest/tst_customizedialog.cpp b/tests/qttest/tst_customizedialog.cpp new file mode 100644 index 000000000..7187bc282 --- /dev/null +++ b/tests/qttest/tst_customizedialog.cpp @@ -0,0 +1,93 @@ +/* + 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 "ui/customizedialog.h" +#include "ui/configpage/configpage.h" + +#include +#include +#include +#include +#include + +/// A configuration page that counts how often it was applied +class CountingPage : public ConfigPage +{ + public: + CountingPage(const QString &title) : ConfigPage(nullptr), m_title(title) {} + void applyConf() override { ++applied; } + QString title() const override { return m_title; } + QIcon icon() const override { return QIcon(); } + int applied = 0; + private: + QString m_title; +}; + +/** + CustomizeDialog: one tab per page in the order added, OK applies every + page once, Cancel applies none. +*/ +class tst_customizedialog : public QObject +{ + Q_OBJECT + +private slots: + void tabsInOrder() + { + CustomizeDialog dialog; + auto *a = new CountingPage(QStringLiteral("Barres d'outils")); + auto *b = new CountingPage(QStringLiteral("Clavier")); + dialog.addPage(a); + dialog.addPage(b); + dialog.addPage(a); // twice: ignored + dialog.addPage(nullptr); + auto *tabs = dialog.findChild(QStringLiteral("customizeTabs")); + QVERIFY(tabs); + QCOMPARE(tabs->count(), 2); + QCOMPARE(tabs->tabText(0), QStringLiteral("Barres d'outils")); + QCOMPARE(tabs->tabText(1), QStringLiteral("Clavier")); + QCOMPARE(tabs->widget(1), b); + } + + void okAppliesEveryPage() + { + CustomizeDialog dialog; + auto *a = new CountingPage(QStringLiteral("a")); + auto *b = new CountingPage(QStringLiteral("b")); + dialog.addPage(a); + dialog.addPage(b); + dialog.show(); + dialog.findChild()->button(QDialogButtonBox::Ok)->click(); + QCOMPARE(a->applied, 1); + QCOMPARE(b->applied, 1); + QCOMPARE(dialog.result(), int(QDialog::Accepted)); + } + + void cancelAppliesNothing() + { + CustomizeDialog dialog; + auto *a = new CountingPage(QStringLiteral("a")); + dialog.addPage(a); + dialog.show(); + dialog.findChild()->button(QDialogButtonBox::Cancel)->click(); + QCOMPARE(a->applied, 0); + QCOMPARE(dialog.result(), int(QDialog::Rejected)); + } +}; + +QTEST_MAIN(tst_customizedialog) +#include "tst_customizedialog.moc"