From 1cd8fadbc4f3aa3ebbc1a46b34a22368c2351c81 Mon Sep 17 00:00:00 2001 From: Laurent Trinques Date: Sun, 11 Oct 2026 05:00:01 +0200 Subject: [PATCH] Reapply "Add cable management: drawing, cores, numbering, cross-references and cable list" This reverts commit 2bb3343d2d2d4c9517b757b96fc1c6a90c34b458. --- CMakeLists.txt | 1 + cmake/qet_compilation_vars.cmake | 42 + ico/icon-themes.qrc | 36 + ico/qet/traced/16/cable.svg | 6 + ico/qet/traced/22/cable.svg | 6 + ico/qet/traced/large16/cable.svg | 6 + ico/qet/traced/large22/cable.svg | 6 + ico/themes/generated/traced-24/cable.svg | 8 + .../generated/traced-large-24/cable.svg | 8 + ico/themes/qet-dark/traced-24/cable.svg | 8 + ico/themes/qet-dark/traced-large-24/cable.svg | 8 + ico/themes/qet-dark/traced/16/cable.svg | 6 + ico/themes/qet-dark/traced/22/cable.svg | 6 + ico/themes/qet-dark/traced/large16/cable.svg | 6 + ico/themes/qet-dark/traced/large22/cable.svg | 6 + sources/autoNum/assignvariables.cpp | 68 + sources/autoNum/assignvariables.h | 25 + sources/autoNum/ui/numparteditorw.cpp | 23 +- sources/autoNum/ui/numparteditorw.h | 8 + sources/autoNum/ui/pastenumberingimport.cpp | 28 +- sources/autoNum/ui/pastenumberingimport.h | 9 +- sources/autoNum/ui/selectautonumw.cpp | 122 +- sources/autoNum/ui/selectautonumw.h | 59 +- sources/bomexport.cpp | 5 +- sources/bomexport.h | 5 + sources/cable/addcablecommand.cpp | 221 ++ sources/cable/addcablecommand.h | 95 + sources/cable/cable.cpp | 921 +++++ sources/cable/cable.h | 376 ++ sources/cable/cablecopy.cpp | 454 +++ sources/cable/cablecopy.h | 126 + sources/cable/cablecreatedialog.cpp | 1407 +++++++ sources/cable/cablecreatedialog.h | 236 ++ sources/cable/cablemanager.cpp | 635 ++++ sources/cable/cablemanager.h | 113 + sources/cable/cablepart.cpp | 3377 +++++++++++++++++ sources/cable/cablepart.h | 513 +++ sources/cable/cablepropertiesdialog.cpp | 279 ++ sources/cable/cablepropertiesdialog.h | 99 + sources/cable/cablepropertieseditorwidget.cpp | 389 ++ sources/cable/cablepropertieseditorwidget.h | 115 + sources/cable/cablepropertieswidget.cpp | 409 ++ sources/cable/cablepropertieswidget.h | 158 + sources/cable/cablerenumber.cpp | 292 ++ sources/cable/cablerenumber.h | 102 + sources/cable/cabletextformatdialog.cpp | 278 ++ sources/cable/cabletextformatdialog.h | 83 + sources/cable/editcablecommand.cpp | 494 +++ sources/cable/editcablecommand.h | 285 ++ sources/cablelist/cableexportdialog.cpp | 155 + sources/cablelist/cableexportdialog.h | 58 + sources/cablelist/cablelistmodel.cpp | 421 ++ sources/cablelist/cablelistmodel.h | 107 + .../cablelistmodelpropertieswidget.cpp | 100 + .../cablelistmodelpropertieswidget.h | 55 + sources/cablelist/cablelistrows.cpp | 468 +++ sources/cablelist/cablelistrows.h | 101 + sources/cablelist/cablequerywidget.cpp | 319 ++ sources/cablelist/cablequerywidget.h | 71 + sources/cablelist/cabletypelist.cpp | 824 ++++ sources/cablelist/cabletypelist.h | 134 + sources/conductorproperties.cpp | 13 + sources/conductorproperties.h | 9 + sources/diagram.cpp | 40 +- sources/diagram.h | 13 +- sources/diagramevent/diagrameventaddcable.cpp | 463 +++ sources/diagramevent/diagrameventaddcable.h | 78 + sources/diagramevent/diagrameventaddpaste.cpp | 83 +- sources/diagramevent/diagrameventaddpaste.h | 21 + sources/diagramtoolbarsettings.cpp | 5 + sources/diagramview.cpp | 258 +- sources/diagramview.h | 14 + sources/elementsmover.cpp | 200 +- sources/elementsmover.h | 35 + sources/factory/propertieseditorfactory.cpp | 38 +- sources/factory/qetgraphicstablefactory.cpp | 53 +- sources/factory/qetgraphicstablefactory.h | 1 + sources/pdf_links.cpp | 58 +- sources/properties/xrefproperties.cpp | 32 +- sources/properties/xrefproperties.h | 9 + sources/qetapp.cpp | 91 + sources/qetapp.h | 18 + sources/qetdiagrameditor.cpp | 242 +- sources/qetdiagrameditor.h | 17 + .../ViewItem/qetgraphicstableitem.cpp | 51 +- sources/qetgraphicsitem/conductor.cpp | 26 + sources/qetgraphicsitem/conductor.h | 9 + sources/qeticons.cpp | 2 + sources/qeticons.h | 1 + sources/qetproject.cpp | 418 +- sources/qetproject.h | 68 + sources/scripting/qetscriptapi.cpp | 64 +- sources/scripting/qetscriptapi.h | 10 +- sources/ui/cablenumberingdialog.cpp | 383 ++ sources/ui/cablenumberingdialog.h | 86 + sources/ui/configpage/configpages.cpp | 97 +- sources/ui/configpage/configpages.h | 6 + .../configpage/generalconfigurationpage.cpp | 180 + .../ui/configpage/generalconfigurationpage.h | 4 + .../ui/configpage/generalconfigurationpage.ui | 148 + sources/ui/configpage/projectconfigpages.cpp | 124 +- sources/ui/configpage/projectconfigpages.h | 6 + sources/ui/projectpropertiesdialog.cpp | 14 + sources/ui/projectpropertiesdialog.h | 2 + sources/ui/xrefpropertieswidget.cpp | 110 +- sources/ui/xrefpropertieswidget.h | 6 + sources/ui/xrefpropertieswidget.ui | 178 +- .../deleteqgraphicsitemcommand.cpp | 21 + sources/undocommand/linkelementcommand.cpp | 20 + tests/qttest/CMakeLists.txt | 26 + tests/qttest/cable_undo_probe.cpp | 105 + tests/qttest/run_cable_undo_probe.py | 60 + tests/qttest/tst_cabletypelist.cpp | 205 + 113 files changed, 18793 insertions(+), 209 deletions(-) create mode 100644 ico/qet/traced/16/cable.svg create mode 100644 ico/qet/traced/22/cable.svg create mode 100644 ico/qet/traced/large16/cable.svg create mode 100644 ico/qet/traced/large22/cable.svg create mode 100644 ico/themes/generated/traced-24/cable.svg create mode 100644 ico/themes/generated/traced-large-24/cable.svg create mode 100644 ico/themes/qet-dark/traced-24/cable.svg create mode 100644 ico/themes/qet-dark/traced-large-24/cable.svg create mode 100644 ico/themes/qet-dark/traced/16/cable.svg create mode 100644 ico/themes/qet-dark/traced/22/cable.svg create mode 100644 ico/themes/qet-dark/traced/large16/cable.svg create mode 100644 ico/themes/qet-dark/traced/large22/cable.svg create mode 100644 sources/cable/addcablecommand.cpp create mode 100644 sources/cable/addcablecommand.h create mode 100644 sources/cable/cable.cpp create mode 100644 sources/cable/cable.h create mode 100644 sources/cable/cablecopy.cpp create mode 100644 sources/cable/cablecopy.h create mode 100644 sources/cable/cablecreatedialog.cpp create mode 100644 sources/cable/cablecreatedialog.h create mode 100644 sources/cable/cablemanager.cpp create mode 100644 sources/cable/cablemanager.h create mode 100644 sources/cable/cablepart.cpp create mode 100644 sources/cable/cablepart.h create mode 100644 sources/cable/cablepropertiesdialog.cpp create mode 100644 sources/cable/cablepropertiesdialog.h create mode 100644 sources/cable/cablepropertieseditorwidget.cpp create mode 100644 sources/cable/cablepropertieseditorwidget.h create mode 100644 sources/cable/cablepropertieswidget.cpp create mode 100644 sources/cable/cablepropertieswidget.h create mode 100644 sources/cable/cablerenumber.cpp create mode 100644 sources/cable/cablerenumber.h create mode 100644 sources/cable/cabletextformatdialog.cpp create mode 100644 sources/cable/cabletextformatdialog.h create mode 100644 sources/cable/editcablecommand.cpp create mode 100644 sources/cable/editcablecommand.h create mode 100644 sources/cablelist/cableexportdialog.cpp create mode 100644 sources/cablelist/cableexportdialog.h create mode 100644 sources/cablelist/cablelistmodel.cpp create mode 100644 sources/cablelist/cablelistmodel.h create mode 100644 sources/cablelist/cablelistmodelpropertieswidget.cpp create mode 100644 sources/cablelist/cablelistmodelpropertieswidget.h create mode 100644 sources/cablelist/cablelistrows.cpp create mode 100644 sources/cablelist/cablelistrows.h create mode 100644 sources/cablelist/cablequerywidget.cpp create mode 100644 sources/cablelist/cablequerywidget.h create mode 100644 sources/cablelist/cabletypelist.cpp create mode 100644 sources/cablelist/cabletypelist.h create mode 100644 sources/diagramevent/diagrameventaddcable.cpp create mode 100644 sources/diagramevent/diagrameventaddcable.h create mode 100644 sources/ui/cablenumberingdialog.cpp create mode 100644 sources/ui/cablenumberingdialog.h create mode 100644 tests/qttest/cable_undo_probe.cpp create mode 100755 tests/qttest/run_cable_undo_probe.py create mode 100644 tests/qttest/tst_cabletypelist.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 99973095d..bd685a45d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -345,6 +345,7 @@ target_include_directories( ${QET_DIR}/sources/print ${QET_DIR}/sources/svg ${QET_DIR}/sources/materiallist + ${QET_DIR}/sources/cablelist ) if(NOT BUILD_WITH_KF) diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 94d81db28..61f2f9286 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -387,6 +387,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/diagramevent/diagrameventfillet.h ${QET_DIR}/sources/diagramevent/diagrameventaddpath.cpp ${QET_DIR}/sources/diagramevent/diagrameventaddpath.h + ${QET_DIR}/sources/diagramevent/diagrameventaddcable.cpp + ${QET_DIR}/sources/diagramevent/diagrameventaddcable.h ${QET_DIR}/sources/diagramevent/diagrameventaddtext.cpp ${QET_DIR}/sources/diagramevent/diagrameventaddtext.h ${QET_DIR}/sources/diagramevent/diagrameventinterface.cpp @@ -811,6 +813,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/elementinfowidget.h ${QET_DIR}/sources/ui/terminalnumberingdialog.cpp ${QET_DIR}/sources/ui/terminalnumberingdialog.h + ${QET_DIR}/sources/ui/cablenumberingdialog.cpp + ${QET_DIR}/sources/ui/cablenumberingdialog.h ${QET_DIR}/sources/ui/elementpropertieswidget.cpp ${QET_DIR}/sources/ui/elementpropertieswidget.h ${QET_DIR}/sources/ui/formulaassistantdialog.cpp @@ -936,6 +940,44 @@ set(QET_SRC_FILES ${QET_DIR}/sources/materiallist/materialselectiondialog.cpp ${QET_DIR}/sources/materiallist/materialselectiondialog.h + ${QET_DIR}/sources/cablelist/cabletypelist.cpp + ${QET_DIR}/sources/cablelist/cabletypelist.h + ${QET_DIR}/sources/cablelist/cableexportdialog.cpp + ${QET_DIR}/sources/cablelist/cableexportdialog.h + ${QET_DIR}/sources/cablelist/cablelistmodel.cpp + ${QET_DIR}/sources/cablelist/cablelistmodel.h + ${QET_DIR}/sources/cablelist/cablelistmodelpropertieswidget.cpp + ${QET_DIR}/sources/cablelist/cablelistmodelpropertieswidget.h + ${QET_DIR}/sources/cablelist/cablelistrows.cpp + ${QET_DIR}/sources/cablelist/cablelistrows.h + ${QET_DIR}/sources/cablelist/cablequerywidget.cpp + ${QET_DIR}/sources/cablelist/cablequerywidget.h + + ${QET_DIR}/sources/cable/addcablecommand.cpp + ${QET_DIR}/sources/cable/addcablecommand.h + ${QET_DIR}/sources/cable/cable.cpp + ${QET_DIR}/sources/cable/cable.h + ${QET_DIR}/sources/cable/cablecopy.cpp + ${QET_DIR}/sources/cable/cablecopy.h + ${QET_DIR}/sources/cable/cablecreatedialog.cpp + ${QET_DIR}/sources/cable/cablecreatedialog.h + ${QET_DIR}/sources/cable/cablemanager.cpp + ${QET_DIR}/sources/cable/cablemanager.h + ${QET_DIR}/sources/cable/cablepart.cpp + ${QET_DIR}/sources/cable/cablepart.h + ${QET_DIR}/sources/cable/cablepropertiesdialog.cpp + ${QET_DIR}/sources/cable/cablepropertiesdialog.h + ${QET_DIR}/sources/cable/cablepropertieseditorwidget.cpp + ${QET_DIR}/sources/cable/cablepropertieseditorwidget.h + ${QET_DIR}/sources/cable/cablepropertieswidget.cpp + ${QET_DIR}/sources/cable/cablepropertieswidget.h + ${QET_DIR}/sources/cable/cablerenumber.cpp + ${QET_DIR}/sources/cable/cablerenumber.h + ${QET_DIR}/sources/cable/cabletextformatdialog.cpp + ${QET_DIR}/sources/cable/cabletextformatdialog.h + ${QET_DIR}/sources/cable/editcablecommand.cpp + ${QET_DIR}/sources/cable/editcablecommand.h + ${QET_DIR}/sources/xml/terminalstripitemxml.cpp ${QET_DIR}/sources/xml/terminalstripitemxml.h ${QET_DIR}/sources/xml/terminalstriplayoutpatternxml.cpp diff --git a/ico/icon-themes.qrc b/ico/icon-themes.qrc index 30d2b9de1..47e98b7c6 100644 --- a/ico/icon-themes.qrc +++ b/ico/icon-themes.qrc @@ -257,6 +257,21 @@ themes/generated/traced-24/bring_forward.svg themes/generated/traced-large-24/bring_forward.svg qet/traced/large22/bring_forward.svg + qet/traced/16/cable.svg + qet/traced/large16/cable.svg + qet/traced/22/cable.svg + qet/traced/large22/cable.svg + qet/traced/large16/cable.svg + qet/traced/large16/cable.svg + themes/generated/traced-large-24/cable.svg + themes/generated/traced-large-24/cable.svg + qet/traced/large16/cable.svg + qet/traced/large16/cable.svg + qet/traced/large16/cable.svg + qet/traced/large16/cable.svg + themes/generated/traced-24/cable.svg + themes/generated/traced-large-24/cable.svg + qet/traced/large22/cable.svg qet/traced/16/circle.svg qet/traced/large16/circle.svg qet/traced/22/circle.svg @@ -2699,6 +2714,12 @@ themes/qet-dark/traced/large22/bring_forward.svg themes/qet-dark/traced-large-24/bring_forward.svg themes/qet-dark/traced-24/bring_forward.svg + themes/qet-dark/traced/16/cable.svg + themes/qet-dark/traced/large16/cable.svg + themes/qet-dark/traced/22/cable.svg + themes/qet-dark/traced/large22/cable.svg + themes/qet-dark/traced-large-24/cable.svg + themes/qet-dark/traced-24/cable.svg themes/qet-dark/traced/16/circle.svg themes/qet-dark/traced/large16/circle.svg themes/qet-dark/traced/22/circle.svg @@ -3516,6 +3537,21 @@ themes/qet-dark/traced-24/bring_forward.svg themes/qet-dark/traced-large-24/bring_forward.svg themes/qet-dark/traced/large22/bring_forward.svg + themes/qet-dark/traced/16/cable.svg + themes/qet-dark/traced/large16/cable.svg + themes/qet-dark/traced/22/cable.svg + themes/qet-dark/traced/large22/cable.svg + themes/qet-dark/traced/large16/cable.svg + themes/qet-dark/traced/large16/cable.svg + themes/qet-dark/traced-large-24/cable.svg + themes/qet-dark/traced-large-24/cable.svg + themes/qet-dark/traced/large16/cable.svg + themes/qet-dark/traced/large16/cable.svg + themes/qet-dark/traced/large16/cable.svg + themes/qet-dark/traced/large16/cable.svg + themes/qet-dark/traced-24/cable.svg + themes/qet-dark/traced-large-24/cable.svg + themes/qet-dark/traced/large22/cable.svg themes/qet-dark/traced/16/circle.svg themes/qet-dark/traced/large16/circle.svg themes/qet-dark/traced/22/circle.svg diff --git a/ico/qet/traced/16/cable.svg b/ico/qet/traced/16/cable.svg new file mode 100644 index 000000000..7c619123c --- /dev/null +++ b/ico/qet/traced/16/cable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ico/qet/traced/22/cable.svg b/ico/qet/traced/22/cable.svg new file mode 100644 index 000000000..e0a389dea --- /dev/null +++ b/ico/qet/traced/22/cable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ico/qet/traced/large16/cable.svg b/ico/qet/traced/large16/cable.svg new file mode 100644 index 000000000..623488647 --- /dev/null +++ b/ico/qet/traced/large16/cable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ico/qet/traced/large22/cable.svg b/ico/qet/traced/large22/cable.svg new file mode 100644 index 000000000..97f52785c --- /dev/null +++ b/ico/qet/traced/large22/cable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ico/themes/generated/traced-24/cable.svg b/ico/themes/generated/traced-24/cable.svg new file mode 100644 index 000000000..ff2411462 --- /dev/null +++ b/ico/themes/generated/traced-24/cable.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ico/themes/generated/traced-large-24/cable.svg b/ico/themes/generated/traced-large-24/cable.svg new file mode 100644 index 000000000..778188781 --- /dev/null +++ b/ico/themes/generated/traced-large-24/cable.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ico/themes/qet-dark/traced-24/cable.svg b/ico/themes/qet-dark/traced-24/cable.svg new file mode 100644 index 000000000..6cf88f6c5 --- /dev/null +++ b/ico/themes/qet-dark/traced-24/cable.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ico/themes/qet-dark/traced-large-24/cable.svg b/ico/themes/qet-dark/traced-large-24/cable.svg new file mode 100644 index 000000000..3aae9ea45 --- /dev/null +++ b/ico/themes/qet-dark/traced-large-24/cable.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ico/themes/qet-dark/traced/16/cable.svg b/ico/themes/qet-dark/traced/16/cable.svg new file mode 100644 index 000000000..b21070f7f --- /dev/null +++ b/ico/themes/qet-dark/traced/16/cable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ico/themes/qet-dark/traced/22/cable.svg b/ico/themes/qet-dark/traced/22/cable.svg new file mode 100644 index 000000000..608b450e4 --- /dev/null +++ b/ico/themes/qet-dark/traced/22/cable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ico/themes/qet-dark/traced/large16/cable.svg b/ico/themes/qet-dark/traced/large16/cable.svg new file mode 100644 index 000000000..8c9386972 --- /dev/null +++ b/ico/themes/qet-dark/traced/large16/cable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ico/themes/qet-dark/traced/large22/cable.svg b/ico/themes/qet-dark/traced/large22/cable.svg new file mode 100644 index 000000000..104596625 --- /dev/null +++ b/ico/themes/qet-dark/traced/large22/cable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/sources/autoNum/assignvariables.cpp b/sources/autoNum/assignvariables.cpp index 633fe7da3..40563956c 100644 --- a/sources/autoNum/assignvariables.cpp +++ b/sources/autoNum/assignvariables.cpp @@ -649,6 +649,74 @@ namespace autonum } } + /** + @brief resetFolioCounters + @param nc the rule to read + @return the same rule, with the parts whose counter belongs to a + single folio put back to the beginning that folio is numbered + from -- the initial value the rule carries for it -- while the + parts counting over the whole project are left alone + */ + NumerotationContext resetFolioCounters(const NumerotationContext &nc) + { + NumerotationContext out = nc; + for (int i = 0; i < out.size(); ++i) + { + const QStringList parts = out.itemAt(i); + if (parts.isEmpty()) continue; + + const QString &type = parts.at(0); + if (type == QLatin1String("unitfolio") + || type == QLatin1String("tenfolio") + || type == QLatin1String("hundredfolio")) + { + //The beginning a folio is numbered from is the one + //written into the rule, never a hard 1: a counter + //starting at 5 starts every folio at 5. + const bool has_start = parts.size() > 3 + && !parts.at(3).isEmpty(); + out.replaceValue(i, has_start ? parts.at(3) + : QStringLiteral("1")); + } + } + return out; + } + + /** + @brief resetContextCounters + @param nc the rule to read + @return the same rule, with every part which counts set back to + the value it started from + */ + NumerotationContext resetContextCounters(const NumerotationContext &nc) + { + NumerotationContext out = resetFolioCounters(nc); + for (int i = 0; i < out.size(); ++i) + { + const QStringList parts = out.itemAt(i); + if (parts.isEmpty()) continue; + + const QString &type = parts.at(0); + if (type == QLatin1String("unit") + || type == QLatin1String("ten") + || type == QLatin1String("hundred")) + { + out.replaceValue(i, QStringLiteral("1")); + } + else if (type == QLatin1String("wrap")) + { + //A cyclic part walks over [0, modulus), so it goes + //back to 0 rather than to 1 + out.replaceValue(i, QStringLiteral("0")); + } + else if (type == QLatin1String("alpha")) + { + out.replaceValue(i, QStringLiteral("a")); + } + } + return out; + } + /** @brief numerotationContextToFormula @param nc diff --git a/sources/autoNum/assignvariables.h b/sources/autoNum/assignvariables.h index 255ad6003..04e353274 100644 --- a/sources/autoNum/assignvariables.h +++ b/sources/autoNum/assignvariables.h @@ -117,6 +117,31 @@ namespace autonum void setFolioSequentialToHash(QStringList &list, QHash &hash, const QString& autoNumName); void setSequential(const QString& label, autonum::sequentialNumbers &seqStruct, NumerotationContext &context, Diagram *diagram, const QString& hashKey); QString numerotationContextToFormula(const NumerotationContext &nc); + /** + Put back every part of a numbering rule whose counter belongs to + one single folio -- "Chiffre 1 - Folio" and its two longer forms + -- to the beginning that folio is numbered from, which is the + initial value written into the rule itself. The parts which count + over the whole project are left exactly as they are: a folio + starts again, the project does not. + @param nc the rule to read + @return the same rule, with its folio-bound counters restarted + */ + NumerotationContext resetFolioCounters(const NumerotationContext &nc); + /** + Set every part of a numbering rule which counts back to its very + first value, so that numbering starts at the beginning again -- + which is what numbering a whole project over has to do, whatever + the last number handed out happened to be. A part which counts + from an initial value of its own goes back to that value, a plain + number goes back to 1, a cyclic one to 0 and an alphabetic one to + "a". The parts which do not count -- a fixed text, the number of + the folio, the plant -- are left exactly as they are: they say + what a number is made of, they are not the part which goes on. + @param nc the rule to read + @return the same rule, with its counters at their beginning + */ + NumerotationContext resetContextCounters(const NumerotationContext &nc); QString elementPrefixForLocation(const ElementsLocation &location); } diff --git a/sources/autoNum/ui/numparteditorw.cpp b/sources/autoNum/ui/numparteditorw.cpp index 9f45289ad..7b1d25908 100644 --- a/sources/autoNum/ui/numparteditorw.cpp +++ b/sources/autoNum/ui/numparteditorw.cpp @@ -105,6 +105,16 @@ NumPartEditorW::NumPartEditorW (NumerotationContext &context, if (strl.at(0)=="wrap" && strl.size() > 4) ui -> modulus_spinBox -> setValue(strl.at(4).toInt()); ui -> format_le -> setText(NumerotationContext::formatOf(strl)); + //A counter bound to the folio keeps the start it was + //configured with: value_field shows the live counter, and + //writing that back as the start would make every folio + //begin at the last number the previous numbering gave out + if ((strl.at(0)==QLatin1String("unitfolio") + || strl.at(0)==QLatin1String("tenfolio") + || strl.at(0)==QLatin1String("hundredfolio")) + && strl.size() > 3 && !strl.at(3).isEmpty()) { + m_initial_value = strl.at(3).toInt(); + } } } @@ -131,8 +141,12 @@ void NumPartEditorW::setVisibleItems() << tr("Alphabetical") << tr("Text"); } - else if (m_edited_type == 1) + else if (m_edited_type == 1 || m_edited_type == 3) { + //Type 3 is a cable: it is drawn along a folio just like a + //conductor is, so it takes the same kinds of variable and + //none of the ones an element has and a cable has not (its + //line, its column, its prefix). items << tr("number format 1") << tr("number format 1 - Sheet") << tr("number format 01") @@ -232,7 +246,8 @@ NumerotationContext NumPartEditorW::toNumContext() nc.addValue(type_str, ui -> value_field -> displayText(), ui -> increase_spinBox -> value(), - ui->value_field->displayText().toInt(), + m_initial_value >= 0 ? m_initial_value + : ui->value_field->displayText().toInt(), 0, number_format); else if (type_str == "wrap") @@ -315,6 +330,10 @@ void NumPartEditorW::on_type_cb_activated(int) { */ void NumPartEditorW::on_value_field_textEdited() { + //What the user types in is a deliberate start for a counter + //bound to the folio; merely showing the live counter (as + //setContext() does) never changes the start. + m_initial_value = ui -> value_field -> text().toInt(); emit changed(); } diff --git a/sources/autoNum/ui/numparteditorw.h b/sources/autoNum/ui/numparteditorw.h index ce7d3ffae..b9ee8aafb 100644 --- a/sources/autoNum/ui/numparteditorw.h +++ b/sources/autoNum/ui/numparteditorw.h @@ -77,6 +77,14 @@ class NumPartEditorW : public QWidget QValidator *intValidator; QValidator *alphaValidator; int m_edited_type = -1; ///<0 == element : 1 == conductor : 2 == folio + /** + The value a counter bound to the folio counts each folio + from (-1 while the row carries none): value_field shows + the live counter, which numbering a project over leaves + at the last number given out, so the start has to be + kept apart from what is on show. + */ + int m_initial_value = -1; diff --git a/sources/autoNum/ui/pastenumberingimport.cpp b/sources/autoNum/ui/pastenumberingimport.cpp index 0e4ca0476..3a1bc9f2f 100644 --- a/sources/autoNum/ui/pastenumberingimport.cpp +++ b/sources/autoNum/ui/pastenumberingimport.cpp @@ -37,22 +37,41 @@ QList PasteNumberingImport::copiedBy(const elements follow and the project does not have. @param parent : parent of the question @param copied : the numberings the copy carries + @param extra : commands the copy brings beside the paste itself + (the cable lines of a fragment, for example); they are pushed inside + the same macro, so one Ctrl+Z undoes all of it at once. */ void PasteNumberingImport::push(QWidget *parent, Diagram *diagram, const DiagramContent &content, - const QList &copied) + const QList &copied, + const QList &extra) { QETProject *project = diagram->project(); auto *paste = new PasteDiagramCommand(diagram, content); + //The paste and the commands beside it are one step: only the + //macros really need more than the single push. + const auto push_all = [&]() { + if (extra.isEmpty()) { + diagram->undoStack().push(paste); + return; + } + diagram->undoStack().beginMacro(paste->text()); + diagram->undoStack().push(paste); + for (QUndoCommand *cmd : extra) { + diagram->undoStack().push(cmd); + } + diagram->undoStack().endMacro(); + }; + QList missing; if (project && !copied.isEmpty() && QSettings().value("diagramcommands/autonumber-pasted-elements", true).toBool()) { missing = ElementAutoNumSchemeCommand::missingForPaste(project, copied, content.m_elements); } if (missing.isEmpty()) { - diagram->undoStack().push(paste); + push_all(); return; } @@ -71,7 +90,7 @@ void PasteNumberingImport::push(QWidget *parent, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); if (answer != QMessageBox::Yes) { - diagram->undoStack().push(paste); + push_all(); return; } @@ -91,5 +110,8 @@ void PasteNumberingImport::push(QWidget *parent, } } diagram->undoStack().push(paste); + for (QUndoCommand *cmd : extra) { + diagram->undoStack().push(cmd); + } diagram->undoStack().endMacro(); } diff --git a/sources/autoNum/ui/pastenumberingimport.h b/sources/autoNum/ui/pastenumberingimport.h index 0889ad813..8e03c3b0d 100644 --- a/sources/autoNum/ui/pastenumberingimport.h +++ b/sources/autoNum/ui/pastenumberingimport.h @@ -23,6 +23,7 @@ #include #include +#include class Diagram; class QWidget; @@ -40,6 +41,11 @@ class QWidget; without a numbering. The import and the paste are one undo step. + + A paste which brings more than itself (the cable lines of the copy, + for instance, which are no part of a DiagramContent) hands those + commands over through @p extra: they are pushed inside the very + same macro, so one Ctrl+Z still takes the whole of it back. */ class PasteNumberingImport { @@ -49,7 +55,8 @@ class PasteNumberingImport static void push(QWidget *parent, Diagram *diagram, const DiagramContent &content, - const QList &copied); + const QList &copied, + const QList &extra = QList()); static QList copiedBy(const QDomDocument &clipboard); }; diff --git a/sources/autoNum/ui/selectautonumw.cpp b/sources/autoNum/ui/selectautonumw.cpp index ff32c7b31..dc215651e 100644 --- a/sources/autoNum/ui/selectautonumw.cpp +++ b/sources/autoNum/ui/selectautonumw.cpp @@ -138,6 +138,9 @@ void SelectAutonumW::setContext(const NumerotationContext &context) updatePartButtons(); applyEnable(false); + //Everything the rule held before is read now, so nothing of it + //is left to write: what is on show is what there is. + m_dirty = false; } /** @@ -147,7 +150,11 @@ void SelectAutonumW::setContext(const NumerotationContext &context) */ void SelectAutonumW::insertPartRow(NumPartEditorW *part) { - connect(part, &NumPartEditorW::changed, this, [this]() { applyEnable(); }); + connect(part, &NumPartEditorW::changed, this, [this]() { + m_dirty = true; + applyEnable(); + emit contextEdited(); + }); PartRow r; r.part = part; @@ -263,7 +270,10 @@ NumerotationContext SelectAutonumW::toNumContext() const void SelectAutonumW::on_add_button_clicked() { insertPartRow(new NumPartEditorW(m_edited_type, this)); + m_dirty = true; + ui -> remove_button -> setEnabled(true); applyEnable(); + emit contextEdited(); } /** @@ -272,9 +282,16 @@ void SelectAutonumW::on_add_button_clicked() */ void SelectAutonumW::on_remove_button_clicked() { - if (!m_rows.isEmpty()) { + //remove if @num_part_list contains more than one item + if (num_part_list_.size() > 1) { + m_dirty = true; removePartRow(m_rows.last().row); + if (num_part_list_.size() == 1) { + ui -> remove_button -> setDisabled(true); + } } + applyEnable(); + emit contextEdited(); } /** @@ -296,6 +313,78 @@ QComboBox *SelectAutonumW::contextComboBox() const return ui->m_comboBox; } +/** + @brief SelectAutonumW::setSingleRuleMode + @param single true to show this editor as the one and only rule of + its kind. + + The row which lists the available numberings -- its label, its combo + box and the button which deletes one -- is hidden, so there is + nothing to name a rule with and nothing to choose between: the rule + being edited is all there is. The combo box itself stays where it is + and keeps holding the rule's name, so everything which reads or + writes the rule through contextComboBox() carries on untouched; he + just cannot see it. +*/ +void SelectAutonumW::setSingleRuleMode(bool single) +{ + m_single_rule = single; + ui->label->setVisible(!single); + ui->m_comboBox->setVisible(!single); + ui->m_remove_pb->setVisible(!single); + + //A single rule has no list to be deleted from, so it needs a + //button of its own to get out of the numbering again. It is + //only shown when there is really a rule to take away -- see + //setRuleRemovable. + if (single && !m_rule_remove_pb) { + m_rule_remove_pb = new QPushButton(tr("Delete the rule"), this); + m_rule_remove_pb->setToolTip(tr( + "Delete the cable numbering rule: cables are " + "numbered again as before, and the question of " + "defining one comes back on the next drawing.")); + m_rule_remove_pb->setObjectName(QStringLiteral("m_rule_remove_pb")); + if (ui->m_definition_groupe && ui->m_definition_groupe->layout()) { + ui->m_definition_groupe->layout()->addWidget(m_rule_remove_pb); + } + connect(m_rule_remove_pb, &QPushButton::clicked, + this, &SelectAutonumW::removeClicked); + } + if (m_rule_remove_pb) { + m_rule_remove_pb->setVisible(m_single_rule && m_rule_removable); + } +} + +/** + @brief SelectAutonumW::setRuleRemovable + Whether the button which takes the whole rule away is worth showing: + it has nothing to do while this editor holds no rule at all, and it + would only be a button which does nothing. + @param can_remove true when a rule is there to be taken away +*/ +void SelectAutonumW::setRuleRemovable(bool can_remove) +{ + m_rule_removable = can_remove; + if (m_rule_remove_pb) { + m_rule_remove_pb->setVisible(m_single_rule && can_remove); + } +} + +/** + @brief SelectAutonumW::isValid + @return true when there is at least one variable in the editor and + every one of them holds what it needs. An editor which has only just + been opened holds an empty rule, and an empty rule is not one. +*/ +bool SelectAutonumW::isValid() +{ + if (num_part_list_.isEmpty()) return false; + for (NumPartEditorW *npe : num_part_list_) { + if (!npe || !npe->isValid()) return false; + } + return true; +} + /** @brief SelectAutonumW::on_buttonBox_clicked Action on button clicked @@ -364,6 +453,32 @@ void SelectAutonumW::on_buttonBox_clicked(QAbstractButton *button) )); break; } + else if (m_edited_type == 3) + { + QMessageBox::information ( + this, + tr("Cable Auto Numbering", + "title window"), + tr("This is where you can define how new cables will be numbered.\n" + "-A numbering is composed of a minimum variable.\n" + "-You can add or delete a dialing variable through the - and + buttons.\n" + "-A numbering variable includes: a type, a value and an increment.\n" + + "\n-the \"Digit 1\", \"Digit 01\" and \"Digit 001\" types represent a numeric type defined in the \"Value\" field, " + "which is incremented to each new cable by the value of the \"Increment\" field.\n" + "-\"Digit 01\" and \"Digit 001\", are respectively represented on the diagram by two and three digits minimum.\n" + "If the digit defined in the Value field has fewer digits than the chosen type, it will be preceded by one or two 0s in " + "order to respect its type.\n" + + "\n-Type \"Text\", represents a fixed text.\nThe \"Increment\" field is not used.\n" + + "\n-The \"Sheet no.\" type represents the number of the current sheet.\nThe other fields are not used.\n" + + "\n-The \"Sheet\" type represents the name of the current sheet.\nThe other fields are not used.", + "help dialog about the cable autonumerotation" + )); + break; + } else { QMessageBox::information ( @@ -397,6 +512,9 @@ void SelectAutonumW::on_buttonBox_clicked(QAbstractButton *button) case QDialogButtonBox::ApplyRole: applyEnable(false); emit applyPressed(); + //Whatever was edited is written now, so there is + //nothing of it left for a later save to write again. + m_dirty = false; break; }; } diff --git a/sources/autoNum/ui/selectautonumw.h b/sources/autoNum/ui/selectautonumw.h index 77055b764..b43c47fa2 100644 --- a/sources/autoNum/ui/selectautonumw.h +++ b/sources/autoNum/ui/selectautonumw.h @@ -27,6 +27,7 @@ class NumPartEditorW; class QAbstractButton; class FormulaAutonumberingW; class QComboBox; +class QPushButton; class QToolButton; class QHBoxLayout; @@ -54,11 +55,55 @@ class SelectAutonumW : public QWidget void contextToFormula (); QString formula(); QComboBox *contextComboBox() const; + /** + Show this editor as the only numbering rule there is: the + row which lets several named numberings be kept and chosen + between is hidden, so the rule being edited is the one and + only. Everything else -- the definition itself, the buttons + around it -- stays exactly as it is for the other kinds. + */ + void setSingleRuleMode(bool single); + bool isSingleRuleMode() const { return m_single_rule; } + /** + True while everything the editor holds is filled in well + enough to be a rule -- no variable left with nothing in it. + The Apply button says the same thing; this is so that a page + which saves on its own way knows it too, rather than writing + a rule nobody has finished filling in. + */ + bool isValid(); + /** + True while the rule on show has been touched since it was + read or since it was last applied: a page which saves on + its own way (the cable rule does) writes it only when there + is something to write, so that confirming a window with + nothing changed in it leaves the project alone. + */ + bool isModified() const { return m_dirty; } + /** + Show the button which takes the rule itself away, which is + the only way to stop numbering cables automatically again: + the row the other numberings delete from is hidden here, so + a single rule would have no way out at all. The page behind + the editor says whether there is a rule to take away at the + moment -- without one the button has nothing to do and is + kept out of sight. + @param can_remove true when a rule is really there + */ + void setRuleRemovable(bool can_remove); void setExplicitNaming(); signals: void applyPressed(); void removeClicked(); + /** + Sent whenever what the rule holds is edited -- a + field of a part changed, a part added, a part taken + away -- so that a window holding this editor can let + its own buttons say whether there is something to + write now. + */ + void contextEdited(); /// Only with setExplicitNaming() void newClicked(); void renameClicked(); @@ -96,7 +141,19 @@ class SelectAutonumW : public QWidget NumerotationContext m_context; FormulaAutonumberingW *m_feaw; FormulaAutonumberingW *m_fcaw; - int m_edited_type = -1; ///<0 == element : 1 == conductor : 2 == folio + int m_edited_type = -1; ///<0 == element : 1 == conductor : 2 == folio : 3 == cable + ///< when true, the row of available numberings is hidden and + ///< this editor holds the one rule there is (see setSingleRuleMode) + bool m_single_rule = false; + ///< true from the moment the rule on show is touched until + ///< it is read again or applied (see isModified) + bool m_dirty = false; + ///< the button which takes the whole rule away, built for + ///< the one kind of numbering which has no list to delete + ///< it from (see setRuleRemovable) + QPushButton *m_rule_remove_pb = nullptr; + ///< whether such a rule is there to be taken away + bool m_rule_removable = false; }; #endif // SELECTAUTONUMW_H diff --git a/sources/bomexport.cpp b/sources/bomexport.cpp index 97ecdff61..768dd78c0 100644 --- a/sources/bomexport.cpp +++ b/sources/bomexport.cpp @@ -29,7 +29,9 @@ QString csvField(QString value) return QLatin1Char('"') + value + QLatin1Char('"'); } -QByteArray csvRecord(const QStringList &values) +} + +QByteArray BomExport::csvRecord(const QStringList &values) { QStringList escaped; for (const auto &value : values) { @@ -37,7 +39,6 @@ QByteArray csvRecord(const QStringList &values) } return (escaped.join(QLatin1Char(';')) + QLatin1Char('\n')).toUtf8(); } -} QStringList BomExport::defaultColumns() { diff --git a/sources/bomexport.h b/sources/bomexport.h index 5305d378e..a12ba5789 100644 --- a/sources/bomexport.h +++ b/sources/bomexport.h @@ -30,6 +30,11 @@ namespace BomExport QString defaultQuery(bool include_slaves = true, bool include_junctions = true); QString junctionFilter(); + /// One CSV record: every field quoted, ';' between them, '\n' + /// after -- the format every CSV export of this program writes. + /// Public so a list which is not read from the project database + /// (the cable list) writes the same bytes. + QByteArray csvRecord(const QStringList &values); QByteArray toCsv(QSqlQuery &query, const QStringList &headers, bool include_headers = true, int *row_count = nullptr); bool writeCsv(const QString &file_path, const QByteArray &csv, diff --git a/sources/cable/addcablecommand.cpp b/sources/cable/addcablecommand.cpp new file mode 100644 index 000000000..d3d81b41b --- /dev/null +++ b/sources/cable/addcablecommand.cpp @@ -0,0 +1,221 @@ +/* + 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 "addcablecommand.h" + +#include "cablemanager.h" +#include "cablepart.h" +#include "../autoNum/numerotationcontextcommands.h" +#include "../diagram.h" +#include "../qetproject.h" + +#include + +/** + @brief AddCableCommand::AddCableCommand + @param cable the cable the line was just given to + @param part the line which was just drawn + @param diagram the folio it was drawn on + @param own_cable true when the cable was created by this very action + @param taken the cores this line took away from other cables, so + undo can give them back +*/ +AddCableCommand::AddCableCommand(Cable *cable, + CablePart *part, + Diagram *diagram, + bool own_cable, + const QList &taken) : + m_cable(cable), + m_part(part), + m_diagram(diagram), + m_taken(taken), + m_own_cable(own_cable) +{ + if (!m_cable || !m_part) return; + + m_part_data.uuid = m_part->partUuid(); + m_part_data.diagram = m_diagram ? m_diagram->uuid() : QUuid(); + m_part_data.p1 = m_part->firstPoint(); + m_part_data.p2 = m_part->secondPoint(); + + for (const CableCore &core : m_cable->usedCores()) { + if (core.part == m_part_data.uuid) { + m_cores.append(core); + } + } + + //A cable this action brought into being takes the next number + //the project's numbering rule hands out. The number itself was + //worked out before this command existed; what has to happen here + //is moving the rule on, and moving it is undoable just like the + //cable is -- so undoing gives the number back and the cable + //drawn after that takes it again instead of skipping over it. + // + //Only the rule as it stands now is taken down. Where the rule + //stands after this cable is worked out when the cable really + //comes into being, because several cables pasted together are + //all made before any of them is added, and each of them has to + //move the rule on from wherever the one before it left it. + if (m_own_cable && m_diagram && project() && project()->hasCableAutoNum()) + { + const QString key = project()->cableCurrentAutoNum(); + const NumerotationContext current = project()->cableAutoNum(key); + if (!current.isEmpty()) + { + m_num_key = key; + m_num_before = current; + } + } +} + +/** + @brief AddCableCommand::~AddCableCommand + Whatever the stack leaves behind has to be cleaned up here: a cable + which is not held by the project any more belongs to this command, + and so does a line which never made it onto a folio. + + The cable is a QPointer, so if another command took it away first it + is already null here and there is nothing left to do -- which is + exactly the point, since following a raw pointer here meant freeing + the same cable a second time. +*/ +AddCableCommand::~AddCableCommand() +{ + if (m_cable && m_own_cable) { + QETProject *proj = project(); + if (!proj || !proj->cables().contains(m_cable.data())) { + delete m_cable.data(); + } + } + if (m_part && !m_part->scene()) { + delete m_part; + } +} + +/** + @brief AddCableCommand::project + @return the project this command works in, or nullptr +*/ +QETProject *AddCableCommand::project() const +{ + return m_diagram ? m_diagram->project() : nullptr; +} + +/** + @brief AddCableCommand::redo + Put the line on the folio, together with everything the cable holds + for it. +*/ +void AddCableCommand::redo() +{ + QETProject *proj = project(); + if (!proj || !m_cable || !m_part || !m_diagram) return; + + if (m_own_cable) { + proj->addCable(m_cable); + } else { + if (!m_cable->hasPart(m_part_data.uuid)) { + m_cable->addPart(m_part_data); + } + for (const CableCore &core : m_cores) { + m_cable->setCore(core); + } + } + + if (m_part->scene() != m_diagram) { + m_diagram->addItem(m_part); + } + + //Take the cores away from the cables which held them before this + //line claimed those conductors. On the very first run they are + //already gone, so nothing happens then; redoing after an undo + //has to take them again. + for (const CableCoreTaken &taken : std::as_const(m_taken)) + { + if (taken.cable && taken.cable != m_cable) { + taken.cable->removeCore(taken.core.core); + } + } + //The cable field of every conductor is worked out again either + //way: redoing after an undo puts the cable back into the project + //without it having said anything, and the wires it feeds would go + //on showing a blank cable field although the cable which + //generates that field is right there again. + // + //And the number this cable was given is spent now: the rule + //moves on to the one the next cable will be handed. Worked out + //here rather than when the command was made, because a run of + //cables pasted together is made from end to end before any of + //it is added -- each of them has to move the rule on from where + //the one before it left it. + if (!m_num_key.isEmpty()) { + NumerotationContextCommands ncc(proj->cableAutoNum(m_num_key), + m_diagram.data()); + proj->addCableAutoNum(m_num_key, ncc.next()); + proj->setCurrentCableAutoNum(m_num_key); + } + CableManager::refreshLabels(proj); +} + +/** + @brief AddCableCommand::undo + Take the line back off the folio. An existing cable loses the + section and the cores it was carrying on it -- and with them the + cable field of the conductors, which CableManager works out again as + soon as the cable says it changed. + + The cores this line had taken away from other cables go back to + those cables afterwards: without them the conductors they belong to + would be left with a blank cable field although the cable which + generates that field is still there. +*/ +void AddCableCommand::undo() +{ + QETProject *proj = project(); + if (!proj || !m_cable || !m_part || !m_diagram) return; + + if (m_part->scene()) { + m_diagram->removeItem(m_part); + } + + if (m_own_cable) { + //The cable keeps everything it knows: it is only taken out of + //the project, so redo finds it exactly as it was. + proj->removeCable(m_cable); + } else { + m_cable->removePart(m_part_data.uuid); + } + + for (const CableCoreTaken &taken : std::as_const(m_taken)) + { + if (taken.cable && taken.cable != m_cable) { + taken.cable->setCore(taken.core); + } + } + //The number this cable was given goes back into the rule, so + //the cable drawn after an undo takes that number rather than + //the one after it: nothing of it is left spent. + if (!m_num_key.isEmpty()) { + proj->addCableAutoNum(m_num_key, m_num_before); + proj->setCurrentCableAutoNum(m_num_key); + } + + //Same as in redo(): worked out again whatever happened above, + //so a cable section or a whole cable taken away never leaves + //the wires it used to feed with a cable field it still writes. + CableManager::refreshLabels(proj); +} diff --git a/sources/cable/addcablecommand.h b/sources/cable/addcablecommand.h new file mode 100644 index 000000000..2980ece12 --- /dev/null +++ b/sources/cable/addcablecommand.h @@ -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 . +*/ +#ifndef ADDCABLECOMMAND_H +#define ADDCABLECOMMAND_H + +#include "cable.h" + +#include "../autoNum/numerotationcontext.h" + +#include +#include +#include + +class CablePart; +class Diagram; +class QETProject; + +/** + @brief Puts a drawn cable line on the folio, or takes it back off. + + Both halves are one single step, because a cable line without its + cable is of no use to anybody: undoing has to take the line away and + leave nothing behind, and redoing has to put both back exactly as + they were. +*/ +class AddCableCommand : public QUndoCommand +{ + public: + AddCableCommand(Cable *cable, + CablePart *part, + Diagram *diagram, + bool own_cable, + const QList &taken = QList()); + ~AddCableCommand() override; + + void undo() override; + void redo() override; + + private: + QETProject *project() const; + + private: + /** + The cable the line belongs to. + + A pointer, not a raw one: RemoveCableCommand deletes the + cable when it goes, and both of them are torn down together + when the stack takes a new command. Whoever of the two runs + first frees the cable, so the other one must not follow it + into the freed memory -- QPointer goes null instead, and + this command then simply has nothing left to clean up. + */ + QPointer m_cable; + ///The line itself, owned by the folio once it is drawn + QPointer m_part; + QPointer m_diagram; + ///Which section of the cable this line is + CablePartData m_part_data; + ///The cores the line was giving to the cable, put back by redo + QList m_cores; + ///The cores this line took away from other cables: redo takes + ///them again, undo gives them back + QList m_taken; + ///True when this command brought the cable into existence, so it + ///is the one which has to take it away again + bool m_own_cable = false; + /** + When this cable took a number out of the project's numbering + rule, the rule as it was before it did and as it was after: + an empty key means the project numbers its cables by no rule + and nothing here applies. Handing the number out is part of + bringing the cable into being, so undo has to hand it back -- + otherwise undoing a cable and drawing it once more would + leave a gap in the numbering for good. + */ + QString m_num_key; + NumerotationContext m_num_before; +}; + +#endif // ADDCABLECOMMAND_H diff --git a/sources/cable/cable.cpp b/sources/cable/cable.cpp new file mode 100644 index 000000000..79810c5e7 --- /dev/null +++ b/sources/cable/cable.cpp @@ -0,0 +1,921 @@ +/* + 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 "cable.h" + +#include +#include + +#include + +namespace { + /** + @brief The name an alignment is written under in the file, an + empty string when the alignment is the plain default (the text + ends where it always did, before the line). + @param alignment Qt::AlignLeft, Qt::AlignHCenter or Qt::AlignRight + @return left, center or right + */ + QString alignmentName(int alignment) + { + switch (alignment) + { + case int(Qt::AlignLeft): return QStringLiteral("left"); + case int(Qt::AlignHCenter): return QStringLiteral("center"); + case int(Qt::AlignRight): return QStringLiteral("right"); + default: return QString(); + } + } + + /** + @brief The alignment a name written in the file stands for. + @param name left, center or right + @return the Qt flags, 0 when the name says nothing + */ + int alignmentFromName(const QString &name) + { + if (name == QLatin1String("left")) return int(Qt::AlignLeft); + if (name == QLatin1String("center")) return int(Qt::AlignHCenter); + if (name == QLatin1String("right")) return int(Qt::AlignRight); + return 0; + } +} + +/** + @brief Cable::Cable + @param parent +*/ +Cable::Cable(QObject *parent) : + QObject(parent), + m_uuid(QUuid::createUuid()) +{ +} + +/** + @brief Cable::uuid + @return the identity of the cable, never empty once constructed +*/ +QUuid Cable::uuid() const +{ + return m_uuid; +} + +/** + @brief Cable::setUuid + Only used while reading a project: a cable keeps the identity it was + saved with, so the conductors it feeds stay bound to it. + @param uuid +*/ +void Cable::setUuid(const QUuid &uuid) +{ + if (uuid.isNull() || uuid == m_uuid) { + return; + } + m_uuid = uuid; + emit changed(); +} + +/** + @brief Cable::designation + @return the cable number, e.g. 15W1, which heads the label drawn next + to the trunk line and opens every core label +*/ +QString Cable::designation() const +{ + return m_designation; +} + +/** + @brief Cable::setDesignation + @param designation +*/ +void Cable::setDesignation(const QString &designation) +{ + if (m_designation == designation) return; + m_designation = designation; + emit changed(); +} + +/** + @brief Cable::designationByHand + @return true when the name of this cable was typed in rather than + worked out of the numbering rule +*/ +bool Cable::designationByHand() const +{ + return m_designation_by_hand; +} + +/** + @brief Cable::setDesignationByHand + Says where the name of this cable comes from. Nothing of it is + drawn, so it changes no label and asks for no redraw. + @param by_hand +*/ +void Cable::setDesignationByHand(bool by_hand) +{ + m_designation_by_hand = by_hand; +} + +/** + @brief Cable::bmk + @return the BMK of the cable +*/ +QString Cable::bmk() const +{ + return m_bmk; +} + +/** + @brief Cable::setBmk + @param bmk +*/ +void Cable::setBmk(const QString &bmk) +{ + if (m_bmk == bmk) return; + m_bmk = bmk; + emit changed(); +} + +/** + @brief Cable::installation + @return the installation (Anlage) the cable belongs to +*/ +QString Cable::installation() const +{ + return m_installation; +} + +/** + @brief Cable::setInstallation + @param installation +*/ +void Cable::setInstallation(const QString &installation) +{ + if (m_installation == installation) return; + m_installation = installation; + emit changed(); +} + +/** + @brief Cable::location + @return the location (Ort) the cable runs through +*/ +QString Cable::location() const +{ + return m_location; +} + +/** + @brief Cable::setLocation + @param location +*/ +void Cable::setLocation(const QString &location) +{ + if (m_location == location) return; + m_location = location; + emit changed(); +} + +/** + @brief Cable::length + @return the length of the cable as it is written down: a free text, + because it may come from the drawing or be typed in by hand +*/ +QString Cable::length() const +{ + return m_length; +} + +/** + @brief Cable::setLength + @param length +*/ +void Cable::setLength(const QString &length) +{ + if (m_length == length) return; + m_length = length; + emit changed(); +} + +/** + @brief Cable::properties + @return every field the user fills in, together with what of it he + want to see drawn next to the trunk line +*/ +CableProperties Cable::properties() const +{ + CableProperties properties; + properties.designation = m_designation; + properties.designation_by_hand = m_designation_by_hand; + properties.type = m_type; + properties.installation = m_installation; + properties.location = m_location; + properties.length = m_length; + properties.show_designation = m_show_designation; + properties.show_type = m_show_type; + properties.show_installation = m_show_installation; + properties.show_location = m_show_location; + properties.show_length = m_show_length; + properties.texts = m_texts; + properties.text_offset = m_text_offset; + properties.core_offset = m_core_offset; + return properties; +} + +/** + @brief Cable::setProperties + Write a whole set of fields at once -- one undo step covers it, and + the label next to the trunk line and the conductors it feeds are + worked out again once, not ten times. + @param properties +*/ +void Cable::setProperties(const CableProperties &properties) +{ + if (properties == this->properties()) return; + + m_designation = properties.designation; + m_designation_by_hand = properties.designation_by_hand; + m_type = properties.type; + m_installation = properties.installation; + m_location = properties.location; + m_length = properties.length; + m_show_designation = properties.show_designation; + m_show_type = properties.show_type; + m_show_installation = properties.show_installation; + m_show_location = properties.show_location; + m_show_length = properties.show_length; + m_texts = properties.texts; + m_text_offset = properties.text_offset; + m_core_offset = properties.core_offset; + emit changed(); +} + +/** + @brief Cable::type + @return the type of the cable, e.g. H07RN-F 4G1,5. The type is copied + from the cable type file when the cable is created, so a project + opens even when that file is gone or has changed since. +*/ +QString Cable::type() const +{ + return m_type; +} + +/** + @brief Cable::setType + @param type +*/ +void Cable::setType(const QString &type) +{ + if (m_type == type) return; + m_type = type; + emit changed(); +} + +/** + @brief Cable::coreCount + @return how many cores the type of this cable has. This is only the + size of the type, not the number of cores actually wired: see + usedCoreCount(). +*/ +int Cable::coreCount() const +{ + return m_core_count; +} + +/** + @brief Cable::setCoreCount + @param count +*/ +void Cable::setCoreCount(int count) +{ + count = qMax(0, count); + if (m_core_count == count) return; + m_core_count = count; + + //A type with fewer cores takes the cores it no longer has away + //from the conductors which showed them: the label of a core the + //cable doesn't own would be a lie. + for (int i = m_cores.size() - 1; i >= 0; --i) { + if (m_cores.at(i).core >= m_core_count) { + m_cores.removeAt(i); + } + } + emit changed(); +} + +/** + @brief Cable::coreColors + @return the colour of each core, in the order of the cable type file +*/ +QStringList Cable::coreColors() const +{ + return m_core_colors; +} + +/** + @brief Cable::setCoreColors + @param colors +*/ +void Cable::setCoreColors(const QStringList &colors) +{ + if (m_core_colors == colors) return; + m_core_colors = colors; + emit changed(); +} + +/** + @brief Cable::typeState + @return everything the type of this cable decides, taken in one + block: changing the type works on that block, and undo puts the very + same block back -- name, size, colours and the cores which go with + them, so no mark can be left standing on a core the cable lost. +*/ +CableTypeState Cable::typeState() const +{ + CableTypeState state; + state.type = m_type; + state.core_count = m_core_count; + state.core_colors = m_core_colors; + state.cores = m_cores; + return state; +} + +/** + @brief Cable::applyTypeState + Put back a state taken with typeState(). + + The count comes before the cores: a cable which has fewer cores than + it had cannot hold the others, and setCores() drops what stands + beyond the count -- which is exactly what a type with fewer cores + means, and exactly what undo has to take back. + @param state +*/ +void Cable::applyTypeState(const CableTypeState &state) +{ + setType(state.type); + setCoreColors(state.core_colors); + setCoreCount(state.core_count); + setCores(state.cores); +} + +/** + @brief Cable::usedCores + @return every core wired to a conductor, ordered by core number +*/ +QList Cable::usedCores() const +{ + QList cores = m_cores; + std::sort(cores.begin(), cores.end(), + [](const CableCore &a, const CableCore &b) {return a.core < b.core;}); + return cores; +} + +/** + @brief Cable::hasCore + @param core + @return true when that core is wired to a conductor +*/ +bool Cable::hasCore(int core) const +{ + for (const CableCore &c : m_cores) { + if (c.core == core) return true; + } + return false; +} + +/** + @brief Cable::core + @param core + @return the assignment of that core, an empty one when it is free +*/ +CableCore Cable::core(int core) const +{ + for (const CableCore &c : m_cores) { + if (c.core == core) return c; + } + return CableCore(); +} + +/** + @brief Cable::setCore + Wire a core to a conductor, or move the slash of an already wired + core. A core number may only appear once, so the previous holder of + that number, if any, is replaced. + @param core +*/ +void Cable::setCore(const CableCore &core) +{ + if (core.core < 0 || core.core >= m_core_count) return; + + for (int i = 0; i < m_cores.size(); ++i) { + if (m_cores.at(i).core == core.core) { + if (m_cores.at(i).conductor == core.conductor + && m_cores.at(i).part == core.part + && m_cores.at(i).position == core.position) { + return; + } + m_cores[i] = core; + emit changed(); + return; + } + } + m_cores.append(core); + emit changed(); +} + +/** + @brief Cable::setCores + Write a whole set of assignments in place of the current one, the way + undoing a dragged slash has to: every core which was moved, swapped + or dropped comes back at once. + @param cores +*/ +void Cable::setCores(const QList &cores) +{ + m_cores = cores; + for (int i = m_cores.size() - 1; i >= 0; --i) { + if (m_cores.at(i).core < 0 || m_cores.at(i).core >= m_core_count) { + m_cores.removeAt(i); + } + } + emit changed(); +} + +/** + @brief Cable::removeCore + Wire a core back to nothing: its conductor stops showing the label + this cable gave it. + @param core +*/ +void Cable::removeCore(int core) +{ + for (int i = 0; i < m_cores.size(); ++i) { + if (m_cores.at(i).core == core) { + m_cores.removeAt(i); + emit changed(); + return; + } + } +} + +/** + @brief Cable::usedCoreCount + @return how many cores of this cable are wired to a conductor +*/ +int Cable::usedCoreCount() const +{ + return m_cores.size(); +} + +/** + @brief Cable::freeCoreCount + @return how many cores the type offers and no conductor shows yet +*/ +int Cable::freeCoreCount() const +{ + return qMax(0, m_core_count - m_cores.size()); +} + +/** + @brief Cable::firstFreeCore + @return the lowest core number which is free, -1 when every core of + the type is already wired +*/ +int Cable::firstFreeCore() const +{ + for (int i = 0; i < m_core_count; ++i) { + if (!hasCore(i)) return i; + } + return -1; +} + +/** + @brief Cable::colorOfCore + @param core + @return the colour this core has in the cable type file, an empty + string when the type doesn't describe that many cores +*/ +QString Cable::colorOfCore(int core) const +{ + return m_core_colors.value(core); +} + +/** + @brief Cable::labelOfCore + The text written into the cable field of the conductor this core is + wired to. It is never typed by hand: it is worked out here, from the + cable and the core, so renaming the cable or changing its type + updates every conductor at once. + @param core + @return e.g. "15W1:br" +*/ +QString Cable::labelOfCore(int core) const +{ + if (m_designation.isEmpty()) { + return QString(); + } + const QString color = colorOfCore(core); + if (color.isEmpty()) { + return m_designation; + } + return m_designation + QLatin1Char(':') + color; +} + +/** + @brief Cable::parts + @return every drawn section of this cable, whatever folio it sits on +*/ +QList Cable::parts() const +{ + return m_parts; +} + +/** + @brief Cable::hasPart + @param part + @return true when the cable has a section with that identity +*/ +bool Cable::hasPart(const QUuid &part) const +{ + if (part.isNull()) return false; + for (const CablePartData &p : m_parts) { + if (p.uuid == part) return true; + } + return false; +} + +/** + @brief Cable::part + @param part + @return the section with that identity, an invalid one when there is none +*/ +CablePartData Cable::part(const QUuid &part) const +{ + for (const CablePartData &p : m_parts) { + if (p.uuid == part) return p; + } + return CablePartData(); +} + +/** + @brief Cable::addPart + @param part +*/ +void Cable::addPart(const CablePartData &part) +{ + if (part.uuid.isNull() || hasPart(part.uuid)) return; + m_parts.append(part); + emit changed(); +} + +/** + @brief Cable::removePart + Also drops the cores drawn on that section, since their slash and + their colour label live with it. + @param part +*/ +void Cable::removePart(const QUuid &part) +{ + bool touched = false; + for (int i = m_parts.size() - 1; i >= 0; --i) { + if (m_parts.at(i).uuid == part) { + m_parts.removeAt(i); + touched = true; + } + } + for (int i = m_cores.size() - 1; i >= 0; --i) { + if (m_cores.at(i).part == part) { + m_cores.removeAt(i); + touched = true; + } + } + if (touched) { + emit changed(); + } +} + +/** + @brief Cable::setPartGeometry + Moving or lengthening the trunk line is a purely optical change: no + core is added, none is removed, nothing is reassigned. Only the line + itself moves -- which is why this never emits changed(): the labels + the cable writes into the conductors do not depend on it. + @param part + @param p1 + @param p2 +*/ +void Cable::setPartGeometry(const QUuid &part, const QPointF &p1, const QPointF &p2) +{ + for (int i = 0; i < m_parts.size(); ++i) { + if (m_parts.at(i).uuid != part) continue; + if (m_parts.at(i).p1 == p1 && m_parts.at(i).p2 == p2) return; + m_parts[i].p1 = p1; + m_parts[i].p2 = p2; + return; + } +} + +/** + @brief Cable::setPartDiagram + @param part + @param diagram +*/ +void Cable::setPartDiagram(const QUuid &part, const QUuid &diagram) +{ + for (int i = 0; i < m_parts.size(); ++i) { + if (m_parts.at(i).uuid != part) continue; + if (m_parts.at(i).diagram == diagram) return; + m_parts[i].diagram = diagram; + emit changed(); + return; + } +} + +/** + @brief Cable::movePartCores + Carry the colour labels of one section along when that section is + moved as a whole: when a copy is put down, when it is stamped a grid + way beside the original, and when the line travels with the rest of + a selection rather than on its own. + + Quiet on purpose. Where a slash stands says nothing about the text + a conductor shows, so there is nothing to work out again -- and this + runs while items travel under the mouse, where one full pass over + every wire of the project would cost more than the movement itself. + @param part the section which moved + @param delta how far it came, in scene coordinates +*/ +void Cable::movePartCores(const QUuid &part, const QPointF &delta) +{ + if (part.isNull() || delta.isNull()) return; + + for (CableCore &core : m_cores) + { + if (core.part == part) { + core.position += delta; + } + } +} + +/** + @brief Cable::toXml + @param document + @return the <cable> element holding every part and every wired core +*/ +QDomElement Cable::toXml(QDomDocument &document) const +{ + QDomElement root = document.createElement(QLatin1String("cable")); + root.setAttribute(QLatin1String("uuid"), m_uuid.toString()); + if (!m_designation.isEmpty()) { + root.setAttribute(QLatin1String("designation"), m_designation); + } + //Only a name which was typed in says so: the ones the numbering + //rule handed out are the ordinary case and need no marking + if (m_designation_by_hand) { + root.setAttribute(QLatin1String("designation_by_hand"), QLatin1String("1")); + } + if (!m_bmk.isEmpty()) { + root.setAttribute(QLatin1String("bmk"), m_bmk); + } + if (!m_installation.isEmpty()) { + root.setAttribute(QLatin1String("installation"), m_installation); + } + if (!m_location.isEmpty()) { + root.setAttribute(QLatin1String("location"), m_location); + } + if (!m_length.isEmpty()) { + root.setAttribute(QLatin1String("length"), m_length); + } + //Only what was switched off is written: a cable written before + //these existed shows everything, like the ones written from now + if (!m_show_designation) { + root.setAttribute(QLatin1String("show_designation"), QLatin1String("0")); + } + if (!m_show_type) { + root.setAttribute(QLatin1String("show_type"), QLatin1String("0")); + } + if (!m_show_installation) { + root.setAttribute(QLatin1String("show_installation"), QLatin1String("0")); + } + if (!m_show_location) { + root.setAttribute(QLatin1String("show_location"), QLatin1String("0")); + } + if (!m_show_length) { + root.setAttribute(QLatin1String("show_length"), QLatin1String("0")); + } + if (!m_type.isEmpty()) { + root.setAttribute(QLatin1String("type"), m_type); + } + if (m_core_count > 0) { + root.setAttribute(QLatin1String("cores"), QString::number(m_core_count)); + } + + if (!m_core_colors.isEmpty()) + { + QDomElement colors = document.createElement(QLatin1String("colors")); + for (const QString &color : m_core_colors) { + QDomElement e = document.createElement(QLatin1String("color")); + e.appendChild(document.createTextNode(color)); + colors.appendChild(e); + } + root.appendChild(colors); + } + + //Only the texts this cable does not write the way the preferences + //say are stored, so a cable stays silent as long as it merely + //follows the default -- and a later change of that default still + //reaches it. The nudges are kept in the same element: they say + //where those texts stand rather than what the cable is. + if (!m_texts.isEmpty() || !m_text_offset.isNull() || !m_core_offset.isNull()) + { + QDomElement texts = document.createElement(QLatin1String("texts")); + bool stored = false; + for (auto it = m_texts.cbegin(); it != m_texts.cend(); ++it) + { + const CableTextFormat &format = it.value(); + if (format.font.isEmpty() && format.alignment == 0) { + continue; + } + QDomElement e = document.createElement(QLatin1String("text")); + e.setAttribute(QLatin1String("key"), it.key()); + if (!format.font.isEmpty()) { + e.setAttribute(QLatin1String("font"), format.font); + } + const QString align = alignmentName(format.alignment); + if (!align.isEmpty()) { + e.setAttribute(QLatin1String("align"), align); + } + texts.appendChild(e); + stored = true; + } + if (!m_text_offset.isNull()) + { + texts.setAttribute(QLatin1String("text_dx"), + QString::number(m_text_offset.x(), 'f', 2)); + texts.setAttribute(QLatin1String("text_dy"), + QString::number(m_text_offset.y(), 'f', 2)); + stored = true; + } + if (!m_core_offset.isNull()) + { + texts.setAttribute(QLatin1String("core_dx"), + QString::number(m_core_offset.x(), 'f', 2)); + texts.setAttribute(QLatin1String("core_dy"), + QString::number(m_core_offset.y(), 'f', 2)); + stored = true; + } + if (stored) { + root.appendChild(texts); + } + } + + for (const CablePartData &part : m_parts) + { + QDomElement e = document.createElement(QLatin1String("part")); + e.setAttribute(QLatin1String("uuid"), part.uuid.toString()); + e.setAttribute(QLatin1String("diagram"), part.diagram.toString()); + e.setAttribute(QLatin1String("x1"), QString::number(part.p1.x(), 'f', 2)); + e.setAttribute(QLatin1String("y1"), QString::number(part.p1.y(), 'f', 2)); + e.setAttribute(QLatin1String("x2"), QString::number(part.p2.x(), 'f', 2)); + e.setAttribute(QLatin1String("y2"), QString::number(part.p2.y(), 'f', 2)); + root.appendChild(e); + } + + for (const CableCore &core : usedCores()) + { + QDomElement e = document.createElement(QLatin1String("core")); + e.setAttribute(QLatin1String("index"), QString::number(core.core)); + if (!core.part.isNull()) { + e.setAttribute(QLatin1String("part"), core.part.toString()); + } + if (!core.conductor.isNull()) { + e.setAttribute(QLatin1String("conductor"), core.conductor.toString()); + } + e.setAttribute(QLatin1String("x"), QString::number(core.position.x(), 'f', 2)); + e.setAttribute(QLatin1String("y"), QString::number(core.position.y(), 'f', 2)); + root.appendChild(e); + } + + return root; +} + +/** + @brief Cable::fromXml + @param element a <cable> element +*/ +void Cable::fromXml(const QDomElement &element) +{ + m_uuid = QUuid(element.attribute(QLatin1String("uuid"))); + if (m_uuid.isNull()) { + m_uuid = QUuid::createUuid(); + } + m_designation = element.attribute(QLatin1String("designation")); + //A file written before this existed holds names the program + //gave, which is what not marking them means + m_designation_by_hand = element.attribute( + QLatin1String("designation_by_hand")) == QLatin1String("1"); + m_bmk = element.attribute(QLatin1String("bmk")); + m_installation = element.attribute(QLatin1String("installation")); + m_location = element.attribute(QLatin1String("location")); + m_length = element.attribute(QLatin1String("length")); + m_show_designation = element.attribute( + QLatin1String("show_designation"), QLatin1String("1")).toInt() != 0; + m_show_type = element.attribute( + QLatin1String("show_type"), QLatin1String("1")).toInt() != 0; + m_show_installation = element.attribute( + QLatin1String("show_installation"), QLatin1String("1")).toInt() != 0; + m_show_location = element.attribute( + QLatin1String("show_location"), QLatin1String("1")).toInt() != 0; + m_show_length = element.attribute( + QLatin1String("show_length"), QLatin1String("1")).toInt() != 0; + m_type = element.attribute(QLatin1String("type")); + m_core_count = qMax(0, element.attribute(QLatin1String("cores"), QLatin1String("0")).toInt()); + + m_core_colors.clear(); + const QDomElement colors = element.firstChildElement(QLatin1String("colors")); + for (QDomElement e = colors.firstChildElement(QLatin1String("color")); + !e.isNull(); + e = e.nextSiblingElement(QLatin1String("color"))) { + m_core_colors.append(e.text()); + } + + //The texts this cable writes its own way; everything not in here + //keeps following what the preferences say. + m_texts.clear(); + m_text_offset = QPointF(); + m_core_offset = QPointF(); + const QDomElement texts = element.firstChildElement(QLatin1String("texts")); + for (QDomElement e = texts.firstChildElement(QLatin1String("text")); + !e.isNull(); + e = e.nextSiblingElement(QLatin1String("text"))) + { + const QString key = e.attribute(QLatin1String("key")); + if (key.isEmpty()) continue; + CableTextFormat format; + format.font = e.attribute(QLatin1String("font")); + format.alignment = alignmentFromName(e.attribute(QLatin1String("align"))); + if (format.font.isEmpty() && format.alignment == 0) continue; + m_texts.insert(key, format); + } + if (!texts.isNull()) + { + m_text_offset.setX(texts.attribute(QLatin1String("text_dx")).toDouble()); + m_text_offset.setY(texts.attribute(QLatin1String("text_dy")).toDouble()); + m_core_offset.setX(texts.attribute(QLatin1String("core_dx")).toDouble()); + m_core_offset.setY(texts.attribute(QLatin1String("core_dy")).toDouble()); + } + + m_parts.clear(); + for (QDomElement e = element.firstChildElement(QLatin1String("part")); + !e.isNull(); + e = e.nextSiblingElement(QLatin1String("part"))) + { + CablePartData part; + part.uuid = QUuid(e.attribute(QLatin1String("uuid"))); + if (part.uuid.isNull()) { + continue; + } + part.diagram = QUuid(e.attribute(QLatin1String("diagram"))); + part.p1 = QPointF(e.attribute(QLatin1String("x1")).toDouble(), + e.attribute(QLatin1String("y1")).toDouble()); + part.p2 = QPointF(e.attribute(QLatin1String("x2")).toDouble(), + e.attribute(QLatin1String("y2")).toDouble()); + m_parts.append(part); + } + + m_cores.clear(); + for (QDomElement e = element.firstChildElement(QLatin1String("core")); + !e.isNull(); + e = e.nextSiblingElement(QLatin1String("core"))) + { + CableCore core; + core.core = e.attribute(QLatin1String("index"), QLatin1String("-1")).toInt(); + if (core.core < 0) { + continue; + } + core.part = QUuid(e.attribute(QLatin1String("part"))); + core.conductor = QUuid(e.attribute(QLatin1String("conductor"))); + core.position = QPointF(e.attribute(QLatin1String("x")).toDouble(), + e.attribute(QLatin1String("y")).toDouble()); + if (hasCore(core.core)) { + continue; + } + m_cores.append(core); + } +} diff --git a/sources/cable/cable.h b/sources/cable/cable.h new file mode 100644 index 000000000..29a0c9d55 --- /dev/null +++ b/sources/cable/cable.h @@ -0,0 +1,376 @@ +/* + 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 CABLE_H +#define CABLE_H + +#include +#include +#include +#include +#include +#include +#include + +class QDomDocument; +class QDomElement; + +/** + @brief One core of a cable which is wired to a conductor of the drawing. + + A cable owns its assignment: this is the only place in QElectroTech + which knows that core number 3 of cable 15W1 lands on such a + conductor. The conductor itself only carries the text the assignment + generates (15W1:br), written back by CableManager::refreshLabels(), + so the text can never drift away from the cable it comes from. +*/ +struct CableCore +{ + /// Core number inside the cable, 0 based, from 0 to coreCount()-1 + int core = -1; + /// The part carrying the slash drawn for this core + QUuid part; + /// The conductor this core lands on, null when the core is free + QUuid conductor; + /// Where the slash sits, in the coordinates of its own part + QPointF position; + + ///Two assignments are the same when they say the same thing, so + ///undo can tell a gesture which changed something from one which + ///merely put the slash back where it already was + friend bool operator==(const CableCore &a, const CableCore &b) + { + return a.core == b.core && a.part == b.part + && a.conductor == b.conductor && a.position == b.position; + } + friend bool operator!=(const CableCore &a, const CableCore &b) + { + return !(a == b); + } +}; + +/** + @brief One drawn section of a cable: the trunk line of one folio. + + The geometry is kept as plain data rather than as a pointer to the + graphics item, because the folio owns the item and deletes it when the + folio is deleted, while the cable outlives it (it may have another + part on another folio). +*/ +struct CablePartData +{ + QUuid uuid; + QUuid diagram; + QPointF p1; + QPointF p2; + + bool isHorizontal() const {return qFuzzyCompare(p1.y(), p2.y());} +}; + +/** + @brief How one of the texts of a cable is written. + + Both fields may say nothing at all, and that is the normal case: + the cable then writes that text the way the preferences offer it + (QETApp::cableTextsFont() and QETApp::cableCoreFont()), so picking + another default in the preferences reaches every cable which has + not been given a font of its own. + + The texts of a cable are kept in one hash rather than one pair of + members each, so that a new text can be added without dragging a + new field through the whole code. They are named the way the file + writes them: + "designation", "type", "installation", "location", "length" for the + five texts written next to the trunk line, and "cores" for the + colour labels of the cores -- which have no alignment, being turned + by a quarter along their own line. +*/ +struct CableTextFormat +{ + ///The font, in the stable description QETUtils::fontToString() + ///writes; empty when this cable follows the preferences + QString font; + ///Qt::AlignLeft, Qt::AlignHCenter or Qt::AlignRight; 0 when the + ///text goes on ending where it always did, before the line + int alignment = 0; + + bool operator==(const CableTextFormat &other) const + { + return font == other.font && alignment == other.alignment; + } + bool operator!=(const CableTextFormat &other) const + { + return !(*this == other); + } +}; + +/** + @brief Everything about a cable the user may type in or tick, and + whether what he typed is written on the sheet at all. + + It is kept as a whole so that one undo step can put a whole edit + back, and so the selection panel and the dialog always offer exactly + the same fields in the same order. +*/ +struct CableProperties +{ + ///Name of the cable, e.g. 15W1 -- the head of the label + QString designation; + /** + Where that name comes from: false while the program worked + it out of the numbering rule, true from the moment it was + typed in. Nothing shows it and no field edits it -- it + travels with the fields so that undo puts it back with them, + and so that numbering the whole project over again knows + which names were never the rule's to begin with and may be + left alone. + */ + bool designation_by_hand = false; + ///Kind of cable, e.g. H07RN-F 4G1,5 + QString type; + ///Installation the cable belongs to + QString installation; + ///Place the cable runs through + QString location; + ///Length, typed in by hand + QString length; + + ///What of that is written next to the trunk line of the drawing + bool show_designation = true; + bool show_type = true; + bool show_installation = true; + bool show_location = true; + bool show_length = true; + + ///How each text is written -- font and alignment, keyed by the + ///names listed on CableTextFormat. A text which is not in here + ///follows what the preferences say, which is where a cable + ///freshly drawn stands for all of them. + QHash texts; + + ///How far the label at the left of the trunk line is nudged from + ///where it has always stood. It is a nudge on the drawing only, + ///and it moves the whole label as one thing: installation, place + ///and designation over the line, the type and the length under + ///it -- while the line, the slashes and the wiring keep standing + ///exactly where they were. + QPointF text_offset; + ///How far the row of colours is nudged from the slashes it names. + ///Again on the drawing only: the slashes, the entries they mark + ///and every wire behind them stay where they are, and so does the + ///row itself when the trunk line is moved. + QPointF core_offset; + + bool operator==(const CableProperties &other) const + { + return designation == other.designation + && designation_by_hand == other.designation_by_hand + && type == other.type + && installation == other.installation + && location == other.location + && length == other.length + && show_designation == other.show_designation + && show_type == other.show_type + && show_installation == other.show_installation + && show_location == other.show_location + && show_length == other.show_length + && texts == other.texts + && text_offset == other.text_offset + && core_offset == other.core_offset; + } + bool operator!=(const CableProperties &other) const + { + return !(*this == other); + } +}; + +/** + @brief Everything about a cable which its type decides, taken as one + block so that changing the type is one single step. + + The cores go with the type rather than with the text fields: taking a + type with fewer cores takes those cores off the drawing, and undoing + has to bring their marks back along with the name and the colours. +*/ +struct CableTypeState +{ + ///Kind of cable, e.g. H07RN-F 4G1,5 + QString type; + ///How many cores that type has + int core_count = 0; + ///The colour of each of them, in core order + QStringList core_colors; + ///The cores the cable really holds at this moment + QList cores; + + bool operator==(const CableTypeState &other) const + { + return type == other.type + && core_count == other.core_count + && core_colors == other.core_colors + && cores == other.cores; + } + bool operator!=(const CableTypeState &other) const + { + return !(*this == other); + } +}; + +/** + @brief The Cable class is the project wide object a drawn cable line + stands for. + + It carries what the user fills in -- the type, the number, BMK, + installation, location -- together with what QElectroTech works out: + how many cores the type has, which colour each core has, which cores + are wired to which conductor, and which folios the cable runs on. + + Properties belong to the cable, never to one of its parts: editing + the type or the number anywhere changes it for every part. +*/ +class Cable : public QObject +{ + Q_OBJECT + + public: + explicit Cable(QObject *parent = nullptr); + + //Identity + QUuid uuid() const; + void setUuid(const QUuid &uuid); + + //Properties the user fills in + QString designation() const; + void setDesignation(const QString &designation); + /** + True when the name of this cable was typed in rather + than worked out of the numbering rule: numbering the + whole project over asks before it takes such a name. + */ + bool designationByHand() const; + void setDesignationByHand(bool by_hand); + QString bmk() const; + void setBmk(const QString &bmk); + QString installation() const; + void setInstallation(const QString &installation); + QString location() const; + void setLocation(const QString &location); + QString length() const; + void setLength(const QString &length); + + ///Every field of the cable, and what of it is drawn on the sheet + CableProperties properties() const; + void setProperties(const CableProperties &properties); + + //Type, copied from the cable type file when the cable is created + QString type() const; + void setType(const QString &type); + int coreCount() const; + void setCoreCount(int count); + QStringList coreColors() const; + void setCoreColors(const QStringList &colors); + ///Everything the type of this cable decides, taken as one block + CableTypeState typeState() const; + ///Put back a state taken with typeState(), in one single pass + void applyTypeState(const CableTypeState &state); + + //Cores + QList usedCores() const; + bool hasCore(int core) const; + CableCore core(int core) const; + void setCore(const CableCore &core); + ///Write a whole set of assignments in place of another + void setCores(const QList &cores); + void removeCore(int core); + int usedCoreCount() const; + int freeCoreCount() const; + ///The lowest core number which is free, -1 when none is + int firstFreeCore() const; + ///The colour of a core, as written in the cable type file + QString colorOfCore(int core) const; + ///The text a conductor of this core shows, e.g. "15W1:br" + QString labelOfCore(int core) const; + + //Parts + QList parts() const; + bool hasPart(const QUuid &part) const; + CablePartData part(const QUuid &part) const; + void addPart(const CablePartData &part); + void removePart(const QUuid &part); + void setPartGeometry(const QUuid &part, const QPointF &p1, const QPointF &p2); + void setPartDiagram(const QUuid &part, const QUuid &diagram); + ///Carry the colour labels of one section along when that + ///section is moved as a whole (paste, duplicate, a selection + ///being moved with everything else on it) + void movePartCores(const QUuid &part, const QPointF &delta); + + //Serialisation + QDomElement toXml(QDomDocument &document) const; + void fromXml(const QDomElement &element); + + signals: + void changed(); + + private: + QUuid m_uuid; + QString m_designation; + ///< where that name comes from (see designationByHand) + bool m_designation_by_hand = false; + QString m_bmk; + QString m_installation; + QString m_location; + QString m_length; + QString m_type; + int m_core_count = 0; + QStringList m_core_colors; + QList m_cores; + QList m_parts; + ///What the label next to the trunk line is allowed to show + bool m_show_designation = true; + bool m_show_type = true; + bool m_show_installation = true; + bool m_show_location = true; + bool m_show_length = true; + ///The texts this cable writes its own way, by the names + ///listed on CableTextFormat; everything not in here follows + ///what the preferences say + QHash m_texts; + ///How far the label at the left of the line and the row of + ///colours are nudged on the drawing: distances nothing else + ///in the project ever looks at + QPointF m_text_offset; + QPointF m_core_offset; +}; + +/** + @brief One core which was taken away from another cable. + + A conductor belongs to one cable only, so drawing a second cable + across wires a first one already holds takes those cores away from + it. That has to be remembered where it is decided, so that undoing + the second cable gives the first one its cores back instead of + leaving those conductors with a blank cable field forever. +*/ +struct CableCoreTaken +{ + ///The cable the core was taken from; null when it is gone + QPointer cable; + ///The core exactly as the other cable held it + CableCore core; +}; + +#endif // CABLE_H diff --git a/sources/cable/cablecopy.cpp b/sources/cable/cablecopy.cpp new file mode 100644 index 000000000..2ac97c893 --- /dev/null +++ b/sources/cable/cablecopy.cpp @@ -0,0 +1,454 @@ +/* + 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 "cablecopy.h" + +#include "cablemanager.h" +#include "cablepart.h" + +#include "../autoNum/assignvariables.h" +#include "../autoNum/numerotationcontext.h" +#include "../diagram.h" +#include "../qetgraphicsitem/conductor.h" +#include "../qetproject.h" + +#include +#include + +#include + +/** + @brief CableCopy::write + Puts the cable lines selected on @a diagram into @a document, so a + copy carries them the way it carries the elements and the texts of + the same selection. + + Only the line which was picked is written, together with the whole + cable it belongs to: the copy needs everything the cable knows, but + only the one section it is standing on. + @param diagram the folio being copied + @param document the fragment the clipboard is about to hold +*/ +void CableCopy::write(Diagram *diagram, QDomDocument &document) +{ + if (!diagram) return; + QDomElement root = document.documentElement(); + if (root.isNull()) return; + + QList parts; + for (QGraphicsItem *item : diagram->selectedItems()) { + if (auto *part = qgraphicsitem_cast(item)) { + parts << part; + } + } + if (parts.isEmpty()) return; + + QDomElement block = document.createElement(QStringLiteral("cables")); + bool anything = false; + for (CablePart *part : std::as_const(parts)) + { + Cable *cable = part->cable(); + if (!cable) continue; + + QDomElement element = cable->toXml(document); + const QPointF p1 = part->firstPoint(); + const QPointF p2 = part->secondPoint(); + element.setAttribute(QStringLiteral("line"), part->partUuid().toString()); + element.setAttribute(QStringLiteral("x1"), QString::number(p1.x(), 'f', 2)); + element.setAttribute(QStringLiteral("y1"), QString::number(p1.y(), 'f', 2)); + element.setAttribute(QStringLiteral("x2"), QString::number(p2.x(), 'f', 2)); + element.setAttribute(QStringLiteral("y2"), QString::number(p2.y(), 'f', 2)); + block.appendChild(element); + anything = true; + } + if (anything) root.appendChild(block); +} + +/** + @brief CableCopy::read + Builds a cable of its own for every line @a root carries, each of + them standing exactly where the fragment says it stood. + + The cable keeps everything the source cable knows -- type, + installation, place, length, how many cores, which colours -- and it + also keeps where the colour labels of the copied section stand, so a + copy arrives as a whole: onto a folio which holds no wire at all, + into an empty part of a sheet, wherever. What it does not keep is + any wire: a copy may not take a wire away from the cable it was + copied from, not even for as long as it is being built, so every + core is let go while the cable still answers to the source's + identity -- no conductor of the source is ever repointed, not even + for the length of one signal. + + Which wire each carried core belongs to is worked out later, in + CableCopy::wire(), because that depends on where the line has + finally been put -- and a copy put where there is no wire simply + keeps its labels, to be wired when the line is pulled into place. + @param diagram the folio which is about to hold those lines + @param root the <diagram> element of a copy fragment + @return the lines built, not yet wired +*/ +QList CableCopy::read(Diagram *diagram, const QDomElement &root) +{ + QList built; + if (!diagram || !diagram->project()) return built; + + const QDomElement block = root.firstChildElement(QStringLiteral("cables")); + if (block.isNull()) return built; + + QETProject *project = diagram->project(); + //One rule for the whole run. Every cable of a paste takes a + //number from it and steps it on, so several cables pasted at + //once do not all end up under the same number -- although the + //project's own rule does not move until those cables really + //exist, which is where the commands which add them take over. + //A counter bound to the folio starts the folio the copies are + //pasted onto at its own beginning, once for the whole run. + NumerotationContext rule = autonum::resetFolioCounters( + project->cableAutoNum(project->cableCurrentAutoNum())); + for (QDomElement element = block.firstChildElement(QStringLiteral("cable")); + !element.isNull(); + element = element.nextSiblingElement(QStringLiteral("cable"))) + { + if (element.attribute(QStringLiteral("x1")).isEmpty()) continue; + + Cable *cable = project->newCable(); + cable->fromXml(element); + + //The section of the copy, standing where the section it was + //copied from stood: the caller moves it to its final place, + //and the colour labels come with it (CablePart::itemChange) + CablePartData own; + own.uuid = QUuid::createUuid(); + own.diagram = diagram->uuid(); + own.p1 = QPointF(element.attribute(QStringLiteral("x1")).toDouble(), + element.attribute(QStringLiteral("y1")).toDouble()); + own.p2 = QPointF(element.attribute(QStringLiteral("x2")).toDouble(), + element.attribute(QStringLiteral("y2")).toDouble()); + + //The colour labels of that one section, carried over whole + //and with every wire taken away + const QUuid source_part(element.attribute(QStringLiteral("line"))); + QList carried; + for (const CableCore &core : cable->usedCores()) + { + if (core.part != source_part) continue; + + CableCore copy = core; + copy.conductor = QUuid(); + copy.part = own.uuid; + carried.append(copy); + } + //Goes first, while the copy still answers to the source's + //identity: that way nothing of the source is left pointing + //at a cable which is about to become somebody else + cable->setCores(carried); + + //The copy is a cable of its own: nothing of the source's + //identity, nothing of its sections and nothing of the wires + //it wires may be taken over, only what the user filled in + for (const CablePartData &other : cable->parts()) { + cable->removePart(other.uuid); + } + cable->setUuid(QUuid::createUuid()); + //The copy is numbered by the rule like every other cable. + //A project which has no rule gets no number invented for it, + //and then what the copy was copied from keeps standing there + //rather than being wiped out by an empty one. + const QString designation = + CableManager::nextDesignation(project, diagram, rule); + if (!designation.isEmpty()) { + cable->setDesignation(designation); + //The copy is numbered by the rule, so whatever the + //source was called by hand is not carried over + cable->setDesignationByHand(false); + } + cable->addPart(own); + + auto *item = new CablePart(own); + item->setCable(cable); + diagram->addItem(item); + built << item; + } + return built; +} + +/** + @brief CableCopy::wire + Give the cores of every line in @a parts the wire each of them + stands on now that the line is where it is going to stay. + + A copy arrives whole: it carries the colour labels it was copied + with, and this is the moment they are matched against the wires of + the new place. Matching never moves a label: each one goes on + standing exactly where it was carried, and the wire it stands on -- + within reach, as everywhere else -- is the one which takes the cable + field. A label standing on no wire at all simply goes on + standing there -- that is what makes a copy useful onto a folio + which holds no wire yet: the line is there, with its colours, ready + to be pulled into place, and pulling it there wires it + (CablePart::settleCores). + + A piece of wire belongs to one cable only, and a copy never takes + one away: the wires of the folio it lands on which another cable + already describes are left to that cable, nothing is asked about + them and nothing is written for them. The wires which came along + with the copy itself are different -- they are new wires on this + folio, carrying the cable field they had back on the folio they + come from, where they used to be one of that cable's own wires. If + that were still believed here, every one of them would look like a + wire another cable holds and the copy would be refused all of them, + arriving without a single entry although it is standing on wires of + its own. So they are let go of first, exactly like read() lets go + of the wires the cores used to name. + @param diagram the folio the lines are on + @param parts the lines built by CableCopy::read() + @param fresh the wires which came with this copy + @return one entry per line which is going to stay, with what it + had to give back to other cables, ready to be put on the undo stack +*/ +QList CableCopy::wire(Diagram *diagram, + const QList &parts, + const QList &fresh) +{ + QList wired; + if (!diagram || !diagram->project()) { + for (CablePart *part : parts) discard(diagram, part); + return wired; + } + QETProject *project = diagram->project(); + + //A wire which came with this copy is a new wire here: what it + //says about a cable describes the folio it was copied from. + //Done by identity rather than by position, because the wire it + //was copied from may well stand in this very place -- and that + //one goes on belonging to its own cable. + for (Conductor *conductor : fresh) + { + if (!conductor) continue; + if (conductor->properties().m_cable_uuid.isNull()) continue; + + + conductor->setCableReference(QString(), QUuid(), -1); + } + + for (CablePart *part : parts) + { + Cable *cable = part ? part->cable() : nullptr; + if (!cable) { + discard(diagram, part); + continue; + } + + //One wire a colour label is dropped on. Two labels may + //not be given the same wire, so each one is handed out + //once. + struct Offered + { + int core; + Conductor *conductor; + }; + QList offered; + QSet wires; + + const QLineF line(part->firstPoint(), part->secondPoint()); + const QList crossings = CableManager::crossedConductors(diagram, line); + for (const CableCore &core : cable->usedCores()) + { + if (core.part != part->partUuid()) continue; + if (!core.conductor.isNull()) continue; + + //The wire this label stands on, worked out the way it + //is worked out everywhere else (CablePart::wireFor): + //the nearest place this line runs across a wire, close + //enough for the slash and the wire to read as one + //thing. The label itself is not moved to reach it -- + //a copy arrives with its colours where they were + //carried, and no gesture moves one of them, so here + //too the entry follows the label rather than the + //label being sent off to find a wire. When that wire + //has already been handed to another label of the same + //copy, the next nearest one this label stands on wins, + //rather than leaving it with no wire although one is + //under it. + QList open = crossings; + Conductor *conductor = nullptr; + while (true) + { + Conductor *nearest = part->wireFor(open, core, core.position); + if (!nearest) break; + if (!wires.contains(nearest->uuid())) { + conductor = nearest; + break; + } + for (int i = open.size() - 1; i >= 0; --i) { + if (open.at(i).conductor == nearest) open.removeAt(i); + } + } + + if (!conductor) continue; + + //A wire another cable already describes stays with that + //cable. Taking it away would empty a cable behind the + //user's back, and asking about it on every paste turned + //putting a copy into an existing drawing into a question + //to answer first -- so a copy simply takes the wires + //nobody holds, and its colour labels standing on the + //others name nothing at all. An entry follows its label, + //and here it comes to a stop. He can give such a label + //one of those wires himself afterwards, by dragging it + //there, which is where the question is still asked. + const QUuid holder_uuid = conductor->properties().m_cable_uuid; + if (!holder_uuid.isNull() && holder_uuid != cable->uuid()) + { + const Cable *previous = CableManager::cableByUuid(project, holder_uuid); + if (previous + && previous->hasCore(conductor->properties().m_cable_slot)) + { + continue; + } + } + + wires.insert(conductor->uuid()); + Offered item; + item.core = core.core; + item.conductor = conductor; + offered.append(item); + } + + QList bound; + for (const Offered &item : std::as_const(offered)) + { + //A wire is one piece of wire: whichever cable held it + //before has to let it go. + CableManager::claimConductor(project, item.conductor, cable); + + //A cable binds a conductor by its identity, so a + //conductor read back from a file written before it had a + //persistent one is given one to keep from now on. + item.conductor->setUuid(item.conductor->uuid()); + + CableCore core = cable->core(item.core); + core.conductor = item.conductor->uuid(); + cable->setCore(core); + + Bound bind; + bind.core = item.core; + bind.conductor = item.conductor; + bound.append(bind); + } + + Wired wired_line; + wired_line.part = part; + wired_line.bound = bound; + wired.append(wired_line); + } + return wired; +} + +/** + @brief CableCopy::repoint + Work out again which wire each core of @a wired is on, using the + identity the wire has right now. + + A copy is put on the undo stack just after it was wired, and the + first run of that stack renews the identity of every conductor it + pasted (PasteDiagramCommand::redo): a copy must not keep the + identity of the conductor it was copied from. The cores of the copy + would then go on naming an identity which no longer exists, and the + next working out of the labels (CableManager::refreshLabels) would + find nothing there and take the cable field away from those wires. + + So the cores are pointed at the very same wires again, with their + new identity, and the cable fields are written out from the cables + one more time afterwards: what the copy was given as it was put + down still names the identity its wires had a moment earlier, and + on a folio where those wires have their own new identity that + leaves them without an entry although the copy is standing on them. + Nothing moves and nothing is asked: an untouched copy is not + changed by any of this at all. + @param wired the lines CableCopy::wire() just gave wires to +*/ +void CableCopy::repoint(const QList &wired) +{ + for (const Wired &line : wired) + { + Cable *cable = line.part ? line.part->cable() : nullptr; + if (!cable) continue; + + for (const Bound &bind : line.bound) + { + if (!bind.conductor) continue; + + const QUuid now = bind.conductor->uuid(); + const CableCore core = cable->core(bind.core); + if (core.core != bind.core || core.conductor == now) continue; + + + CableCore updated = core; + updated.conductor = now; + cable->setCore(updated); + } + } + + //With every core naming the identity its wire really has now, + //the cable fields are written out from the cables once more: + //this is what puts the entries a copy was given as it was put + //down onto the wires it has actually landed on. + Diagram *folio = nullptr; + for (const Wired &line : wired) { + if (line.part && line.part->diagram()) { + folio = line.part->diagram(); + break; + } + } + if (folio && folio->project()) { + CableManager::refreshLabels(folio->project()); + } +} + +/** + @brief CableCopy::discard + Take a line away again whole -- off the folio, out of the project, + and deleted -- without putting anything on the undo stack, because + nothing really happened: the user said no to the question above. + + Whatever the cable said while it was being built must not be left + behind either: the project has to go on claiming exactly what it + claimed before, or it would ask to be saved although nothing was + changed at all. + @param diagram the folio it was built on + @param part the line to take away, may be null +*/ +void CableCopy::discard(Diagram *diagram, CablePart *part) +{ + if (!part) return; + Cable *cable = part->cable(); + QETProject *project = diagram ? diagram->project() : nullptr; + + if (part->scene()) part->scene()->removeItem(part); + + if (cable && project && project->cables().contains(cable)) + { + const bool options_modified = project->projectOptionsWereModified(); + project->removeCable(cable); + project->setModified(options_modified); + } + + delete part; + delete cable; +} diff --git a/sources/cable/cablecopy.h b/sources/cable/cablecopy.h new file mode 100644 index 000000000..040e7c402 --- /dev/null +++ b/sources/cable/cablecopy.h @@ -0,0 +1,126 @@ +/* + 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 CABLECOPY_H +#define CABLECOPY_H + +#include "cable.h" + +#include +#include + +class CablePart; +class Conductor; +class Diagram; +class QDomDocument; +class QDomElement; +class QUndoCommand; + +/** + @brief Carrying a drawn cable line over into a copy -- through the + clipboard (Ctrl+C then Ctrl+V) or as a duplicate (Ctrl+D). + + What travels is the line and everything which made the cable what it + was: its type, its installation, the place it runs through, its + length, how many cores it has, which colours those have -- and where + the colour labels of the copied section stand, so a copy arrives as + a whole even onto a folio which holds no wire at all. What does not + travel is which wire each core is on: a copy is a cable of its own + with a number of its own, and it takes the wire each colour label + happens to stand on at the place it is put -- those of the folio it + lands on which no other cable describes. A wire another cable + already holds is left to that cable, and a label put where there is + no wire goes on standing there, to be wired when the line is pulled + into place. + + A cable line is not one of the folio's items (the cable owns it), + so Diagram::toXml() never writes it: the block travels beside the + folio's own XML rather than inside it, and Diagram::fromXml() reads + it back only when its caller asks to be handed the lines it built. +*/ +namespace CableCopy +{ + ///Put the cable lines selected on @a diagram into @a document + void write(Diagram *diagram, QDomDocument &document); + + ///Build new cables of their own for the lines @a root holds, + ///at the coordinates the fragment carries, with the colour + ///labels of the copied section carried over as they were. + ///Nothing is asked and no wire is taken yet: that needs the + ///place the lines have really been put at, which only the + ///caller knows + QList read(Diagram *diagram, const QDomElement &root); + + ///One core of a line, and the wire it was given + struct Bound + { + int core = -1; + Conductor *conductor = nullptr; + }; + + ///One line which was built, with what it had to take away + struct Wired + { + QPointer part; + QList taken; + ///The wires the colour labels of this line were given, + ///so they can be pointed at them again afterwards + QList bound; + }; + + ///Give each colour label of a line the wire it stands on now + ///that the line is where it will stay. + /// + ///@a fresh lists the wires which came along with the copy + ///itself. They are new wires on this folio: the cable field + ///they carry describes the folio they were copied from, where + ///they used to be one of that cable's own wires -- and carried + ///over, that reference would make every one of them look like + ///a wire some other cable holds, so the copy would be refused + ///all of them and arrive without a single entry although it is + ///standing on wires of its own. They are let go of first, the + ///same way read() lets go of the wires the cores used to name. + /// + ///A wire the folio already held, which another cable + ///describes, is never taken away from that cable: nothing is + ///asked and nothing is written for it, and the colour label + ///standing on it simply names nothing until the user drags it + ///onto a free wire himself, which is where the question is + ///still asked. A label standing on no wire at all goes on + ///standing where it is, to be wired when the line is pulled + ///into place. + QList wire(Diagram *diagram, + const QList &parts, + const QList &fresh); + + ///Point the cores @a wired lists at the wires they were given, + ///again: putting the copy on the undo stack may have renewed + ///the identity of a pasted conductor (PasteDiagramCommand::redo), + ///and a cable binds a wire by its identity. Nothing is asked and + ///nothing moves here. The cable fields are written out once more + ///afterwards, from the cables themselves, so the entries a copy + ///was given as it was put down are on the wires it really + ///landed on rather than on the identity they had one moment + ///earlier. + void repoint(const QList &wired); + + ///Take a built line back off the folio without leaving a trace + ///in the undo history, for a copy which was refused + void discard(Diagram *diagram, CablePart *part); +} + +#endif // CABLECOPY_H diff --git a/sources/cable/cablecreatedialog.cpp b/sources/cable/cablecreatedialog.cpp new file mode 100644 index 000000000..5b098ff68 --- /dev/null +++ b/sources/cable/cablecreatedialog.cpp @@ -0,0 +1,1407 @@ +/* + 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 "cablecreatedialog.h" + +#include "editcablecommand.h" +#include "../diagram.h" +#include "../qetgraphicsitem/conductor.h" +#include "../qetproject.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +/** + @brief CableFilterProxy::CableFilterProxy + @param parent +*/ +CableFilterProxy::CableFilterProxy(QObject *parent) : + QSortFilterProxyModel(parent) +{ +} + +/** + @brief CableFilterProxy::setTokens + @param tokens every word the user typed, all of which have to be found +*/ +void CableFilterProxy::setTokens(const QStringList &tokens) +{ + if (m_tokens == tokens) return; + m_tokens = tokens; + beginFilterChange(); + endFilterChange(QSortFilterProxyModel::Direction::Rows); +} + +/** + @brief CableFilterProxy::setHideUnsuitable + @param hide true to keep the rows which are of no use out of sight, + false to show them -- struck through while a line was just drawn, + plain while a type is being changed, where the loss was explained + instead of being refused +*/ +void CableFilterProxy::setHideUnsuitable(bool hide) +{ + if (m_hide_unsuitable == hide) return; + m_hide_unsuitable = hide; + beginFilterChange(); + endFilterChange(QSortFilterProxyModel::Direction::Rows); +} + +/** + @brief CableFilterProxy::filterAcceptsRow + @param source_row + @param source_parent + @return +*/ +bool CableFilterProxy::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const +{ + const QModelIndex first = sourceModel()->index(source_row, 0, source_parent); + + if (m_hide_unsuitable && first.data(UnsuitableRole).toBool()) { + return false; + } + if (m_tokens.isEmpty()) { + return true; + } + + const int columns = sourceModel()->columnCount(); + for (const QString &token : m_tokens) + { + bool found = false; + for (int column = 0; column < columns; ++column) + { + const QString value = sourceModel()->index(source_row, column, source_parent) + .data(Qt::DisplayRole).toString(); + if (value.contains(token, Qt::CaseInsensitive)) + { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; +} + +/** + @brief CableCreateDialog::CableCreateDialog + @param diagram the folio the line was drawn on + @param crossings the conductors that line runs across, in drawing order + @param part the section which will carry the trunk line and its slashes + @param parent +*/ +CableCreateDialog::CableCreateDialog(Diagram *diagram, + const QList &crossings, + const CablePartData &part, + QWidget *parent) : + QDialog(parent), + m_diagram(diagram), + m_crossings(crossings), + m_part(part) +{ + build(); +} + +/** + @brief CableCreateDialog::CableCreateDialog + @param diagram the folio whose undo stack takes the change + @param cable the cable whose type is being changed + @param parent +*/ +CableCreateDialog::CableCreateDialog(Diagram *diagram, + Cable *cable, + QWidget *parent) : + QDialog(parent), + m_diagram(diagram), + m_cable(cable), + m_change(cable) +{ + build(); +} + +/** + @brief CableCreateDialog::build + Everything the two modes share: the header saying what this window + is about, the tabs, the property fields and the buttons. What the + header says and which tabs are built is where they part company -- + a window which settles a line just drawn knows how many conductors + that line crossed, one which changes a cable knows how many cores + that cable has. +*/ +void CableCreateDialog::build() +{ + setWindowTitle(tr("Cable")); + + auto *layout = new QVBoxLayout(this); + + if (changeMode()) + { + //What this window is for, and how many cores the cable has + //right now: the type he picks is measured against that number. + const QString name = m_change->type().isEmpty() + ? tr("(no type)") : m_change->type(); + m_header = new QLabel(tr("Change the type: %1, %n core(s)", + "which type this window changes, and how many cores that cable has now", + m_change->coreCount()).arg(name), this); + } + else + { + m_header = new QLabel(tr("%n recognized core(s)", + "how many cores the line just crossed", + m_crossings.size()), this); + } + QFont header_font = m_header->font(); + header_font.setBold(true); + m_header->setFont(header_font); + layout->addWidget(m_header); + + m_tabs = new QTabWidget(this); + layout->addWidget(m_tabs); + + buildNewTab(); + //A window changing an existing cable has no line just drawn, so + //there is no other cable to hand that line over to: the second + //tab would only offer cables this one is to be replaced by, + //which is not what changing a type means. + if (!changeMode()) { + buildExistingTab(); + } + buildPropertyRow(); + layout->addWidget(m_property_row); + + m_buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(m_buttons, &QDialogButtonBox::accepted, this, &CableCreateDialog::accept); + connect(m_buttons, &QDialogButtonBox::rejected, this, &CableCreateDialog::reject); + layout->addWidget(m_buttons); + + connect(m_tabs, &QTabWidget::currentChanged, this, [this]() {selectionChanged();}); + + loadTypeFile(); + refreshExisting(); + selectionChanged(); + + resize(780, 540); +} + +/** + @brief CableCreateDialog::cell + One read only cell of one of the two tables. A row which cannot be + used is drawn struck through and greyed out, so the checkbox which + keeps such rows visible never leaves the user to guess why they are + there. + @param value + @param unsuitable + @return +*/ +QStandardItem *CableCreateDialog::cell(const QString &value, bool unsuitable) const +{ + auto *item = new QStandardItem(value); + item->setEditable(false); + if (unsuitable) + { + QFont font = item->font(); + font.setStrikeOut(true); + item->setFont(font); + item->setForeground(QBrush(palette().color(QPalette::Disabled, QPalette::Text))); + } + return item; +} + +/** + @brief CableCreateDialog::buildNewTab + The tab choosing the type of a brand new cable, read from the cable + type file. +*/ +void CableCreateDialog::buildNewTab() +{ + auto *page = new QWidget(m_tabs); + auto *layout = new QVBoxLayout(page); + layout->setContentsMargins(4, 4, 4, 4); + + auto *search_row = new QHBoxLayout; + search_row->addWidget(new QLabel(tr("Search:"), page)); + m_new_search = new QLineEdit(page); + m_new_search->setClearButtonEnabled(true); + m_new_search->setPlaceholderText(tr("Filter cable types")); + search_row->addWidget(m_new_search, 1); + layout->addLayout(search_row); + + //What the checkbox hides is what cannot be taken: too few cores + //to carry the line which was just drawn, and -- when a type is + //being changed rather than drawn -- too few cores to keep this + //cable as it stands, since taking such a type takes cores off it. + m_new_hide = new QCheckBox(changeMode() + ? tr("Hide types which have fewer cores " + "than this cable") + : tr("Hide types which have fewer cores " + "than recognized conductors"), page); + m_new_hide->setChecked(true); + layout->addWidget(m_new_hide); + + //Said up front rather than only when a struck through row is + //picked: the user sees the lines crossed out the moment the + //checkbox shows them, and asks why straight away. + auto *why = new QLabel(changeMode() + ? tr("Uncheck to also see the types which have " + "fewer cores: taking them makes the cores " + "beyond their number leave the cable.") + : tr("A struck through line is a type which has " + "fewer cores than recognized conductors: it " + "cannot carry this cable."), page); + why->setWordWrap(true); + layout->addWidget(why); + + m_new_model = new QStandardItemModel(this); + m_new_proxy = new CableFilterProxy(this); + m_new_proxy->setSourceModel(m_new_model); + + m_new_table = new QTableView(page); + m_new_table->setModel(m_new_proxy); + m_new_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_new_table->setSelectionMode(QAbstractItemView::SingleSelection); + m_new_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_new_table->setAlternatingRowColors(true); + m_new_table->setSortingEnabled(true); + m_new_table->horizontalHeader()->setStretchLastSection(true); + m_new_table->verticalHeader()->setVisible(false); + layout->addWidget(m_new_table, 1); + + m_new_note = new QLabel(page); + m_new_note->setWordWrap(true); + m_new_note->setVisible(false); + layout->addWidget(m_new_note); + + //A new line for the cable type file, filled in the way the + //material window fills in one of its own articles: he adds the + //type right here rather than leaving the window to find out how. + //Beside it, the button rewriting the line he picked -- which + //stays out of reach until a line really is picked, since there + //would then be nothing to rewrite. + auto *add_row = new QHBoxLayout; + add_row->addStretch(1); + auto *add_button = new QPushButton(tr("New entry"), page); + add_button->setToolTip(tr("Add a new cable type to the list")); + connect(add_button, &QPushButton::clicked, + this, &CableCreateDialog::addTypeRecord); + add_row->addWidget(add_button); + + m_edit_button = new QPushButton(tr("Edit entry"), page); + m_edit_button->setToolTip(tr("Edit the cable type selected in " + "the list.")); + m_edit_button->setEnabled(false); + connect(m_edit_button, &QPushButton::clicked, + this, &CableCreateDialog::editTypeRecord); + add_row->addWidget(m_edit_button); + + layout->addLayout(add_row); + + m_tabs->addTab(page, tr("New cable")); + + connect(m_new_search, &QLineEdit::textChanged, this, [this](const QString &text) { + m_new_proxy->setTokens(text.split(QLatin1Char(' '), Qt::SkipEmptyParts)); + }); + connect(m_new_hide, &QCheckBox::toggled, m_new_proxy, &CableFilterProxy::setHideUnsuitable); + connect(m_new_table->selectionModel(), &QItemSelectionModel::currentChanged, + this, [this]() {selectionChanged();}); + //A double click settles the choice, exactly the way clicking + //OK would: he has come to pick that cable, and making him press + //OK after every pick is one click too many. A row which cannot + //carry this cable goes on being refused the same way it is + //refused by that button, greyed out as it is. + connect(m_new_table, &QTableView::doubleClicked, this, [this]() { + if (m_buttons->button(QDialogButtonBox::Ok)->isEnabled()) { + accept(); + } + }); +} + +/** + @brief CableCreateDialog::buildExistingTab + The tab giving more cores to a cable the project already holds. That + cable keeps its type, its designation, its installation and its + location: choosing it is what inherits them. +*/ +void CableCreateDialog::buildExistingTab() +{ + auto *page = new QWidget(m_tabs); + auto *layout = new QVBoxLayout(page); + layout->setContentsMargins(4, 4, 4, 4); + + auto *search_row = new QHBoxLayout; + search_row->addWidget(new QLabel(tr("Search:"), page)); + m_existing_search = new QLineEdit(page); + m_existing_search->setClearButtonEnabled(true); + m_existing_search->setPlaceholderText(tr("Filter existing cables")); + search_row->addWidget(m_existing_search, 1); + layout->addLayout(search_row); + + m_existing_hide = new QCheckBox(tr("Hide cables which have fewer " + "free cores than recognized conductors"), page); + m_existing_hide->setChecked(true); + layout->addWidget(m_existing_hide); + + //Same as on the first tab: the reason a line is crossed out is + //said where the crossed out lines appear, not only afterwards. + auto *why = new QLabel(tr("A struck through line is a cable which has " + "fewer free cores than recognized " + "conductors: it cannot receive them."), page); + why->setWordWrap(true); + layout->addWidget(why); + + m_existing_model = new QStandardItemModel(this); + m_existing_proxy = new CableFilterProxy(this); + m_existing_proxy->setSourceModel(m_existing_model); + + m_existing_table = new QTableView(page); + m_existing_table->setModel(m_existing_proxy); + m_existing_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_existing_table->setSelectionMode(QAbstractItemView::SingleSelection); + m_existing_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_existing_table->setAlternatingRowColors(true); + m_existing_table->setSortingEnabled(true); + m_existing_table->horizontalHeader()->setStretchLastSection(true); + m_existing_table->verticalHeader()->setVisible(false); + layout->addWidget(m_existing_table, 1); + + m_existing_note = new QLabel(page); + m_existing_note->setWordWrap(true); + m_existing_note->setVisible(false); + layout->addWidget(m_existing_note); + + m_tabs->addTab(page, tr("Existing cables")); + + connect(m_existing_search, &QLineEdit::textChanged, this, [this](const QString &text) { + m_existing_proxy->setTokens(text.split(QLatin1Char(' '), Qt::SkipEmptyParts)); + }); + connect(m_existing_hide, &QCheckBox::toggled, m_existing_proxy, &CableFilterProxy::setHideUnsuitable); + connect(m_existing_table->selectionModel(), &QItemSelectionModel::currentChanged, + this, [this]() {selectionChanged();}); + //A double click settles the choice here too: the cable goes + //straight onto the line he has just drawn, without a stop at + //the OK button on the way -- and a cable with no room left for + //these cores goes on being refused, since that button is greyed + //out for it. + connect(m_existing_table, &QTableView::doubleClicked, this, [this]() { + if (m_buttons->button(QDialogButtonBox::Ok)->isEnabled()) { + accept(); + } + }); +} + +/** + @brief CableCreateDialog::buildPropertyRow + The properties of the cable itself, which belong to it rather than to + one of its parts: whatever is typed here applies to the whole cable. + + There is one single field for the number of the cable, not one per + name it goes by: the Désignation is the tag of the cable -- the thing + one is tempted to call its BMK -- and it is what gets drawn next to + the line and written into the conductors. +*/ +void CableCreateDialog::buildPropertyRow() +{ + m_property_row = new QWidget(this); + auto *form = new QFormLayout(m_property_row); + form->setContentsMargins(0, 0, 0, 0); + + m_designation = new QLineEdit(m_property_row); + m_installation = new QLineEdit(m_property_row); + m_location = new QLineEdit(m_property_row); + + form->addRow(tr("Designation"), m_designation); + form->addRow(tr("Plant (=)"), m_installation); + form->addRow(tr("Location (+)"), m_location); +} + +/** + @brief CableCreateDialog::loadTypeFile + Read the cable type file the same way the material file is read, and + fill the first tab with it. A file which is not there yet simply + leaves the tab empty and says so. +*/ +void CableCreateDialog::loadTypeFile() +{ + m_new_model->clear(); + + QString path = CableTypeList::configuredPath(); + if (path.isEmpty()) { + path = CableTypeList::defaultPath(); + } + m_types = CableTypeListData(); + CableTypeList::load(path, &m_types); + + m_new_model->setHorizontalHeaderLabels(CableTypeList::translatedHeader(m_types.columns)); + + const int needed = neededCores(); + int current_row = -1; + for (const CableTypeRecord &record : std::as_const(m_types.records)) + { + const int cores = CableTypeList::coreCount(record); + //A type saying how many cores it has, but fewer than the line + //crosses, cannot carry the cable: too few cores were chosen. + //When a type is being changed, such a row is not refused -- + //he may well want a cable with fewer cores -- but it is the + //one the checkbox hides, and the one which has something to + //say about what it takes off the cable. + const bool fewer = cores > 0 && cores < needed; + const bool unsuitable = fewer && !changeMode(); + if (changeMode() && !m_change->type().isEmpty() + && CableTypeList::designation(record) == m_change->type()) { + current_row = m_types.records.indexOf(record); + } + + QList items; + items.reserve(m_types.columns.size()); + for (const QString &column : std::as_const(m_types.columns)) + { + items.append(cell(record.value(column), unsuitable)); + } + if (!items.isEmpty()) + { + items.first()->setData(fewer, CableFilterProxy::UnsuitableRole); + m_new_model->appendRow(items); + } + } + + //The type the cable already has is the one he came here to + //change, so it is the one standing under his finger from the + //start -- and OK is enabled with something to say yes to. + if (current_row >= 0 && m_new_proxy) + { + const QModelIndex source = m_new_model->index(current_row, 0); + m_new_table->setCurrentIndex(m_new_proxy->mapFromSource(source)); + m_new_table->scrollTo(m_new_table->currentIndex()); + } +} + +/** + @brief CableCreateDialog::refreshExisting + Fill the second tab with the cables the project already holds, + saying how many of their cores are wired and how many are left. +*/ +void CableCreateDialog::refreshExisting() +{ + //No second tab when a type is being changed, so nothing to fill + //-- and nothing was built which could be filled either. + if (changeMode()) return; + + m_existing_model->clear(); + m_existing.clear(); + + if (!m_diagram || !m_diagram->project()) return; + + m_existing_model->setHorizontalHeaderLabels({ + tr("Designation"), + tr("Type"), + tr("Cores"), + tr("Plant"), + tr("Location") + }); + + const int needed = m_crossings.size(); + for (Cable *cable : m_diagram->project()->cables()) + { + if (!cable) continue; + + const bool unsuitable = cable->freeCoreCount() < needed; + QList items; + items << cell(cable->designation(), unsuitable) + << cell(cable->type(), unsuitable) + << cell(tr("%1 / %2") + .arg(cable->usedCoreCount()) + .arg(cable->coreCount()), unsuitable) + << cell(cable->installation(), unsuitable) + << cell(cable->location(), unsuitable); + items.first()->setData(unsuitable, CableFilterProxy::UnsuitableRole); + + m_existing_model->appendRow(items); + m_existing.append(cable); + } +} + +/** + @brief CableCreateDialog::currentSourceIndex + @return the row currently chosen on whichever tab is in front, in the + coordinates of the model behind the filter +*/ +QModelIndex CableCreateDialog::currentSourceIndex() const +{ + const bool on_new_tab = m_tabs->currentIndex() == 0; + QTableView *table = on_new_tab ? m_new_table : m_existing_table; + CableFilterProxy *proxy = on_new_tab ? m_new_proxy : m_existing_proxy; + + const QModelIndex proxy_index = table->currentIndex(); + if (!proxy_index.isValid()) return QModelIndex(); + return proxy->mapToSource(proxy_index); +} + +/** + @brief CableCreateDialog::currentSelectionIsUnsuitable + @return true when the chosen row is one the filter only shows when + the checkbox is off, that is one which cannot carry this cable +*/ +bool CableCreateDialog::currentSelectionIsUnsuitable() const +{ + const QModelIndex index = currentSourceIndex(); + if (!index.isValid()) return false; + + const bool on_new_tab = m_tabs->currentIndex() == 0; + CableFilterProxy *proxy = on_new_tab ? m_new_proxy : m_existing_proxy; + return proxy->index(index.row(), 0).data(CableFilterProxy::UnsuitableRole).toBool(); +} + +/** + @brief CableCreateDialog::unsuitableNote + @return what has to be said about the struck through row which was + chosen, so the user learns why it cannot be taken +*/ +QString CableCreateDialog::unsuitableNote() const +{ + const int needed = neededCores(); + const QModelIndex index = currentSourceIndex(); + if (!index.isValid()) return QString(); + + if (m_tabs->currentIndex() == 0) + { + const int cores = CableTypeList::coreCount(m_types.records.at(index.row())); + //Changing a type may well take a cable with fewer cores: + //nothing is refused, but what that costs has to be said out + //loud before he says yes to it. + if (changeMode()) + { + return tr("This type has fewer cores than this cable: %1 instead of %2. " + "The cores beyond %1 leave the cable and their marks " + "disappear from the drawing.") + .arg(cores) + .arg(needed); + } + + return tr("Too many cores selected: the type offers only %1, " + "%2 conductors are recognized") + .arg(cores) + .arg(needed); + } + + const Cable *cable = m_existing.value(index.row()); + return tr("Only %1 free cores, %2 requested") + .arg(cable ? cable->freeCoreCount() : 0) + .arg(needed); +} + +/** + @brief CableCreateDialog::selectionChanged + Show the reason a struck through row cannot be taken, keep the OK + button in step with it, and fill the property fields from whatever is + chosen -- an existing cable hands its properties over, a new one gets + its number worked out. +*/ +void CableCreateDialog::selectionChanged() +{ + for (QLabel *note : {m_new_note, m_existing_note}) + { + if (!note) continue; + note->clear(); + note->hide(); + } + + const bool on_new_tab = m_tabs->currentIndex() == 0; + QLabel *note = on_new_tab ? m_new_note : m_existing_note; + const QModelIndex index = currentSourceIndex(); + + bool acceptable = index.isValid(); + + if (on_new_tab && m_types.columns.isEmpty()) + { + note->setText(tr("No cable type available: configure the cable " + "type file in the preferences.")); + note->show(); + acceptable = false; + } + else if (acceptable && currentSelectionIsUnsuitable()) + { + note->setText(unsuitableNote()); + note->show(); + //Changing a type may well take one with fewer cores: what + //that costs is said out loud here, and he may still take it. + acceptable = !changeMode(); + } + else if (!acceptable) + { + note->setText(on_new_tab ? tr("Choose a cable type.") + : tr("Choose an existing cable.")); + note->show(); + } + + //A type with more cores than this cable holds now loses nothing: + //the extra ones wait in the list of cores which are not placed + //yet, and he brings them in one by one. + if (acceptable && on_new_tab && changeMode() && index.isValid() + && index.row() < m_types.records.size()) + { + const int cores = CableTypeList::coreCount(m_types.records.at(index.row())); + if (cores > m_change->coreCount()) + { + note->setText(tr("%1 cores instead of %2: the extra cores " + "stay unplaced.") + .arg(cores) + .arg(m_change->coreCount())); + note->show(); + } + } + + m_buttons->button(QDialogButtonBox::Ok)->setEnabled(acceptable); + + //The button rewriting a line of the file works on the line he + //picked and on nothing else: greyed out while his finger has + //chosen no line yet, and greyed out on the tab holding the + //cables of the project, which are not lines of that file. + if (m_edit_button) + { + m_edit_button->setEnabled(on_new_tab && index.isValid() + && index.row() < m_types.records.size()); + } + + if (!acceptable) return; + + if (on_new_tab) + { + if (changeMode()) + { + //The cable already has its number, its installation and + //its place: those are what it holds, not something this + //window works out again. + fillDesignation(m_change->designation(), + m_change->designationByHand()); + m_installation->setText(m_change->installation()); + m_location->setText(m_change->location()); + return; + } + + fillDesignation(m_diagram + ? CableManager::nextDesignation(m_diagram->project(), m_diagram.data()) + : QString(), + false); + m_installation->clear(); + m_location->clear(); + return; + } + + if (const Cable *cable = m_existing.value(index.row())) + { + fillDesignation(cable->designation(), cable->designationByHand()); + m_installation->setText(cable->installation()); + m_location->setText(cable->location()); + } +} + +/** + @brief CableCreateDialog::fillDesignation + Put a name into the field and keep hold of what this window put + there: whatever he types over it is a name he chose rather than one + the program handed out. + @param text the name to show + @param by_hand whether that name was typed in by him +*/ +void CableCreateDialog::fillDesignation(const QString &text, bool by_hand) +{ + m_designation->setText(text); + m_designation_prefill = text; + m_designation_prefill_by_hand = by_hand; +} + +/** + @brief CableCreateDialog::nextFreeSlots + @param count how many cores are needed + @return the lowest core numbers no conductor shows yet +*/ +QList CableCreateDialog::nextFreeSlots(int count) const +{ + QList core_slots; + if (!m_cable) return core_slots; + + const int total = m_cable->coreCount(); + for (int core = 0; core < total && core_slots.size() < count; ++core) + { + if (!m_cable->hasCore(core)) { + core_slots.append(core); + } + } + return core_slots; +} + +/** + @brief CableCreateDialog::assignCrossings + Give the recognised conductors to the cable, one after the other + along the line, so each core takes the colour the type lists at its + place. A conductor another cable was holding is taken away from it: + a piece of wire belongs to one cable only. + @param slots the core numbers being handed out, in drawing order +*/ +void CableCreateDialog::assignCrossings(const QList &core_slots) +{ + if (!m_cable || !m_diagram || !m_diagram->project()) return; + QETProject *project = m_diagram->project(); + + int position = 0; + for (const CableCrossing &crossing : std::as_const(m_crossings)) + { + if (position >= core_slots.size()) break; + + Conductor *conductor = crossing.conductor; + if (!conductor) continue; + + //Already one of this cable's cores: it keeps the core it has, + //drawing the same cable twice must not give it two. + if (conductor->properties().m_cable_uuid == m_cable->uuid()) + { + ++position; + continue; + } + + CableManager::claimConductor(project, conductor, m_cable); + + //A cable binds a conductor by its identity, so a conductor + //read back from a file written before it had a persistent one + //is given one to keep from now on. + conductor->setUuid(conductor->uuid()); + + CableCore core; + core.core = core_slots.at(position); + core.part = m_part.uuid; + core.conductor = conductor->uuid(); + core.position = crossing.position; + m_cable->setCore(core); + ++position; + } +} + +/** + @brief CableCreateDialog::coresTakenFromOthers + Which cores of other cables the wires this line crosses already + belong to. + + A conductor is one piece of wire, so handing it to the cable being + drawn takes that core away from whichever cable held it before. The + list is worked out before anything is changed, both to be able to + ask the user first and to be able to give those cores back when he + undoes the line. + @param target the cable about to claim those conductors; a null one + is a cable which does not exist yet, and then every core another + cable holds is one which would be taken away + @return one entry per core which is about to change hands +*/ +QList CableCreateDialog::coresTakenFromOthers(const QUuid &target) const +{ + QList taken; + if (!m_diagram || !m_diagram->project()) return taken; + + QETProject *project = m_diagram->project(); + for (const CableCrossing &crossing : std::as_const(m_crossings)) + { + Conductor *conductor = crossing.conductor; + if (!conductor) continue; + + const auto properties = conductor->properties(); + if (properties.m_cable_uuid.isNull() + || properties.m_cable_uuid == target) { + continue; + } + + Cable *previous = CableManager::cableByUuid(project, properties.m_cable_uuid); + if (!previous || !previous->hasCore(properties.m_cable_slot)) continue; + + CableCoreTaken item; + item.cable = previous; + item.core = previous->core(properties.m_cable_slot); + taken.append(item); + } + return taken; +} + +/** + @brief CableCreateDialog::accept + Settle the cable the drawing gets: a new one from the chosen type, or + the chosen existing one, filled with the recognised conductors. + + Nothing is written before the user has been asked about the cores + other cables are about to lose: saying yes to that question has to be + the last thing he does before the change really happens, so that + saying no leaves the project exactly as it was. +*/ +void CableCreateDialog::accept() +{ + //Changing the type of a cable which already exists has nothing + //to do with conductors a line crosses: it takes the chosen type + //onto that cable, and that alone. + if (changeMode()) + { + acceptTypeChange(); + return; + } + + if (!m_diagram || !m_diagram->project()) { + QDialog::reject(); + return; + } + QETProject *project = m_diagram->project(); + const int needed = m_crossings.size(); + QList core_slots; + + const QModelIndex index = currentSourceIndex(); + if (!index.isValid() || currentSelectionIsUnsuitable()) return; + + Cable *chosen = nullptr; + if (m_tabs->currentIndex() == 0) + { + if (index.row() >= m_types.records.size()) return; + const CableTypeRecord record = m_types.records.at(index.row()); + const int cores = CableTypeList::coreCount(record); + if (cores > 0 && cores < needed) return; + } + else + { + chosen = m_existing.value(index.row()); + if (!chosen) return; + if (chosen->freeCoreCount() < needed) return; + } + + //The wires this line crosses which another cable already holds + //are about to change hands, and the drawing already shows that + //as the label of the first cable vanishing: better to say so + //here than to let the user discover it afterwards. + m_taken = coresTakenFromOthers(chosen ? chosen->uuid() : QUuid()); + if (!m_taken.isEmpty()) + { + const QString text = tr( + "%n crossed conductor(s) already bound to another cable\n\n" + "If you confirm, the other cable will lose these cores. Do you want to continue?", + "ask before taking cores away from another cable", + m_taken.size()); + + if (QMessageBox::warning(this, tr("Cable"), text, + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) != QMessageBox::Yes) { + m_taken.clear(); + return; + } + } + + if (m_tabs->currentIndex() == 0) + { + const CableTypeRecord record = m_types.records.at(index.row()); + const int cores = CableTypeList::coreCount(record); + + m_cable = project->newCable(); + m_cable->setType(CableTypeList::designation(record)); + //A type saying how many cores it has keeps its own count, so + //"5 of 8" stays true; a type saying nothing gets the number of + //conductors which were recognised, since that is all we know. + m_cable->setCoreCount(qMax(cores, needed)); + m_cable->setCoreColors(CableTypeList::coreColors(record)); + m_cable->setDesignation(CableManager::nextDesignation(project, m_diagram.data())); + m_cable->addPart(m_part); + core_slots = nextFreeSlots(needed); + } + else + { + m_cable = chosen; + if (!m_cable->hasPart(m_part.uuid)) { + m_cable->addPart(m_part); + } + core_slots = nextFreeSlots(needed); + } + + m_cable->setDesignation(m_designation->text().trimmed()); + //A name this window filled in is the program's, a name he typed + //over it is his own -- and it stays his own when he puts the + //very same name back into the field + m_cable->setDesignationByHand( + m_designation->text().trimmed() != m_designation_prefill + || m_designation_prefill_by_hand); + m_cable->setInstallation(m_installation->text().trimmed()); + m_cable->setLocation(m_location->text().trimmed()); + + assignCrossings(core_slots); + + QDialog::accept(); +} + +/** + @brief CableCreateDialog::neededCores + @return how many cores whatever he picks has to hold at least: the + conductors the line just crossed, or -- when a type is being changed + -- the cores this cable holds right now +*/ +int CableCreateDialog::neededCores() const +{ + return changeMode() ? m_change->coreCount() : m_crossings.size(); +} + +/** + @brief CableCreateDialog::typeFilePath + @return where the cable type file stands, read the same way the type + tab reads it +*/ +QString CableCreateDialog::typeFilePath() const +{ + QString path = CableTypeList::configuredPath(); + if (path.isEmpty()) { + path = CableTypeList::defaultPath(); + } + return path; +} + +/** + @brief CableCreateDialog::addTypeRecord + Fill in a new line of the cable type file, the way the material + window adds one of its own articles: the form is appended to the + file, the search is cleared so nothing hides what was just written, + and the file is read again so the new line comes back from where it + really stands -- and it is the one under his finger. +*/ +void CableCreateDialog::addTypeRecord() +{ + QStringList columns = m_types.columns; + if (columns.isEmpty()) { + columns = CableTypeList::defaultColumns(); + } + + CableTypeEntryDialog entry(columns, this); + if (entry.exec() != QDialog::Accepted) { + return; + } + + const QString path = typeFilePath(); + QString error; + if (!CableTypeList::appendRecord(path, entry.record(), &error)) + { + QMessageBox::critical(this, + tr("Cannot be written"), + tr("Cannot write to the file:\n%1\n%2") + .arg(path, error)); + return; + } + + //A search hiding the line he just filled in would leave him + //with a form he completed and no line to see. + m_new_search->clear(); + loadTypeFile(); + + const CableTypeRecord written = entry.record(); + for (int row = 0; row < m_types.records.size(); ++row) + { + if (!(m_types.records.at(row) == written)) continue; + + const QModelIndex source = m_new_model->index(row, 0); + m_new_table->setCurrentIndex(m_new_proxy->mapFromSource(source)); + m_new_table->scrollTo(m_new_table->currentIndex()); + break; + } + + selectionChanged(); +} + +/** + @brief CableCreateDialog::editTypeRecord + Rewrite the line of the cable type file he picked: the very window + which adds a line opens, already filled in with what that line says, + and only his yes writes it back -- saying no leaves the file exactly + as it was. + + What he writes replaces the line he started from and nothing else. + The other lines of the file are read again first, so whatever he + saved from his spreadsheet in the meantime is written back too + rather than being thrown away. +*/ +void CableCreateDialog::editTypeRecord() +{ + const QModelIndex index = currentSourceIndex(); + if (!index.isValid() || index.row() >= m_types.records.size()) return; + + const CableTypeRecord before = m_types.records.at(index.row()); + + QStringList columns = m_types.columns; + if (columns.isEmpty()) { + columns = CableTypeList::defaultColumns(); + } + + CableTypeEntryDialog entry(columns, this); + entry.setValues(before); + if (entry.exec() != QDialog::Accepted) { + return; + } + + CableTypeRecord after = entry.record(); + //Cells standing beyond the header belong to the user, and the + //form knows nothing about them: they must not vanish from the + //line just because they could not be shown. + after.extra = before.extra; + + const QString path = typeFilePath(); + QString error; + if (!CableTypeList::updateRecord(path, before, after, &error)) + { + QMessageBox::critical(this, + tr("Cannot be written"), + tr("Cannot write to the file:\n%1\n%2") + .arg(path, error)); + return; + } + + //A search hiding the line he just wrote would leave him with a + //form he filled in and no line to see. + m_new_search->clear(); + loadTypeFile(); + + for (int row = 0; row < m_types.records.size(); ++row) + { + if (!(m_types.records.at(row) == after)) continue; + + const QModelIndex source = m_new_model->index(row, 0); + m_new_table->setCurrentIndex(m_new_proxy->mapFromSource(source)); + m_new_table->scrollTo(m_new_table->currentIndex()); + break; + } + + selectionChanged(); +} + +/** + @brief CableCreateDialog::acceptTypeChange + Take the chosen type onto the cable whose type is being changed: + its name, how many cores it has and the colour of each of them, + together with the cores that cable holds -- the ones beyond the new + count leave it, the ones which stay keep their place and their wire, + and the added ones wait in the list of cores not placed yet. + + Everything lands as one single undo step, together with whatever he + typed into the property fields: the type, the cores and the fields + all come back together or not at all. +*/ +void CableCreateDialog::acceptTypeChange() +{ + if (!m_change) { + QDialog::reject(); + return; + } + + const QModelIndex index = currentSourceIndex(); + if (!index.isValid() || index.row() >= m_types.records.size()) return; + + const CableTypeRecord record = m_types.records.at(index.row()); + + const QString type = CableTypeList::designation(record); + int count = CableTypeList::coreCount(record); + //A type saying how many cores it has takes that count; one + //saying nothing leaves this cable the size it already is. + if (count <= 0) count = m_change->coreCount(); + const QStringList colors = CableTypeList::coreColors(record); + + const CableTypeState before = m_change->typeState(); + CableTypeState after = before; + after.type = type; + after.core_count = count; + after.core_colors = colors; + after.cores.clear(); + for (const CableCore &core : std::as_const(before.cores)) + { + if (core.core < count) { + after.cores.append(core); + } + } + + //Cores beyond the new count take their mark off the drawing. + //He is only asked about that when a wire really loses its + //label: a core which stood nowhere was not on the drawing at + //all, and nothing he can see goes away with it. + int wired = 0; + for (const CableCore &core : std::as_const(before.cores)) + { + if (core.core >= count && !core.conductor.isNull()) { + ++wired; + } + } + if (wired > 0) + { + const QString text = tr( + "%n core(s) of this cable bound to a conductor. " + "With the chosen type, they leave this cable and their mark " + "disappears from the drawing. Continue?", + "ask before cores lose the mark they show on the drawing", + wired); + + if (QMessageBox::warning(this, tr("Cable"), text, + QMessageBox::Yes | QMessageBox::No, + QMessageBox::Yes) != QMessageBox::Yes) { + return; + } + } + + //The type itself is written by the type step rather than by the + //fields, so the two cannot write it twice and undo cannot leave + //half of it standing. + const CableProperties properties_before = m_change->properties(); + CableProperties properties_after = properties_before; + properties_after.designation = m_designation->text().trimmed(); + //A name this window filled in is the one the cable already had; + //anything he typed over it is a name of his own + properties_after.designation_by_hand = + properties_after.designation != m_designation_prefill + || m_designation_prefill_by_hand; + properties_after.installation = m_installation->text().trimmed(); + properties_after.location = m_location->text().trimmed(); + + QList steps; + if (properties_after != properties_before) { + steps.append(new ChangeCablePropertiesCommand(m_change, + properties_before, + properties_after)); + } + if (before != after) { + steps.append(new ChangeCableTypeCommand(m_change, before, after)); + } + + //Nothing changed at all: saying yes to an unchanged form is + //still saying yes, but it is not a step which has to be kept. + if (steps.isEmpty()) + { + QDialog::accept(); + return; + } + + QUndoCommand *step = steps.size() == 1 + ? steps.takeFirst() + : new BatchCommand(steps, QCoreApplication::translate("ChangeCableTypeCommand", + "Change the cable type")); + + //The folio is the usual place this step is taken back from. When + //no folio is known -- a cable picked without a line -- the + //project keeps them all anyway, and only when there is neither + //does the change stand without a way back. + QUndoStack *stack = m_diagram ? &m_diagram->undoStack() : nullptr; + if (!stack) + { + auto *project = qobject_cast(m_change->parent()); + if (project && project->undoStack()) { + stack = project->undoStack(); + } + } + + if (stack) { + stack->push(step); + } else { + if (properties_after != properties_before) { + m_change->setProperties(properties_after); + } + m_change->applyTypeState(after); + delete step; + } + + QDialog::accept(); +} + +/** + @brief CableTypeEntryDialog::CableTypeEntryDialog + Build one field per column of the cable type file. Two columns get + the shape they need -- a number which counts, and colours written + the way the file separates them -- and every other column is a plain + field, since it may be one the user invented rather than one + QElectroTech knows. + @param columns the columns of the file, in file order + @param parent +*/ +CableTypeEntryDialog::CableTypeEntryDialog(const QStringList &columns, QWidget *parent) : + QDialog(parent), + m_columns(columns) +{ + setWindowTitle(tr("New entry")); + + auto *main_layout = new QVBoxLayout(this); + + auto *intro = new QLabel(tr("Enter the cable type to add. Fields " + "left empty stay empty in the file."), this); + intro->setWordWrap(true); + m_intro = intro; + main_layout->addWidget(intro); + + auto *grid = new QGridLayout; + for (int i = 0; i < m_columns.size(); ++i) + { + const QString column = m_columns.at(i); + + auto *label = new QLabel(CableTypeList::translatedColumn(column), this); + label->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + + QWidget *field = nullptr; + if (column == QLatin1String("cores")) + { + auto *spin = new QSpinBox(this); + spin->setRange(0, 99); + spin->setSpecialValueText(QStringLiteral("—")); + spin->setToolTip(tr("How many cores this type has. The dash means " + "the file does not specify.")); + field = spin; + } + else + { + auto *edit = new QLineEdit(this); + edit->setClearButtonEnabled(true); + if (column == QLatin1String("core_colors")) + { + edit->setPlaceholderText(QStringLiteral("br,sw,gr,bl,gnge")); + edit->setToolTip(tr("The color of each core, separated by a " + "comma and in the order of the cores.")); + } + field = edit; + } + + label->setBuddy(field); + grid->addWidget(label, i, 0, Qt::AlignRight | Qt::AlignVCenter); + grid->addWidget(field, i, 1); + m_fields.append(field); + } + grid->setColumnStretch(1, 1); + main_layout->addLayout(grid); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttons->button(QDialogButtonBox::Ok)->setText(tr("Save")); + buttons->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + connect(buttons, &QDialogButtonBox::accepted, this, &CableTypeEntryDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &CableTypeEntryDialog::reject); + main_layout->addWidget(buttons); + + if (!m_fields.isEmpty()) { + m_fields.first()->setFocus(); + } + + setMinimumWidth(420); +} + +/** + @brief CableTypeEntryDialog::record + @return the line the form describes. Empty fields are left out: an + empty column never means anything for a type being created. +*/ +CableTypeRecord CableTypeEntryDialog::record() const +{ + CableTypeRecord record; + + for (int i = 0; i < m_columns.size() && i < m_fields.size(); ++i) + { + const QString column = m_columns.at(i); + + QString value; + if (auto *spin = qobject_cast(m_fields.at(i))) + { + //Zero means "not said": a type may leave its size to the + //drawing, and an empty cell says exactly that. + if (spin->value() > 0) { + value = QString::number(spin->value()); + } + } + else if (auto *edit = qobject_cast(m_fields.at(i))) + { + value = edit->text(); + value.remove(QLatin1Char('\r')); + value.remove(QLatin1Char('\n')); + value = value.trimmed(); + } + + if (!value.isEmpty()) { + record.setValue(column, value); + } + } + + return record; +} + +/** + @brief CableTypeEntryDialog::setValues + Fill the form with a line which is already in the file. The window + then says it edits rather than adds, so the two can never be told + apart by mistake: what he writes is only written when he says yes to + it, and only about the line he started from. + @param values the line as it stands in the file +*/ +void CableTypeEntryDialog::setValues(const CableTypeRecord &values) +{ + setWindowTitle(tr("Edit entry")); + if (m_intro) + { + m_intro->setText(tr("Edit the cable type selected in the list. " + "Fields left empty stay empty in the file.")); + } + + for (int i = 0; i < m_columns.size() && i < m_fields.size(); ++i) + { + const QString value = values.value(m_columns.at(i)); + + if (auto *spin = qobject_cast(m_fields.at(i))) + { + bool ok = false; + const int count = value.toInt(&ok); + spin->setValue(ok ? qBound(spin->minimum(), count, spin->maximum()) + : spin->minimum()); + } + else if (auto *edit = qobject_cast(m_fields.at(i))) + { + edit->setText(value); + } + } +} + +/** + @brief CableTypeEntryDialog::accept + Refuse an empty form: a type with nothing said about it is not worth + a line in the file. +*/ +void CableTypeEntryDialog::accept() +{ + if (record().values.isEmpty()) + { + QMessageBox::warning(this, + tr("No details"), + tr("Enter at least one detail " + "to create an entry.")); + return; + } + + QDialog::accept(); +} diff --git a/sources/cable/cablecreatedialog.h b/sources/cable/cablecreatedialog.h new file mode 100644 index 000000000..a18dd3094 --- /dev/null +++ b/sources/cable/cablecreatedialog.h @@ -0,0 +1,236 @@ +/* + 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 CABLECREATEDIALOG_H +#define CABLECREATEDIALOG_H + +#include "../cablelist/cabletypelist.h" +#include "cable.h" +#include "cablemanager.h" + +#include +#include +#include +#include + +class Diagram; +class QCheckBox; +class QDialogButtonBox; +class QLabel; +class QLineEdit; +class QStandardItem; +class QStandardItemModel; +class QTableView; +class QTabWidget; +class QWidget; +class QPushButton; + +/** + @brief Filter keeping the rows the user is looking for. + + Every word he types has to be found somewhere in the row, whatever + the column it sits in, the same way the material file is searched. + On top of that it can hide the rows which are of no use to the cable + being drawn -- the checkbox each of the two tabs carries. +*/ +class CableFilterProxy : public QSortFilterProxyModel +{ + Q_OBJECT + + public: + static constexpr int UnsuitableRole = Qt::UserRole + 1; + + explicit CableFilterProxy(QObject *parent = nullptr); + + void setTokens(const QStringList &tokens); + void setHideUnsuitable(bool hide); + + protected: + bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + + private: + QStringList m_tokens; + bool m_hide_unsuitable = true; +}; + +/** + @brief The small window filling in one line of the cable type file. + + It is the cable counterpart of the window the material list opens to + add one of its own articles: one field per column of the file, in + file order, and a column the program doesn't know still gets its + field since it belongs to the user rather than to QElectroTech. The + two columns a cable type is read by get the shape they need -- a + number which counts, and colours written the way the file separates + them. +*/ +class CableTypeEntryDialog : public QDialog +{ + Q_OBJECT + + public: + /** + @param columns the columns of the cable type file, in file + order + @param parent + */ + explicit CableTypeEntryDialog(const QStringList &columns, QWidget *parent = nullptr); + + ///The line the form describes, empty fields left out + CableTypeRecord record() const; + /** + Fill the form with a line which is already in the file, + for the window where that line is edited rather than + added: the window then says so, and every field carries + what the file holds about that column. + @param values the line as it stands in the file + */ + void setValues(const CableTypeRecord &values); + + void accept() override; + + private: + QStringList m_columns; + ///One field per column, in file order, for the tab key + QList m_fields; + ///What the window says it is about, changed when editing + QLabel *m_intro = nullptr; +}; + +/** + @brief The dialog which opens as soon as a cable line has been drawn. + + It says how many cores were recognised, then lets the user either + pick a type from the cable type file -- a brand new cable -- or pick + one of the cables the project already holds and give it more cores. + Both tabs carry a search field and the checkbox hiding what cannot be + used, exactly like the material selection does. + + The very same window opens to change the type of a cable which + already exists: it then shows the type tab alone, filled in with what + that cable holds, and takes the chosen type onto the cable -- cores + reworked, marks redrawn -- as one single undo step. It carries the + button filling in a new line of the cable type file, the way the + material window carries its own. +*/ +class CableCreateDialog : public QDialog +{ + Q_OBJECT + + public: + CableCreateDialog(Diagram *diagram, + const QList &crossings, + const CablePartData &part, + QWidget *parent = nullptr); + + /** + The same window, opened to change the type of a cable which + already exists rather than to settle a line just drawn. + @param diagram the folio whose undo stack takes the change + @param cable the cable whose type is being changed + @param parent + */ + CableCreateDialog(Diagram *diagram, + Cable *cable, + QWidget *parent = nullptr); + + ///The cable the user settled on, null when he cancelled + Cable *cable() const {return m_cable;} + ///The cores this cable took away from cables the project + ///already held, so that undo gives them back + QList takenCores() const {return m_taken;} + + void accept() override; + + private: + void build(); + void buildNewTab(); + void buildExistingTab(); + void buildPropertyRow(); + void loadTypeFile(); + void refreshExisting(); + void selectionChanged(); + ///Fill the name field in and remember what this window put there + void fillDesignation(const QString &text, bool by_hand); + void assignCrossings(const QList &core_slots); + QList coresTakenFromOthers(const QUuid &target) const; + QList nextFreeSlots(int count) const; + QModelIndex currentSourceIndex() const; + bool currentSelectionIsUnsuitable() const; + QString unsuitableNote() const; + QStandardItem *cell(const QString &value, bool unsuitable) const; + ///True while this window changes the type of an existing cable + bool changeMode() const {return m_change != nullptr;} + ///How many cores whatever he picks has to hold at least + int neededCores() const; + ///Where the cable type file stands + QString typeFilePath() const; + ///The button filling in a new line of the cable type file + void addTypeRecord(); + ///The button rewriting the line of the cable type file he picked + void editTypeRecord(); + ///Take the chosen type onto the cable being changed + void acceptTypeChange(); + + //ATTRIBUTES + private: + QPointer m_diagram; + QList m_crossings; + CablePartData m_part; + Cable *m_cable = nullptr; + ///The cable whose type is changed, null while a line is drawn + QPointer m_change; + ///The cores other cables lost to this one, given back by undo + QList m_taken; + + QLabel *m_header = nullptr; + QTabWidget *m_tabs = nullptr; + QDialogButtonBox *m_buttons = nullptr; + + QLineEdit *m_new_search = nullptr; + QCheckBox *m_new_hide = nullptr; + QTableView *m_new_table = nullptr; + QStandardItemModel *m_new_model = nullptr; + CableFilterProxy *m_new_proxy = nullptr; + QLabel *m_new_note = nullptr; + ///The button rewriting the chosen line of the file, which + ///can only work on a line which was picked + QPushButton *m_edit_button = nullptr; + CableTypeListData m_types; + + QLineEdit *m_existing_search = nullptr; + QCheckBox *m_existing_hide = nullptr; + QTableView *m_existing_table = nullptr; + QStandardItemModel *m_existing_model = nullptr; + CableFilterProxy *m_existing_proxy = nullptr; + QLabel *m_existing_note = nullptr; + QList m_existing; + + QWidget *m_property_row = nullptr; + QLineEdit *m_designation = nullptr; + /** + The name exactly as this window filled it in, and where + that name came from: whatever he types over it from then + on is a name of his own (see accept()) + */ + QString m_designation_prefill; + bool m_designation_prefill_by_hand = false; + QLineEdit *m_installation = nullptr; + QLineEdit *m_location = nullptr; +}; + +#endif // CABLECREATEDIALOG_H diff --git a/sources/cable/cablemanager.cpp b/sources/cable/cablemanager.cpp new file mode 100644 index 000000000..e3728d5af --- /dev/null +++ b/sources/cable/cablemanager.cpp @@ -0,0 +1,635 @@ +/* + 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 "cablemanager.h" + +#include "cable.h" +#include "../autoNum/assignvariables.h" +#include "../autoNum/numerotationcontextcommands.h" +#include "../diagram.h" +#include "../qetgraphicsitem/conductor.h" +#include "../qetgraphicsitem/element.h" +#include "../qetgraphicsitem/terminal.h" +#include "../qetproject.h" + +#include +#include + +#include +#include + +namespace { + +/** + @brief Where two segments cross, if they do at all. + + Done by hand rather than with QLineF::intersects() because the + collinear case has to be a miss: a cable drawn exactly on top of a + conductor follows it, it does not cross it, and counting it would + claim a core the cable never reaches. + @param a1 start of the first segment + @param a2 end of the first segment + @param b1 start of the second segment + @param b2 end of the second segment + @param result receives the crossing point when there is one + @return true when the two segments cross within their own length +*/ +bool segmentIntersection(const QPointF &a1, const QPointF &a2, + const QPointF &b1, const QPointF &b2, + QPointF *result) +{ + const QPointF r = a2 - a1; + const QPointF s = b2 - b1; + const QPointF u = b1 - a1; + + const qreal denominator = r.x() * s.y() - r.y() * s.x(); + if (qFuzzyIsNull(denominator)) { + return false; + } + + const qreal t = (u.x() * s.y() - u.y() * s.x()) / denominator; + const qreal v = (u.x() * r.y() - u.y() * r.x()) / denominator; + if (t < 0.0 || t > 1.0 || v < 0.0 || v > 1.0) { + return false; + } + + if (result) { + *result = a1 + t * r; + } + return true; +} + +/** + @brief How far a point stands from a segment, in scene units +*/ +qreal distanceToSegment(const QPointF &point, const QPointF &a, const QPointF &b) +{ + const QPointF ab = b - a; + const qreal length_squared = ab.x() * ab.x() + ab.y() * ab.y(); + if (qFuzzyIsNull(length_squared)) { + return QLineF(point, a).length(); + } + + const QPointF ap = point - a; + qreal t = (ap.x() * ab.x() + ap.y() * ab.y()) / length_squared; + t = qBound(0.0, t, 1.0); + return QLineF(point, a + t * ab).length(); +} + +/** + @brief Whether some cable of the project already stands under this + number. + + A number which is taken must not be handed out twice. That happens + while a whole run of cables is numbered one after the other -- none + of them has been added to the project by anything but its own + creation yet -- and it happens when a rule has simply been given a + value which some cable carries already. + @param project + @param designation the number to look for + @return true when a cable of that project stands under it +*/ +bool designationIsTaken(QETProject *project, const QString &designation) +{ + if (!project || designation.isEmpty()) return false; + + for (Cable *cable : project->cables()) + { + if (cable && cable->designation() == designation) { + return true; + } + } + return false; +} + +} // namespace + +/** + @brief CableManager::crossedConductors + Tell which conductors a line about to become a cable runs across, in + the order they are met along the line. That order is what gives each + core its place, and therefore its colour, so it has to be the order + of the drawing and not the order the conductors happen to be stored + in. + @param diagram the folio the line is drawn on + @param line the line, in scene coordinates + @return the crossings, ordered from the start of the line +*/ +QList CableManager::crossedConductors(Diagram *diagram, const QLineF &line) +{ + QList crossings; + if (!diagram) { + return crossings; + } + + const QPointF start = line.p1(); + if (qFuzzyCompare(start, line.p2())) { + return crossings; + } + + const QPointF direction = line.p2() - start; + for (Conductor *conductor : diagram->conductors()) + { + QPointF where; + if (!crossingOf(diagram, line, conductor, &where)) continue; + + CableCrossing crossing; + crossing.conductor = conductor; + crossing.position = where; + crossings.append(crossing); + } + + std::sort(crossings.begin(), crossings.end(), + [&start, &direction](const CableCrossing &a_, const CableCrossing &b_) { + const QPointF da = a_.position - start; + const QPointF db = b_.position - start; + const qreal ta = da.x() * direction.x() + da.y() * direction.y(); + const qreal tb = db.x() * direction.x() + db.y() * direction.y(); + return ta < tb; + }); + + return crossings; +} + +/** + @brief CableManager::crossingOf + Where one line runs across one conductor, if it does at all. + + Done by hand rather than with QLineF::intersects() because the + collinear case has to be a miss: a cable drawn exactly on top of a + conductor follows it, it does not cross it, and counting it would + claim a core the cable never reaches. + @param diagram the folio the line is drawn on + @param line the line, in scene coordinates + @param conductor the wire being asked about + @param result receives the crossing point, in scene coordinates + @return true when the line really runs across that wire +*/ +bool CableManager::crossingOf(Diagram *diagram, + const QLineF &line, + Conductor *conductor, + QPointF *result) +{ + if (!conductor || !diagram) return false; + + const QPointF start = line.p1(); + const QPointF end = line.p2(); + if (qFuzzyCompare(start, end)) return false; + + //Work in the conductor's own coordinates: its path is stored + //there, and a conductor is never where it was when it was + //created once the whole drawing has been moved about. + const QPointF a = conductor->mapFromScene(start); + const QPointF b = conductor->mapFromScene(end); + + const QPainterPath path = conductor->path(); + for (int i = 0; i + 1 < path.elementCount(); ++i) + { + const QPainterPath::Element &e1 = path.elementAt(i); + const QPainterPath::Element &e2 = path.elementAt(i + 1); + //A MoveTo ends the segment before it: the jump to the new + //subpath is not drawn, so nothing can cross it. + if (e2.type == QPainterPath::MoveToElement) { + continue; + } + QPointF where; + if (segmentIntersection(a, b, + QPointF(e1.x, e1.y), + QPointF(e2.x, e2.y), + &where)) + { + if (result) { + *result = conductor->mapToScene(where); + } + return true; + } + } + return false; +} + +/** + @brief CableManager::distanceTo + How far a point of the sheet stands from a wire, in scene units. + @param conductor + @param scene_pos + @return that distance, which is where the wire passes when the point + is beside it rather than in front of one of its ends +*/ +qreal CableManager::distanceTo(Conductor *conductor, const QPointF &scene_pos) +{ + if (!conductor) { + return std::numeric_limits::max(); + } + + qreal best = std::numeric_limits::max(); + const QPainterPath path = conductor->path(); + for (int i = 0; i + 1 < path.elementCount(); ++i) + { + const QPainterPath::Element &e1 = path.elementAt(i); + const QPainterPath::Element &e2 = path.elementAt(i + 1); + if (e2.type == QPainterPath::MoveToElement) continue; + + const qreal d = distanceToSegment( + scene_pos, + conductor->mapToScene(QPointF(e1.x, e1.y)), + conductor->mapToScene(QPointF(e2.x, e2.y))); + if (d < best) { + best = d; + } + } + return best; +} + +namespace { + +/** + @brief The end of @a conductor which does not touch @a element. + + Which end that is tells where the wire goes on its own sheet: a wire + ending on a report arrow points away from it, and that is the end + which says something about where it comes from or where it runs to. + @param conductor the wire to look at + @param element the report arrow it ends on (may be nullptr) + @return the scene position of the other end, (0, 0) when there is none +*/ +QPointF farEndPosition(const Conductor *conductor, Element *element) +{ + const Terminal *far = nullptr; + if (conductor->terminal1 + && conductor->terminal1->parentElement() != element) { + far = conductor->terminal1; + } else { + far = conductor->terminal2 ? conductor->terminal2 : conductor->terminal1; + } + return far ? far->scenePos() : QPointF(); +} + +/** + @brief Whether wire @a a runs before wire @a b on its own sheet. + + Both are looked at from @a element: what tells them apart is where + their other end goes. Putting the wires which come in to a report + and the wires which go out of its partner in this order is what + pairs a continuation up with the core it belongs to when several + cores run through one and the same report. + @param a @param b the two wires to compare + @param element the report arrow both end on (may be nullptr) + @return true when @a a comes first +*/ +bool farBefore(const Conductor *a, const Conductor *b, Element *element) +{ + const QPointF pa = farEndPosition(a, element); + const QPointF pb = farEndPosition(b, element); + if (pa.y() != pb.y()) return pa.y() < pb.y(); + if (pa.x() != pb.x()) return pa.x() < pb.x(); + return a->uuid() < b->uuid(); +} + +} // namespace + +/** + @brief CableManager::refreshLabels + Rewrite the cable field of every conductor of the project from the + cables themselves. + + The rule is one-directional and therefore cannot drift: a conductor + which some cable claims gets the label that cable generates, a + conductor which no cable claims any more -- because the cable was + delete, the core was dragged away or the type lost that core -- has + the generated label taken away. A conductor which never belonged to + a cable keeps whatever it had, since only one with a reference is + touched. + + A core which ends on a folio report goes on over there: the wire + coming out of the linked arrow shows the same piece of copper, so + it belongs to the same core and carries the same cable field. What + the links say is worked out from scratch on every pass, and a wire + which some cable already claims is never taken away from it, so the + reference appears wherever a report carries a core and disappears + again by itself as soon as the cable, the core or the link itself + is gone. Chains of reports are followed as far as they go, so a + cable may run over any number of sheets. + @param project +*/ +void CableManager::refreshLabels(QETProject *project) +{ + if (!project) return; + + struct Binding { + QUuid cable; + int slot = -1; + QString label; + }; + + QHash bindings; + for (Cable *cable : project->cables()) + { + if (!cable) continue; + for (const CableCore &core : cable->usedCores()) + { + if (core.conductor.isNull()) continue; + Binding binding; + binding.cable = cable->uuid(); + binding.slot = core.core; + binding.label = cable->labelOfCore(core.core); + bindings.insert(core.conductor, binding); + } + } + + //A core which ends on a folio report continues on the wire which + //leaves the linked arrow on the other sheet. That wire shows the + //same piece of copper, so it gets the same reference as the core + //it continues. The walk repeats while it finds new wires, so a + //chain of reports is followed across any number of sheets, and + //it only ever fills a wire no cable has claimed yet: two cores + //can never fight over the same wire, and a reference worked out + //here is taken away again by the same pass as soon as the cable, + //the core or the link does. + QHash conductors_by_uuid; + QHash> endings_on_report; + for (Diagram *diagram : project->diagrams()) + { + if (!diagram) continue; + for (Conductor *conductor : diagram->conductors()) + { + if (conductor) conductors_by_uuid.insert(conductor->uuid(), conductor); + } + } + + //Where a wire goes on its own sheet (farBefore, above): putting the + //wires which come in and the wires which go out in the same order + //is what pairs a continuation up with its core when several cores + //run through one and the same report. + + bool grew = true; + while (grew) + { + grew = false; + endings_on_report.clear(); + for (const QUuid &source_uuid : bindings.keys()) + { + Conductor *source = conductors_by_uuid.value(source_uuid); + if (!source) continue; + + Terminal *ends[2] = {source->terminal1, source->terminal2}; + for (Terminal *end : ends) + { + if (!end) continue; + Element *element = end->parentElement(); + //An arrow taken off the sheet carries nothing over + //any more, whatever still links it + if (!element || !element->scene()) continue; + if (!(element->linkType() & Element::AllReport)) continue; + + QList &in = endings_on_report[element]; + if (!in.contains(source)) in.append(source); + } + } + + for (auto it = endings_on_report.constBegin(); + it != endings_on_report.constEnd(); ++it) + { + Element *element = it.key(); + QList in = it.value(); + std::sort(in.begin(), in.end(), + [&](const Conductor *a, const Conductor *b) { + return farBefore(a, b, element); + }); + + for (Element *partner : element->linkedElements()) + { + if (!partner || partner == element || !partner->scene()) continue; + + QList out = partner->conductors(); + std::sort(out.begin(), out.end(), + [&](const Conductor *a, const Conductor *b) { + return farBefore(a, b, partner); + }); + + //One core coming in may branch out into several wires + //over there -- they all show the same piece of copper. + //Several cores coming in take one wire each, in the + //order they run on their own sheet. + const int count = (in.size() == 1) + ? out.size() + : qMin(in.size(), out.size()); + for (int i = 0; i < count; ++i) + { + Conductor *continued = out.at(i); + if (!continued || bindings.contains(continued->uuid())) continue; + const int from = qMin(i, in.size() - 1); + bindings.insert(continued->uuid(), + bindings.value(in.at(from)->uuid())); + grew = true; + } + } + } + } + + for (Diagram *diagram : project->diagrams()) + { + if (!diagram) continue; + for (Conductor *conductor : diagram->conductors()) + { + if (!conductor) continue; + + const auto it = bindings.constFind(conductor->uuid()); + if (it != bindings.constEnd()) + { + const ConductorProperties &properties = conductor->properties(); + conductor->setCableReference(it->label, it->cable, it->slot); + } + else if (!conductor->properties().m_cable_uuid.isNull()) + { + conductor->setCableReference(QString(), QUuid(), -1); + } + } + } +} + +/** + @brief CableManager::claimConductor + Make a conductor a core of @a new_owner, taking it away from whichever + cable held it before. A conductor is a single piece of wire: two + cables cannot both claim it, and the one that loses it must not go + on drawing a label for it. + @param project + @param conductor + @param new_owner the cable which is about to use the conductor +*/ +void CableManager::claimConductor(QETProject *project, Conductor *conductor, Cable *new_owner) +{ + if (!project || !conductor) return; + + const QUuid owner_uuid = conductor->properties().m_cable_uuid; + if (owner_uuid.isNull() || (new_owner && owner_uuid == new_owner->uuid())) { + return; + } + + if (Cable *previous = cableByUuid(project, owner_uuid)) { + previous->removeCore(conductor->properties().m_cable_slot); + } +} + +/** + @brief CableManager::cableByUuid + @param project + @param uuid + @return the cable with that identity, or nullptr +*/ +Cable *CableManager::cableByUuid(QETProject *project, const QUuid &uuid) +{ + if (!project || uuid.isNull()) return nullptr; + for (Cable *cable : project->cables()) { + if (cable && cable->uuid() == uuid) return cable; + } + return nullptr; +} + +/** + @brief CableManager::conductorByUuid + @param project + @param uuid + @return the conductor with that identity, or nullptr. Needed because + a cable remembers its cores by identity: only the drawing knows where + they ended up. +*/ +Conductor *CableManager::conductorByUuid(QETProject *project, const QUuid &uuid) +{ + if (!project || uuid.isNull()) return nullptr; + for (Diagram *diagram : project->diagrams()) { + if (!diagram) continue; + for (Conductor *conductor : diagram->conductors()) { + if (conductor && conductor->uuid() == uuid) return conductor; + } + } + return nullptr; +} + +/** + @brief CableManager::nextDesignation + Work out the number of the next cable drawn on a folio. + + When the project has a numbering rule for cables, that rule decides: + whatever it holds at this moment is the number, read by the very + same machinery the element numbering reads its own. When it has + none, the plain letter W is handed out: a project which numbers its + cables by no rule gets no numbers invented for it, and W -- the one + part of the old numbers which was never a number -- is all such a + cable is called until a rule is defined. + + A rule whose counter belongs to one folio is read from the beginning + of the folio the cable is drawn on, so that the number this cable + gets is the first free one of its own folio rather than whatever the + project's rule happens to hold after numbering other folios. + + Nothing here moves the project's own rule on: that belongs to the + moment the cable really comes into being. + @param project + @param diagram the folio the cable is being drawn on + @return the number +*/ +QString CableManager::nextDesignation(QETProject *project, Diagram *diagram) +{ + NumerotationContext context; + if (project) { + context = project->cableAutoNum(project->cableCurrentAutoNum()); + } + //The folio-bound parts are put back to the start of that folio; + //the stepping of the overload below then walks past every + //number of this folio which is already taken + context = autonum::resetFolioCounters(context); + return nextDesignation(project, diagram, context); +} + +/** + @brief CableManager::nextDesignation + Work the number of a cable out of a rule which the caller keeps in + @p context -- the project's own rule as it stands, or a copy of it + which the caller is numbering a whole run of cables with. + + Whatever the rule holds is the number, unless some cable already + stands under it: then @p context is stepped on and the rule is read + once more, so that a paste which brings in several cables at once + numbers each of them differently although the project's own rule + does not move until those cables really exist. A rule which cannot + step -- one with nothing in it which counts -- hands out what it + says and says no more, for what it says is all it knows. + + A project which numbers its cables by no rule is not read at all + and is handed the plain letter W instead: no numbers are invented + where none have been defined. + @param project + @param diagram the folio the cable is being drawn on, which every + folio-bound part of a rule is read from + @param context the rule to read; stepped on when what it says is + already taken + @return the number +*/ +QString CableManager::nextDesignation(QETProject *project, + Diagram *diagram, + NumerotationContext &context) +{ + if (!project || !diagram) return QString(); + + const QString key = project->cableCurrentAutoNum(); + //Whether there was a rule to read at all, taken down before the + //loop: stepping the rule on empties it, and that must not look + //like a project which never had one. + const bool ruled = !key.isEmpty() && !context.isEmpty(); + QString previous; + + for (int attempt = 0; ruled && attempt < 1000 && !context.isEmpty(); ++attempt) + { + //The rule is read on a copy: reading it fills in what its + //folio-bound parts are worth here and now, and that must + //not creep into the rule itself. + NumerotationContext evaluated = context; + const QString formula = autonum::numerotationContextToFormula(evaluated); + autonum::sequentialNumbers seq; + autonum::setSequential(formula, seq, evaluated, diagram, key); + const QString candidate = + autonum::AssignVariables::formulaToLabel(formula, seq, diagram); + + if (candidate.isEmpty()) break; + if (!designationIsTaken(project, candidate)) return candidate; + + //Taken already: step the rule on and read it once more. + if (candidate == previous) return candidate; + previous = candidate; + + NumerotationContextCommands ncc(context, diagram); + const NumerotationContext following = ncc.next(); + if (following.size() != context.size()) break; + context = following; + } + + //No rule at all: the plain letter W, and nothing more. The old + //numbers read folio + W + index, and W is the only part of them + //which was never a number -- so it is what a project which has + //defined no numbering rule calls its cables, until it defines + //one. + if (!ruled) { + return QStringLiteral("W"); + } + + //A rule which says nothing usable here: no number at all. The + //number is what the rule says, and a rule which says nothing + //leaves the field to the user rather than having it filled in + //with something it never held. + return QString(); +} diff --git a/sources/cable/cablemanager.h b/sources/cable/cablemanager.h new file mode 100644 index 000000000..00055f6dc --- /dev/null +++ b/sources/cable/cablemanager.h @@ -0,0 +1,113 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef CABLEMANAGER_H +#define CABLEMANAGER_H + +#include +#include +#include +#include + +class Cable; +class Conductor; +class Diagram; +class NumerotationContext; +class QETProject; + +/** + @brief One place where a drawn cable line crosses a conductor. +*/ +struct CableCrossing +{ + Conductor *conductor = nullptr; + /// Scene coordinates of the crossing, where the slash is drawn + QPointF position; +}; + +/** + @brief The CableManager works out everything which follows from the + cables of a project, so nothing has to be maintained by hand. + + It detects which conductors a line about to be drawn crosses, and it + turns the cables back into the cable field of every conductor they + own -- both directions of one single rule: the cable is the only + source of truth. +*/ +class CableManager +{ + public: + ///How far a colour label may stand from where a line crosses + ///a wire for that wire to count as the one the label stands + ///on: one scene unit, and that is already generous. The grid + ///is there to put a label exactly on the crossing, and it + ///does: a label drawn on a wire, slid along its own line or + ///carried there by a paste lands on that crossing to the + ///last decimal. The single unit only soaks up the rounding + ///which comes of projecting a label onto its line, and a + ///terminal which is a hair off the grid. Anything beyond it + ///is beside the wire rather than on it, so a label one + ///whole grid step away -- however the grid is set -- gives + ///up its entry. One number for every place a label is given + ///to a wire: drawing one, dragging one and wiring a copy + ///must not disagree about it. + static constexpr qreal bind_tolerance = 1.0; + + //Drawing + static QList crossedConductors(Diagram *diagram, const QLineF &line); + static bool crossingOf(Diagram *diagram, + const QLineF &line, + Conductor *conductor, + QPointF *result); + static qreal distanceTo(Conductor *conductor, const QPointF &scene_pos); + + //Keeping the drawing in step with the cables + static void refreshLabels(QETProject *project); + static void claimConductor(QETProject *project, Conductor *conductor, Cable *new_owner); + + //Lookup + static Cable *cableByUuid(QETProject *project, const QUuid &uuid); + static Conductor *conductorByUuid(QETProject *project, const QUuid &uuid); + static QString nextDesignation(QETProject *project, Diagram *diagram); + /** + Work the number of a next cable out of a rule which is + @em not the project's own state but the one @p context + holds: the rule is read as it stands there and what it + hands out is the number, unless some cable already + stands under that number -- which is what happens while + a whole run of cables is numbered one after the other + before any of them has really been added. Then @p + context is stepped on and the rule is read once more. + + When the project numbers its cables by no rule at all, + @p context is not read and the plain letter W is handed + out instead: no numbers are invented where none have + been defined. + + A caller numbering a run of cables keeps the same @p + context for all of them, so each of them gets a number + of its own; a caller numbering one cable lets it go + again afterwards. The project's own rule is never + moved on here -- that belongs to the moment the cable + comes into being. + */ + static QString nextDesignation(QETProject *project, + Diagram *diagram, + NumerotationContext &context); +}; + +#endif // CABLEMANAGER_H diff --git a/sources/cable/cablepart.cpp b/sources/cable/cablepart.cpp new file mode 100644 index 000000000..ef1b7401d --- /dev/null +++ b/sources/cable/cablepart.cpp @@ -0,0 +1,3377 @@ +/* + 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 "cablepart.h" + +#include "cable.h" +#include "cablemanager.h" +#include "cablepropertiesdialog.h" +#include "editcablecommand.h" +#include "../autoNum/assignvariables.h" +#include "../diagram.h" +#include "../properties/xrefproperties.h" +#include "../qetapp.h" +#include "../qetproject.h" +#include "../qetgraphicsitem/conductor.h" +#include "../utils/qetutils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace { + + ///Half size of the slash drawn where a core crosses a conductor +const qreal slash_half = 3.5; + ///How far from a slash or its colour the mouse still counts as holding it +const qreal slash_grab = 6.0; + ///How far the colour of a core may stand from the wire it names: + ///close enough to read as one thing, far enough away that the tail of + ///a letter like "g" does not run into the wire. The text is turned by + ///a quarter, so that tail is its right hand side, which is the side + ///facing the wire. +const qreal core_label_side = 2.5; + ///How far above the line the colour of a core is written, so + ///the line passes beside the letters instead of through them +const qreal core_label_gap = 4.0; + ///Radius of the blue point drawn at each end of a picked line, and + ///how far from such a point the mouse still counts as holding it. + ///Ten across, which is the size QET's own handles use. +const qreal grip_radius = 5.0; +const qreal grip_grab = 6.0; + ///How far from the trunk line the mouse still counts as aiming at + ///it while a core waits to be put down there: the core follows the + ///line only while the mouse is near enough for the click to read as + ///belonging to it, and the slash itself is drawn on the line, at a + ///grid step, so what is shown is always what would be kept. +const qreal place_grab = 30.0; + +} // namespace + + ///The one line which waits for the click putting a core down, if any +QPointer CablePart::s_placing; + +/** + @brief CablePart::CablePart + @param data the section as the cable holds it + @param parent +*/ +CablePart::CablePart(const CablePartData &data, QGraphicsItem *parent) : + CablePart(parent) +{ + setLine(data.p1, data.p2); + m_part_uuid = data.uuid; + m_pending_cable = QUuid(); +} + +/** + @brief CablePart::CablePart + An empty part, waiting for its geometry: used while reading a project. + @param parent +*/ +CablePart::CablePart(QGraphicsItem *parent) : + QetGraphicsItem(parent) +{ + //ItemSendsGeometryChanges is what makes itemChange() hear about + //position changes at all -- without it Qt silently never calls + //it for a setPos(), and then this item is moved only in the + //screen's opinion: the line and its colour labels are drawn at + //the new place because the whole item is transformed, while + //m_p1/m_p2 and CableCore::position go on naming the old one. + //A paste would therefore wire its labels against the coordinates + //the line was copied from, and the next copy would write those + //stale coordinates into the clipboard -- which is exactly the + //paste landing away from the group it belongs to. + setFlags(ItemIsMovable | ItemIsSelectable | ItemSendsGeometryChanges); + setAcceptHoverEvents(true); + m_part_uuid = QUuid::createUuid(); +} + +CablePart::~CablePart() +{ +} + +/** + @brief CablePart::setCable + Bind this section to its cable. The item follows every change of the + cable, because the number and the type it draws live there. + @param cable +*/ +void CablePart::setCable(Cable *cable) +{ + if (m_cable == cable) { + return; + } + if (m_cable) { + disconnect(m_cable, nullptr, this, nullptr); + } + m_cable = cable; + m_pending_cable = QUuid(); + if (m_cable) + { + connect(m_cable, &Cable::changed, this, [this]() { + prepareGeometryChange(); + update(); + }); + } + setUpXrefHooks(); + prepareGeometryChange(); + update(); +} + +/** + @brief CablePart::cable + @return the cable this section belongs to, or nullptr +*/ +Cable *CablePart::cable() const +{ + return m_cable.data(); +} + +/** + @brief CablePart::partUuid + @return which section of the cable this item draws +*/ +QUuid CablePart::partUuid() const +{ + return m_part_uuid; +} + +/** + @brief CablePart::refreshPending + Bind the item to its cable once the whole project is read: a folio is + loaded before the cables it holds sections of, so at that moment the + cable is only known by its identity. +*/ +void CablePart::refreshPending() +{ + if (m_cable) return; + if (!diagram() || !diagram()->project()) return; + + if (Cable *cable = CableManager::cableByUuid(diagram()->project(), m_pending_cable)) { + setCable(cable); + } +} + +/** + @brief CablePart::setLine + Change where the trunk line runs. Purely optical: no core is added, + none is dropped, nothing is reassigned -- a cable keeps its cores + wherever its line happens to run. + @param p1 scene coordinates of one end + @param p2 scene coordinates of the other end +*/ +void CablePart::setLine(const QPointF &p1, const QPointF &p2) +{ + if (m_p1 == p1 && m_p2 == p2) return; + prepareGeometryChange(); + m_p1 = p1; + m_p2 = p2; + syncGeometry(); + update(); +} + +/** + @brief CablePart::line + @return the trunk line, in scene coordinates +*/ +QLineF CablePart::line() const +{ + return QLineF(m_p1, m_p2); +} + +/** + @brief CablePart::textFont + @return the font a text of the cable is written with when the cable + itself asks for none: what the preferences offer on the "Textes" + page, and -- until he has picked something there -- the font the + folio writes its own texts with, a shade smaller: the way every + cable has always been drawn, so that merely opening the + preferences changes nothing. +*/ +QFont CablePart::textFont() const +{ + return QETApp::cableTextsFont(); +} + +/** + @brief CablePart::coreFont + @return the font the colours of the cores are written with when the + cable itself asks for none -- all of them together, since the + colours of one cable are read as one and are never set apart. +*/ +QFont CablePart::coreFont() const +{ + return fontOf(QStringLiteral("cores")); +} + +/** + @brief CablePart::fontOf + @param key the name of a text of the cable: "designation", "type", + "installation", "location", "length" or "cores" + @return the font that text is really written with: the one this + cable asks for, or -- when it asks for none -- the one the + preferences offer +*/ +QFont CablePart::fontOf(const QString &key) const +{ + if (m_cable) + { + const CableTextFormat format = m_cable->properties().texts.value(key); + if (!format.font.isEmpty()) + { + QFont font; + if (QETUtils::fontFromString(font, format.font)) { + return font; + } + } + } + + //The reference to the other folios this cable runs on has a + //font of its own, set with the button next to its text under + //Cross-references -- he sets it there and not in the cable itself. + //When he has left that alone, the reference follows the texts + //of the cable like every other line of the label does. + if (key == QLatin1String("xref") && m_cable && diagram() && diagram()->project()) + { + const QString description = diagram()->project() + ->defaultXRefProperties(QStringLiteral("cable")).font(); + if (!description.isEmpty()) + { + QFont font; + if (QETUtils::fontFromString(font, description)) { + return font; + } + } + } + + return key == QLatin1String("cores") ? QETApp::cableCoreFont() : textFont(); +} + +/** + @brief CablePart::alignOf + How one text lines up at the left end of the trunk line. A cable + which says nothing about it follows the preferences, which is where + -- until he has said otherwise there either -- every text ends where + the line begins, the way it always did. + @param key the name of a text of the cable + @return Qt::AlignLeft, Qt::AlignHCenter or Qt::AlignRight +*/ +Qt::Alignment CablePart::alignOf(const QString &key) const +{ + int alignment = 0; + if (m_cable) { + alignment = m_cable->properties().texts.value(key).alignment; + } + if (!alignment) { + return QETApp::cableTextAlignment(); + } + switch (alignment) + { + case int(Qt::AlignLeft): return Qt::AlignLeft; + case int(Qt::AlignHCenter): return Qt::AlignHCenter; + case int(Qt::AlignRight): return Qt::AlignRight; + default: return Qt::AlignRight; + } +} + +/** + @brief CablePart::textOffset + @return how far the whole label at the left of the line stands from + where it has always stood: what the form offers as "Position du + texte", and zero for a cable which has never been nudged -- which + is where every cable starts, so that merely opening a project + moves nothing. +*/ +QPointF CablePart::textOffset() const +{ + return m_cable ? m_cable->properties().text_offset : QPointF(); +} + +/** + @brief CablePart::coreOffset + @return how far the row of colours stands from the slashes it names: + the same kind of nudge, kept apart from the label's because the two + are set apart in the form and are moved one without the other. +*/ +QPointF CablePart::coreOffset() const +{ + return m_cable ? m_cable->properties().core_offset : QPointF(); +} + +/** + @brief CablePart::textLeft + Where a text of this width starts, at the left end of the trunk + line -- the same place for every text, so that a cable whose + fields are set apart still reads as one label rather than as five. + @param key the name of a text of the cable + @param width how wide that text is, in scene units + @return the x of the left of the text, in scene coordinates +*/ +qreal CablePart::textLeft(const QString &key, qreal width) const +{ + const qreal line_left = qMin(m_p1.x(), m_p2.x()); + if (alignOf(key) == Qt::AlignLeft) { + return line_left + 6.0; + } + if (alignOf(key) == Qt::AlignHCenter) { + return line_left - width * 0.5; + } + return line_left - 6.0 - width; +} + +/** + @brief CablePart::trunkIsHorizontal + @return true when the trunk line runs along the sheet rather than up + and down it: which way it runs is what tells the label where it may + sit, and where the colour of a core may be written. +*/ +bool CablePart::trunkIsHorizontal() const +{ + return qAbs(m_p2.y() - m_p1.y()) <= qAbs(m_p2.x() - m_p1.x()); +} + +/** + @brief CablePart::labelLines + @return what of the cable the label at the left of the trunk line + writes, in the order the lines are stacked: where the cable runs + first, then what it is called. Every field the user switched off, + and every field he left empty, is left out. + + Neither the type of the cable, nor its length, nor the references to + the other folios this cable runs on is among them: all three are + written under the line rather than over it -- the type first, the + length under it and the references at the very bottom -- so what is + above the line stays the description of where the cable belongs and + nothing else. See refLines() for those underneath. + + Each line comes with the rectangle it is written in, worked out with + its own font and its own way of lining up: those lines may differ + from each other as much as he likes, and they are stacked one + under the other by their own height rather than by one common size. +*/ +QList CablePart::labelLines() const +{ + if (!m_cable) return QList(); + + const CableProperties properties = m_cable->properties(); + QList lines; + const auto write = [this, &lines](const QString &text, const QString &key) + { + if (text.isEmpty()) return; + LabelLine line; + line.text = text; + line.key = key; + line.font = fontOf(key); + line.align = alignOf(key); + const QFontMetricsF metrics(line.font); + line.width = metrics.horizontalAdvance(text); + line.height = metrics.height(); + lines.append(line); + }; + + if (properties.show_installation) { + write(properties.installation, QStringLiteral("installation")); + } + if (properties.show_location) { + write(properties.location, QStringLiteral("location")); + } + if (properties.show_designation) { + write(properties.designation, QStringLiteral("designation")); + } + if (lines.isEmpty()) return lines; + + qreal total_height = 0.0; + for (const LabelLine &line : lines) { + total_height += line.height; + } + const qreal top = trunkIsHorizontal() + ? (qMin(m_p1.y(), m_p2.y()) - 2.0 - total_height) + : qMin(m_p1.y(), m_p2.y()); + + qreal y = top; + for (LabelLine &line : lines) + { + line.rect = QRectF(textLeft(line.key, line.width), y, + line.width, line.height); + y += line.height; + } + + //Where the label as a whole has been nudged to: every line of it + //goes with the rest, so those three lines keep reading as one + //label rather than as three which have drifted apart. + const QPointF nudge = textOffset(); + if (!nudge.isNull()) + { + for (LabelLine &line : lines) { + line.rect.translate(nudge); + } + } + return lines; +} + +/** + @brief CablePart::refLines + @return one line per other folio this cable runs on, stacked under + the length of the cable -- under the type when the length is not + drawn, straight under the trunk line when neither is. + + That is the bottom of everything the cable says about itself: above + the line stands where it belongs, under the line what it is and how + long it runs, and last comes where it goes on. Each line names the + section standing there, and each of them is the line he double + clicks to be taken to it. + + Every entry comes in the order crossRefs() lists them, so the index + of a line here is the index of that reference there. +*/ +QList CablePart::refLines() const +{ + if (!m_cable) return QList(); + + QList lines; + for (const CrossRef &ref : crossRefs()) + { + if (ref.text.isEmpty()) continue; + LabelLine line; + line.text = ref.text; + line.key = QStringLiteral("xref"); + line.font = fontOf(line.key); + line.align = alignOf(line.key); + const QFontMetricsF metrics(line.font); + line.width = metrics.horizontalAdvance(line.text); + line.height = metrics.height(); + lines.append(line); + } + if (lines.isEmpty()) return lines; + + //The same stack as typeRect() and lengthRect() build, only + //continued: what those two write between themselves decides how + //far down the references start, so no two of them can overlap + //whatever fonts he has chosen for them. + qreal y = underTop(); + if (!typeText().isEmpty()) { + y += QFontMetricsF(fontOf(QStringLiteral("type"))).height(); + } + if (!lengthText().isEmpty()) { + y += QFontMetricsF(fontOf(QStringLiteral("length"))).height(); + } + + for (LabelLine &line : lines) + { + line.rect = QRectF(textLeft(line.key, line.width), y, + line.width, line.height); + y += line.height; + } + + const QPointF nudge = textOffset(); + if (!nudge.isNull()) + { + for (LabelLine &line : lines) { + line.rect.translate(nudge); + } + } + return lines; +} + +/** + @brief CablePart::crossRefs + @return every other folio this cable runs on, one entry each, in the + order the folios turn -- empty when the cable runs on this folio + alone, which is what a cable normally does. + + The reference needs no data of its own: a cable standing on two + folios is one cable drawn as several lines, so every one of those + lines knows its siblings and they all get the same list, whichever + way they were put together -- a line drawn onto a cable which was + already there, or a cable drawn first and joined later. +*/ +QList CablePart::crossRefs() const +{ + QList refs; + if (!m_cable || !diagram() || !diagram()->project()) return refs; + + QETProject *project = diagram()->project(); + + //Which section stands on which other folio. The first section of + //a folio is the one %l and %c name, when the format asks for + //them: it is the line he has to look for over there. + QHash elsewhere; + const QList parts = m_cable->parts(); + for (const CablePartData &part : parts) + { + Diagram *other = project->diagramByUuid(part.diagram); + if (!other || other == diagram()) continue; + if (!elsewhere.contains(other)) { + elsewhere.insert(other, part); + } + } + if (elsewhere.isEmpty()) return refs; + + //The format this project asks for such a reference, chosen under + //Cross-references for the type "Cable": one text per folio, written + //out with that folio's own numbers and fields. + const XRefProperties xref = project->defaultXRefProperties(QStringLiteral("cable")); + const QString format = xref.masterLabel(); + if (format.isEmpty()) return refs; + + //The folios are walked over in the order they turn, so the lines + //read the way the pages do. + const QList folios = project->diagrams(); + for (Diagram *other : folios) + { + if (!elsewhere.contains(other)) continue; + + autonum::FormulaContext context; + const BorderTitleBlock &border = other->border_and_titleblock; + context.folio = border.folio(); + context.folio_index = other->folioIndex(); + context.folio_total = border.folioTotal(); + context.plant = border.plant(); + context.locmach = border.locmach(); + context.title_block_fields = border.additionalFields(); + context.project_properties = project->projectProperties(); + const CablePartData part = elsewhere.value(other); + context.has_element = true; + context.element_position = other->convertPosition((part.p1 + part.p2) * 0.5); + + //A fresh set of numbers for every line: two references of + //the same cable must not count one after the other. + autonum::sequentialNumbers seq; + CrossRef ref; + ref.diagram = other; + ref.part = part.uuid; + ref.text = autonum::AssignVariables::formulaToLabel(format, seq, context); + if (ref.text.isEmpty()) continue; + refs.append(ref); + } + return refs; +} + +/** + @brief CablePart::crossRefAt + @param scene_pos a point of the sheet, in scene coordinates + @return the index of the reference standing at that very place, -1 + when none does -- measured on the lines which are really drawn, so + the hand of the mouse, the double click and the drawing can never + disagree about which line is which. +*/ +int CablePart::crossRefAt(const QPointF &scene_pos) const +{ + int index = 0; + for (const LabelLine &line : refLines()) + { + if (line.rect.contains(scene_pos)) return index; + ++index; + } + return -1; +} + +/** + @brief CablePart::goToCrossRef + Send the mouse to the section of the cable which stands on the folio + the reference at that place points at, the way a cross reference of + an element does: the folio is shown and the line is picked. + @param scene_pos a point of the sheet, in scene coordinates + @return true when the click stood on a reference +*/ +bool CablePart::goToCrossRef(const QPointF &scene_pos) +{ + const int index = crossRefAt(scene_pos); + if (index < 0) return false; + + const QList refs = crossRefs(); + if (index >= refs.size()) return false; + + const CrossRef ref = refs.at(index); + if (!ref.diagram) return false; + + const QList items = ref.diagram->items(); + for (QGraphicsItem *item : items) + { + auto *section = qgraphicsitem_cast(item); + if (section && section->partUuid() == ref.part) { + QetGraphicsItem::showItem(section); + return true; + } + } + return false; +} + +/** + @brief CablePart::pdfRefs + Where the PDF export has to put a clickable link for every reference + this section writes: the rectangle of the text on this folio, and on + the folio it points at the rectangle of the section it names -- the + very section a double click on the drawing sends him to (see + goToCrossRef), so that the export and the drawing can never disagree + about where the link leads. + + One entry per reference drawn, in the order it is drawn, so that the + export and the drawing count the same way (see crossRefAt). + @return those entries, empty when this cable runs on this folio alone +*/ +QList CablePart::pdfRefs() const +{ + QList out; + if (!m_cable) return out; + + const QList lines = refLines(); + const QList refs = crossRefs(); + const int count = qMin(lines.size(), refs.size()); + + for (int i = 0; i < count; ++i) + { + const CrossRef &ref = refs.at(i); + if (!ref.diagram || lines.at(i).rect.isEmpty()) continue; + + PdfRef link; + link.diagram = ref.diagram; + link.here = lines.at(i).rect; + + //The section standing over there, which is what the reader + //has to be brought to. It is looked for the same way a click + //on the drawing looks for it, so link and click agree. + const QList items = ref.diagram->items(); + for (QGraphicsItem *item : items) + { + auto *section = qgraphicsitem_cast(item); + if (section && section->partUuid() == ref.part) { + link.there = section->mapRectToScene(section->boundingRect()); + break; + } + } + out.append(link); + } + return out; +} + +/** + @brief CablePart::setUpXrefHooks + Keep the reference lines up to date with what the project does: a + folio which is added, removed or moved changes which lines there are + and what they say, and so does the format chosen under Cross-references. + + Only this line is redrawn, which is enough: the references of a + cable are worked out again from the cable itself every time its + label is drawn. +*/ +void CablePart::setUpXrefHooks() +{ + for (const QMetaObject::Connection &connection : std::as_const(m_xref_connections)) { + disconnect(connection); + } + m_xref_connections.clear(); + + Diagram *folio = diagram(); + if (!m_cable || !folio || !folio->project()) return; + + QETProject *project = folio->project(); + const auto rebuild = [this]() { + prepareGeometryChange(); + setUpXrefHooks(); + update(); + }; + + m_xref_connections << connect(project, &QETProject::projectDiagramsOrderChanged, this, rebuild); + m_xref_connections << connect(project, &QETProject::diagramAdded, this, rebuild); + m_xref_connections << connect(project, &QETProject::diagramRemoved, this, rebuild); + m_xref_connections << connect(project, &QETProject::XRefPropertiesChanged, this, rebuild); + + //%F names the other folio by its label, which is read off that + //folio itself: only when the format really asks for it does that + //folio have to be listened to as well. + const XRefProperties xref = project->defaultXRefProperties(QStringLiteral("cable")); + if (xref.masterLabel().contains(QLatin1String("%F"))) + { + for (Diagram *other : project->diagrams()) { + m_xref_connections << connect(other, &Diagram::diagramInformationChanged, this, rebuild); + } + } +} + +/** + @brief CablePart::typeText + @return the type of the cable, an empty string when the drawing is + not meant to show it or the cable says nothing about it +*/ +QString CablePart::typeText() const +{ + if (!m_cable) return QString(); + + const CableProperties properties = m_cable->properties(); + if (!properties.show_type || properties.type.isEmpty()) { + return QString(); + } + return properties.type; +} + +/** + @brief CablePart::lengthText + @return how long the cable is, an empty string when the drawing is + not meant to show it or the cable says nothing about it +*/ +QString CablePart::lengthText() const +{ + if (!m_cable) return QString(); + + const CableProperties properties = m_cable->properties(); + if (!properties.show_length || properties.length.isEmpty()) { + return QString(); + } + return properties.length; +} + +/** + @brief CablePart::labelRect + The rectangle holding the label of the cable, drawn at the left of + the trunk line -- in scene coordinates, like the line itself, so the + item never has to be moved for its text to follow. + + Across a horizontal line the whole label stands above the line: text + straddling the line is what the eyes have to fight with. The type + and the length of the cable stand under the line, one under the + other, and are therefore not measured here. + @return an empty rectangle when the cable says nothing to show +*/ +QRectF CablePart::labelRect() const +{ + QRectF rect; + for (const LabelLine &line : labelLines()) + { + rect = rect.isEmpty() ? line.rect : rect.united(line.rect); + } + return rect; +} + +/** + @brief CablePart::underTop + Where the first line written under the trunk line starts. + + Across a horizontal line that is just under the line itself; along a + line which runs up and down the sheet "under" has no meaning, so it + is just under the rest of the label instead -- which keeps those + lines at the left of the line either way. + @return the y of the top of that first line, in scene coordinates +*/ +qreal CablePart::underTop() const +{ + if (trunkIsHorizontal()) { + return qMax(m_p1.y(), m_p2.y()) + 2.0; + } + + const QRectF label = labelRect(); + //labelRect() already carries the nudge of the label, and the + //lines under it are nudged once more further down: what is asked + //for here is where they would stand without it, so that they go + //with the label exactly once rather than twice. + return label.isEmpty() ? qMin(m_p1.y(), m_p2.y()) + : label.bottom() + 2.0 - textOffset().y(); +} + +/** + @brief CablePart::typeRect + The rectangle the type of the cable is written in. It sits under the + trunk line rather than with the rest of the label, so the type reads + as what the line carries while everything above the line says where + the cable belongs -- and it is the first of the lines written under + the line, so the length can stand under it. + @return an empty rectangle when the type is not drawn +*/ +QRectF CablePart::typeRect() const +{ + const QString type = typeText(); + if (type.isEmpty()) { + return QRectF(); + } + + const QFontMetricsF metrics(fontOf(QStringLiteral("type"))); + const qreal width = metrics.horizontalAdvance(type); + const qreal height = metrics.height(); + + return QRectF(textLeft(QStringLiteral("type"), width), underTop(), + width, height).translated(textOffset()); +} + +/** + @brief CablePart::lengthRect + The rectangle the length of the cable is written in. It stands under + the type of the cable, which itself stands straight under the trunk + line: the type is what the cable is, the length is one more thing + said about it. Where the type is not drawn, the length takes its + place and sits straight under the line. + @return an empty rectangle when the length is not drawn +*/ +QRectF CablePart::lengthRect() const +{ + const QString length = lengthText(); + if (length.isEmpty()) { + return QRectF(); + } + + const QFontMetricsF metrics(fontOf(QStringLiteral("length"))); + const qreal width = metrics.horizontalAdvance(length); + const qreal height = metrics.height(); + + //The type may well be written in another font than this, so how + //far down the length hangs has to be measured from the type and + //not from itself. + qreal top = underTop(); + if (!typeText().isEmpty()) { + top += QFontMetricsF(fontOf(QStringLiteral("type"))).height(); + } + + return QRectF(textLeft(QStringLiteral("length"), width), top, + width, height).translated(textOffset()); +} + +/** + @brief CablePart::boundingRect +*/ +QRectF CablePart::boundingRect() const +{ + QRectF rect(QPointF(qMin(m_p1.x(), m_p2.x()), qMin(m_p1.y(), m_p2.y())), + QPointF(qMax(m_p1.x(), m_p2.x()), qMax(m_p1.y(), m_p2.y()))); + rect.adjust(-14.0, -14.0, 14.0, 14.0); + + const QRectF label = labelRect(); + if (!label.isEmpty()) { + rect = rect.united(label); + } + const QRectF type = typeRect(); + if (!type.isEmpty()) { + rect = rect.united(type); + } + const QRectF length = lengthRect(); + if (!length.isEmpty()) { + rect = rect.united(length); + } + for (const LabelLine &line : refLines()) { + rect = rect.united(line.rect); + } + + if (m_cable) + { + for (const CableCore &core : m_cable->usedCores()) + { + if (core.part != m_part_uuid || !coreDrawn(core)) continue; + const QPointF at = (core.core == m_dragged_core) ? m_drag_scene : coreDrawPosition(core); + rect = rect.united(coreLabelRect(at, m_cable->colorOfCore(core.core)) + .adjusted(-3.0, -3.0, 3.0, 3.0)); + } + //The core which is only waiting to be put down is drawn + //too, so its colour has to be inside what is repainted -- + //right from the moment he asks for it, whether the mouse + //already stands at the line or still has to come there + if (m_pending_core >= 0) + { + rect = rect.united(coreLabelRect(m_drag_scene, + m_cable->colorOfCore(m_pending_core)) + .adjusted(-3.0, -3.0, 3.0, 3.0)); + } + } + return rect; +} + +/** + @brief CablePart::coreLabelPivot + The point the colour of a core is written around before being turned + by a quarter: at the left of its slash -- or rather close enough to + the wire that it reads as one thing with it -- and lifted, so its + lower end stops just above the line instead of being cut through by + it. + + Being turned by a quarter, the tail of a letter like "g" points to + the right, which is the side facing the wire: that is what the small + distance kept here has to clear. + @param at where the slash stands, in scene coordinates + @param color the colour being written, e.g. "br" + @return the centre of the text, in scene coordinates -- nudged by + whatever the form offers as "Position des couleurs", which moves the + colours and never the slash: the slash marks where the wire starts, + and that is not for a distance to decide. +*/ +QPointF CablePart::coreLabelPivot(const QPointF &at, const QString &color) const +{ + const QFontMetricsF metrics(coreFont()); + const qreal length = metrics.horizontalAdvance(color); + const qreal thickness = metrics.height(); + + return QPointF(at.x() - core_label_side - thickness * 0.5, + at.y() - core_label_gap - length * 0.5) + coreOffset(); +} + +/** + @brief CablePart::coreLabelRect + The rectangle the colour of a core is written in: the text stands at + the left of its slash and is turned by a quarter, so it runs along + the line instead of across it. + @param at where the slash stands, in scene coordinates + @param color the colour being written, e.g. "br" + @return the rectangle in scene coordinates, empty when there is + nothing to write +*/ +QRectF CablePart::coreLabelRect(const QPointF &at, const QString &color) const +{ + if (color.isEmpty()) return QRectF(); + + const QFontMetricsF metrics(coreFont()); + const qreal width = metrics.horizontalAdvance(color); + const qreal height = metrics.height(); + + //The text is drawn around this point and then turned by -90°, so + //its screen rectangle is height wide and text length tall. + const QPointF pivot = coreLabelPivot(at, color); + return QRectF(pivot.x() - height * 0.5, pivot.y() - width * 0.5, height, width); +} + +/** + @brief CablePart::coreDrawPosition + Where a stored core is drawn right now. While the trunk line is + being dragged the label stands where it stood, carried along by + however far the line has come: the line, the description at its left + and the colours of its cores are one thing and move as one thing, + so no label ever jumps, ever lands on a neighbour's wire or ever + disappears -- and letting go of the line needs no correction. + @param core the stored core + @return where it is drawn at this moment +*/ +QPointF CablePart::coreDrawPosition(const CableCore &core) const +{ + if (m_line_moved) + { + for (const CableCore &dragged : std::as_const(m_line_drag_cores)) + { + if (dragged.core == core.core && dragged.part == core.part) { + return dragged.position; + } + } + } + return core.position; +} + +/** + @brief CablePart::coreDrawn + Says whether a core is drawn at all. While the line is dragged, the + labels are drawn as they stand during that drag -- which is every + one of them: carrying the line along never leaves any of them out. + @param core the stored core + @return true when there is something to draw +*/ +bool CablePart::coreDrawn(const CableCore &core) const +{ + if (!m_line_moved) return true; + + for (const CableCore &dragged : std::as_const(m_line_drag_cores)) + { + if (dragged.core == core.core && dragged.part == core.part) { + return true; + } + } + return false; +} + +/** + @brief CablePart::dragPosition + Where the label of a core being dragged is drawn: still exactly + where it stood, taken along by however far the mouse has come since + it was picked up -- and only by whole grid steps, counted from the + place it stood, the same as anything else drawn on the sheet moves. + + So picking a label up never shifts it by a hair, even when the + mouse closes on it a little to one side, and letting go can never + take it back: what is drawn while he drags is exactly what is kept + when he lets go. A pull shorter than half a grid step moves nothing + at all, which is how every other item on the folio behaves. + + The label follows the line and nothing else: pulling it upwards, + downwards, sideways or diagonally slides it along its own line, + never takes it off it, and never past either end of it -- a colour + label belongs to the line it is written on. Holding Ctrl lets go of + the grid steps, as everywhere else on the sheet. + @param scene_pos where the mouse stands, in scene coordinates + @return where the label is drawn at this moment +*/ +QPointF CablePart::dragPosition(const QPointF &scene_pos) const +{ + //Where it was picked up: still the place the cable says it + //stands, since nothing is written while he drags. + QPointF anchor = m_drag_scene; + if (m_cable && m_dragged_core >= 0) { + const CableCore held = m_cable->core(m_dragged_core); + if (held.core == m_dragged_core) anchor = held.position; + } + + const QPointF axis = m_p2 - m_p1; + const qreal length_squared = axis.x() * axis.x() + axis.y() * axis.y(); + if (qFuzzyIsNull(length_squared)) return anchor; + + const qreal length = std::sqrt(length_squared); + const QPointF shift = scene_pos - m_press_scene; + const qreal along = (shift.x() * axis.x() + shift.y() * axis.y()) / length; + + const qreal stepped = stepAlong(along); + + const QPointF carried = anchor + (axis / length) * stepped; + + //And no further than the line itself: a colour label belongs to + //the line it is written on and stops at either end of it, so it + //goes on standing over a place the line really runs across and + //an entry can always follow it. Where it was picked up lay on + //the line, so this never moves a label which behaves -- it only + //stops one being pulled past the end. + const qreal share = ((carried.x() - m_p1.x()) * axis.x() + + (carried.y() - m_p1.y()) * axis.y()) + / length_squared; + if (share <= 0.0) return m_p1; + if (share >= 1.0) return m_p2; + return m_p1 + axis * share; +} + +/** + @brief CablePart::stepAlong + Snap a distance measured along the line to a grid step, in the + coordinates of the sheet: a horizontal line steps along x, a + vertical one along y, and holding Ctrl lets go of the grid the same + way Diagram::snapToGrid does everywhere else on the sheet. + @param along how far from the first end of the line, in scene units + @return that distance at a grid step +*/ +qreal CablePart::stepAlong(qreal along) const +{ + const QPointF probe = trunkIsHorizontal() ? QPointF(along, 0.0) + : QPointF(0.0, along); + const QPointF stepped_probe = Diagram::snapToGrid(probe); + return trunkIsHorizontal() ? stepped_probe.x() + : stepped_probe.y(); +} + +/** + @brief CablePart::placePosition + Where the mouse would put down a core which is waiting to be put + down: the mouse itself projected onto the line, snapped to a grid + step and stopped at either end of the line, so what the drawing + shows while he aims is exactly what the click leaves behind. + + Unlike dragPosition this asks where the mouse *is* rather than how + far it has come, since nothing is held yet to measure that from. + @param scene_pos where the mouse stands, in scene coordinates + @return where the slash would be drawn +*/ +QPointF CablePart::placePosition(const QPointF &scene_pos) const +{ + const QPointF axis = m_p2 - m_p1; + const qreal length_squared = axis.x() * axis.x() + axis.y() * axis.y(); + if (qFuzzyIsNull(length_squared)) return m_p1; + + const qreal length = std::sqrt(length_squared); + const QPointF shift = scene_pos - m_p1; + const qreal along = (shift.x() * axis.x() + shift.y() * axis.y()) / length; + + //At a grid step like anything else drawn, and never beyond + //either end: a colour label belongs to the line it is written + //on and stops where the line stops. + const qreal stepped = qBound(0.0, stepAlong(along), length); + return m_p1 + (axis / length) * stepped; +} + +/** + @brief CablePart::shape + Only the trunk line, its text and its slashes are clickable, not the + whole rectangle around them: a cable drawn across a dense folio must + not steal the clicks meant for what it passes over. +*/ +QPainterPath CablePart::shape() const +{ + QPainterPath trunk; + trunk.moveTo(m_p1); + trunk.lineTo(m_p2); + + QPainterPathStroker stroker; + stroker.setWidth(10.0); + stroker.setCapStyle(Qt::FlatCap); + + QPainterPath result = stroker.createStroke(trunk); + + const QRectF label = labelRect(); + if (!label.isEmpty()) { + result.addRect(label); + } + const QRectF type = typeRect(); + if (!type.isEmpty()) { + result.addRect(type); + } + const QRectF length = lengthRect(); + if (!length.isEmpty()) { + result.addRect(length); + } + //The references to the other folios stand under those and are + //clicked the same way. + for (const LabelLine &line : refLines()) { + result.addRect(line.rect); + } + + if (m_cable) + { + for (const CableCore &core : m_cable->usedCores()) + { + if (core.part != m_part_uuid || !coreDrawn(core)) continue; + const QPointF at = (core.core == m_dragged_core) ? m_drag_scene : coreDrawPosition(core); + //The colour beside the slash is held by the same grab as + //the slash: it is read far more often than it is moved, + //and clicking the letters has to do what clicking the + //slash does. The label stands to the left of the slash, + //so the grab reaches that way rather than the other. + result.addRect(QRectF(at - QPointF(18.0, slash_grab), + QSizeF(24.0, slash_grab * 2.0))); + const QRectF text = coreLabelRect(at, m_cable->colorOfCore(core.core)); + if (!text.isEmpty()) { + result.addRect(text.adjusted(-3.0, -3.0, 3.0, 3.0)); + } + } + } + + //While the line is picked, its two end points are part of what + //may be clicked: they reach out over the one unit of stroke the + //trunk itself is, and a finger aiming at a blue point never + //lands that exactly. + if (isSelected()) + { + result.addEllipse(m_p1, grip_grab, grip_grab); + result.addEllipse(m_p2, grip_grab, grip_grab); + } + return result; +} + +/** + @brief CablePart::labelAt + Whether the colour label of that core is standing at that point: + either its slash, or the text beside it, both of which read as one + thing with the wire the core names. + + One core at a time, so that the test can never be answered by a + neighbour standing closer to the point -- dropping one colour + straight onto another has to be seen as exactly that. + @param core which core is being asked about + @param local a point of this item, scene coordinates + @return true when the point is on that label +*/ +bool CablePart::labelAt(int core, const QPointF &local) const +{ + if (!m_cable) return false; + + const CableCore at_core = m_cable->core(core); + if (at_core.core != core || at_core.part != m_part_uuid || !coreDrawn(at_core)) { + return false; + } + + const QPointF at = coreDrawPosition(at_core); + if (QLineF(at, local).length() <= slash_grab) return true; + + const QRectF text = coreLabelRect(at, m_cable->colorOfCore(core)); + return !text.isEmpty() && text.adjusted(-3.0, -3.0, 3.0, 3.0).contains(local); +} + +/** + @brief CablePart::coreAt + @param local a point of this item, scene coordinates + @return the core whose slash or colour stands there, -1 when none does +*/ +int CablePart::coreAt(const QPointF &local) const +{ + if (!m_cable) return -1; + + for (const CableCore &core : m_cable->usedCores()) + { + if (core.part != m_part_uuid || !coreDrawn(core)) continue; + if (core.core != m_dragged_core) + { + if (labelAt(core.core, local)) return core.core; + continue; + } + + //The one being dragged is drawn where the mouse holds it + //right now, not where the cable still says it stands + const QPointF at = m_drag_scene; + if (QLineF(at, local).length() <= slash_grab) { + return core.core; + } + const QRectF text = coreLabelRect(at, m_cable->colorOfCore(core.core)); + if (!text.isEmpty() && text.adjusted(-3.0, -3.0, 3.0, 3.0).contains(local)) { + return core.core; + } + } + return -1; +} + +/** + @brief CablePart::drawLabel + @param painter +*/ +void CablePart::drawLabel(QPainter *painter, const QColor &ink) const +{ + if (!m_cable) return; + + const QList lines = labelLines(); + const QRectF type_rect = typeRect(); + const QRectF length_rect = lengthRect(); + const QList ref_lines = refLines(); + if (lines.isEmpty() && type_rect.isEmpty() && length_rect.isEmpty() + && ref_lines.isEmpty()) return; + + painter->save(); + painter->setPen(ink); + + //Every line brings the font it is written with: they are not + //measured with the same yard, so they may not be written with + //one either. + for (const LabelLine &line : lines) + { + painter->setFont(line.font); + painter->drawText(line.rect, line.align | Qt::AlignVCenter, line.text); + } + if (!type_rect.isEmpty()) { + painter->setFont(fontOf(QStringLiteral("type"))); + painter->drawText(type_rect, alignOf(QStringLiteral("type")) | Qt::AlignVCenter, + typeText()); + } + if (!length_rect.isEmpty()) { + painter->setFont(fontOf(QStringLiteral("length"))); + painter->drawText(length_rect, alignOf(QStringLiteral("length")) | Qt::AlignVCenter, + lengthText()); + } + //Last of all, at the very bottom: which other folios this cable + //runs on, one line each. + for (const LabelLine &line : ref_lines) + { + painter->setFont(line.font); + painter->drawText(line.rect, line.align | Qt::AlignVCenter, line.text); + } + painter->restore(); +} + +/** + @brief CablePart::drawCores + One slash per core the cable really wires, with the colour of that + core beside it -- the same colour which is written into the cable + field of the conductor underneath. Plus, while a core is waiting to + be put down, a paler copy of the very same marks wherever the mouse + stands near the line. + @param painter +*/ +void CablePart::drawCores(QPainter *painter, const QColor &ink) const +{ + if (!m_cable) return; + + for (const CableCore &core : m_cable->usedCores()) + { + if (core.part != m_part_uuid || !coreDrawn(core)) continue; + + const bool dragged = (core.core == m_dragged_core); + const QPointF at = dragged ? m_drag_scene : coreDrawPosition(core); + + drawCoreMark(painter, ink, at, m_cable->colorOfCore(core.core)); + } + + //A core which is waiting to be put down shows where it would + //land: the same slash and the same colour as any other, only + //paler, since nothing of it is written yet. It shows as soon as + //he asks for the core, and not only once the mouse has come + //near enough -- he picks it out of a list, and the line may well + //be across the screen from where that list stands. + if (m_pending_core >= 0) + { + painter->save(); + painter->setOpacity(painter->opacity() * 0.6); + drawCoreMark(painter, ink, m_drag_scene, + m_cable->colorOfCore(m_pending_core)); + painter->restore(); + } +} + +/** + @brief CablePart::drawCoreMark + One slash, with the colour of the core it marks written beside it: + the colour stands at the left of its slash and is turned by a + quarter, the way a measurement is written along the line it belongs + to. The same drawing serves a core which is already on the sheet and + one which is only being aimed at. + @param painter + @param ink the colour everything of this line is drawn with + @param at where the slash stands, in scene coordinates + @param color the colour of that core, e.g. "br" +*/ +void CablePart::drawCoreMark(QPainter *painter, const QColor &ink, + const QPointF &at, const QString &color) const +{ + const QFont core_font = coreFont(); + const QFontMetricsF metrics(core_font); + + painter->save(); + painter->setFont(core_font); + painter->setPen(QPen(ink, 1.0)); + painter->drawLine(QPointF(at.x() - slash_half, at.y() + slash_half), + QPointF(at.x() + slash_half, at.y() - slash_half)); + + if (!color.isEmpty()) + { + const qreal width = metrics.horizontalAdvance(color); + const qreal height = metrics.height(); + const QPointF pivot = coreLabelPivot(at, color); + + painter->save(); + painter->translate(pivot); + painter->rotate(-90.0); + painter->drawText(QRectF(-width * 0.5, -height * 0.5, width, height), + Qt::AlignCenter, color); + painter->restore(); + } + painter->restore(); +} + +/** + @brief CablePart::paint + @param painter + @param option + @param widget +*/ +void CablePart::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) +{ + Q_UNUSED(option) + Q_UNUSED(widget) + + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setFont(textFont()); + + // The folio is painted into a buffer whose lightness is inverted to the + // palette afterwards (PaletteGraphicsView): what is black in the buffer + // comes out as the Text color, what is white comes out as the sheet. + // The palette of the option, on the other hand, is the palette of the + // view's widgets, so it names colors which are already final and land + // on the sheet color -- an ink taken from it vanishes. Conductors are + // drawn the same way: black when they stand, red when they are picked. + const QColor ink = isSelected() ? Qt::red : Qt::black; + + //One unit, like every other line of the drawing: a cable trunk is + //a line, not a highlight, and it has to line up with the wires it + //runs across rather than stand out over them. + QPen pen(ink, 1.0); + pen.setCapStyle(Qt::FlatCap); + pen.setJoinStyle(Qt::MiterJoin); + painter->setPen(pen); + painter->drawLine(m_p1, m_p2); + + drawLabel(painter, ink); + + drawCores(painter, ink); + + //The two points every line of the drawing shows once it is + //picked: one at each end, in the same blue QET's own handles + //use. Pulling one of them changes how long the line is, the + //same way an end of a conductor is pulled (mousePressEvent + //and mouseMoveEvent). They belong to the line and are painted + //by it, so nothing else has to be put into the folio for them. + if (isSelected()) + { + painter->save(); + painter->setPen(Qt::NoPen); + painter->setBrush(Qt::blue); + painter->drawEllipse(m_p1, grip_radius, grip_radius); + painter->drawEllipse(m_p2, grip_radius, grip_radius); + painter->restore(); + } +} + +/** + @brief CablePart::name + @return the cable number, so the cable shows under its own name in + every list which names items +*/ +QString CablePart::name() const +{ + return m_cable ? m_cable->designation() : QString(); +} + +/** + @brief CablePart::syncGeometry + Tell the cable where this section runs. The cable is the one place + the drawing is read back from, so nothing has to be collected from + the folios at saving time. +*/ +void CablePart::syncGeometry() +{ + if (!m_cable) return; + m_cable->setPartGeometry(m_part_uuid, m_p1, m_p2); +} + +/** + @brief CablePart::mousePressEvent + While a core waits to be put down, the click belongs to that: on the + line -- or near enough for the mouse to be pointing at it -- the core + is left standing there, right click calls it off, and a click anywhere + else calls it off as well and goes on doing what it does for everybody + (left clicking on a slash or on its colour holds that core, on one of + the two blue points at the ends it holds that end, anywhere else on + the line it moves the line). + + Otherwise: left clicking on a slash or on its colour holds that core: + it can be dragged onto another conductor to give it that conductor. + + On one of the two blue points at the ends of a picked line it holds + that end instead, so the line can be made longer or shorter the same + way an end of a conductor is pulled (mouseMoveEvent). + + Left clicking anywhere else on the line moves the line. + @param event +*/ +void CablePart::mousePressEvent(QGraphicsSceneMouseEvent *event) +{ + //A core which waits to be put down takes the whole click: on + //the line it is left standing there, anywhere else -- and on + //the right button, which cancels everywhere on the sheet -- + //nothing is put down. The click itself goes on doing whatever + //it does for everybody else. + if (m_pending_core >= 0) + { + if (!m_cable) + { + cancelPlacement(); + event->ignore(); + return; + } + //Where he aims is read off the click itself as well, so a + //dialog closing with the mouse already over the line needs + //no move first and a stale position is never taken for a + //place he never pointed at. + m_drag_scene = placePosition(event->scenePos()); + m_pending_valid = QLineF(event->scenePos(), m_drag_scene).length() + <= place_grab; + + if (event->button() == Qt::LeftButton && m_pending_valid) + { + const int core = m_pending_core; + const QPointF at = m_drag_scene; + cancelPlacement(); + m_just_placed = placeCore(core, at); + update(); + event->accept(); + return; + } + const bool cancel = (event->button() == Qt::RightButton); + cancelPlacement(); + if (cancel) { + event->accept(); + } else { + event->ignore(); + } + return; + } + + //The second of two quick clicks arrives as a double click: by + //then the first one has already put a core down, and that must + //not read as a request to open the dialog a second time. + m_just_placed = false; + + if (event->button() != Qt::LeftButton) { + QetGraphicsItem::mousePressEvent(event); + return; + } + + m_press_scene = event->scenePos(); + m_move_p1 = m_p1; + m_move_p2 = m_p2; + m_line_moved = false; + m_line_drag_cores.clear(); + //Nothing is carried along until he picks up a whole line: a + //press on a colour or on one of the blue points works on that + //one thing and leaves every other line standing. + m_carried.clear(); + //Where every colour label stands when the gesture starts: a + //line which is moved carries all of them along with it, and + //undo has to know where they were before that. A line which is + //stretched does not touch them, and records the same set + //unchanged. + m_cores_before = m_cable ? m_cable->usedCores() : QList(); + //Whether the sheet has already been told that the rest of the + //selection comes along with this line (see carrySelection): every + //press starts from nothing, so the next gesture tells it again. + m_mover_started = false; + m_told_mover = QPointF(); + + //One of the two blue points at the ends, when the line is + //picked and only one of the two is under the finger: pulling + //it changes how long the line is. The line comes before + //whatever stands on it: a colour label parked right at the end + //may not take that point away, since the line could not be + //made longer from that end any more -- the labels would have + //to be pushed aside first, and there is nothing to push them + //with. The label is still picked up beside the point, by its + //letters or by its slash a little further along the line. + //When both ends are that close, which only happens on a very + //short line, nothing is stretched: it is moved like any other + //line instead. + int grip = 0; + if (isSelected()) + { + const bool first = QLineF(event->scenePos(), m_p1).length() <= grip_grab; + const bool second = QLineF(event->scenePos(), m_p2).length() <= grip_grab; + if (first != second) grip = first ? 1 : 2; + } + m_stretch_end = grip; + + if (grip > 0) + { + update(); + event->accept(); + return; + } + + const int core = coreAt(event->scenePos()); + if (core >= 0) + { + m_dragged_core = core; + //The label stays exactly where it stands until the mouse + //really moves: picking it up must not shift it by a hair. + m_drag_scene = m_cable->core(core).position; + if (!isSelected()) { + setSelected(true); + } + update(); + event->accept(); + return; + } + + QetGraphicsItem::mousePressEvent(event); + + //Every other line he has picked up at the same time: dragging + //one line takes the whole selection with it, and all of them go + //back to where they stood if he calls the gesture off. The + //selection is read after the click has been dealt with, since + //clicking an unselected line leaves that one alone selected. + m_carried.clear(); + if (isSelected() && scene()) + { + const QList selected = scene()->selectedItems(); + for (QGraphicsItem *item : selected) + { + auto *other = qgraphicsitem_cast(item); + if (!other || other == this || !other->m_cable) continue; + + CarriedLine carried; + carried.part = other; + carried.p1 = other->m_p1; + carried.p2 = other->m_p2; + carried.cores = other->m_cable->usedCores(); + m_carried.append(carried); + } + } +} + +/** + @brief CablePart::mouseMoveEvent + Three things the mouse can carry: the colour of a core (which stays + on its line and never past either end of it), one of the two blue + points at the ends (which makes the line longer or shorter -- that + changes nothing else: every colour label goes on standing exactly + where the user put it, and the line may not be brought in past the + outermost of those labels, which would otherwise be left standing + beyond the end of their own line), and the whole line (which is + translated as a piece, taking its colour labels with it). The line + in each case is snapped to the grid. + @param event +*/ +void CablePart::mouseMoveEvent(QGraphicsSceneMouseEvent *event) +{ + //Where a core waiting to be put down would stand: on the line + //itself, at a grid step, and drawn from the very first moment + //it is asked for -- how near the mouse stands decides nothing + //but whether a click keeps it or sends it away (see + //mousePressEvent), never whether he can see it. + if (m_pending_core >= 0) + { + if (!m_cable) { + cancelPlacement(); + } else { + m_drag_scene = placePosition(event->scenePos()); + update(); + } + event->accept(); + return; + } + + if (m_dragged_core >= 0) + { + m_drag_scene = dragPosition(event->scenePos()); + update(); + event->accept(); + return; + } + + if (m_stretch_end > 0 && (event->buttons() & Qt::LeftButton)) + { + //The end under the finger follows the mouse, snapped to the + //grid like anything else drawn on the folio, and the line + //keeps its right angle: the direction the mouse covers the + //most decides which way the line runs, the same way the + //drawing tool decides it -- so pulling the end of a + //horizontal line far enough upwards turns the line + //vertical instead of hanging it into the air at an angle. + const QPointF fixed = (m_stretch_end == 1) ? m_move_p2 : m_move_p1; + QPointF moving = Diagram::snapToGrid(event->scenePos()); + const QPointF to = moving - fixed; + + bool horizontal; + if (qAbs(to.x()) > qAbs(to.y())) horizontal = true; + else if (qAbs(to.y()) > qAbs(to.x())) horizontal = false; + else horizontal = trunkIsHorizontal(); + moving = horizontal ? QPointF(moving.x(), fixed.y()) + : QPointF(fixed.x(), moving.y()); + + //Never shorter than his own colour labels: every one of + //them which stood on this part of the line when the + //gesture began has to go on standing on it, or it would be + //left beyond the end of the line it belongs to and could + //no longer be wired. He may pull the line out as far as he + //likes, and he may bring either end in as far as he likes + //up to the outermost of those labels -- which are found + //along the line as it runs now, since the direction can + //change from one move to the next. + const QPointF started_from = (m_stretch_end == 1) ? m_move_p1 : m_move_p2; + const qreal at_fixed = horizontal ? fixed.x() : fixed.y(); + const qreal at_start = horizontal ? started_from.x() : started_from.y(); + const qreal first = qMin(at_fixed, at_start); + const qreal last = qMax(at_fixed, at_start); + + bool any_label = false; + qreal low = at_fixed, high = at_fixed; + for (const CableCore &core : m_cores_before) + { + if (core.part != m_part_uuid) continue; + + const qreal at = horizontal ? core.position.x() : core.position.y(); + if (at < first || at > last) continue; + + if (!any_label) { low = high = at; any_label = true; } + else { low = qMin(low, at); high = qMax(high, at); } + } + + if (any_label) + { + //The end sits on the grid, so the limit it stops + //against is taken from it on the far side of the + //label, never in front of it. + QSettings settings; + const int step = horizontal + ? settings.value(QStringLiteral("diagrameditor/Xgrid"), + Diagram::xGrid).toInt() + : settings.value(QStringLiteral("diagrameditor/Ygrid"), + Diagram::yGrid).toInt(); + if (step > 0) { + high = std::ceil(high / step) * step; + low = std::floor(low / step) * step; + } + + qreal at_end = horizontal ? moving.x() : moving.y(); + at_end = at_end > at_fixed ? qMax(at_end, high) + : qMin(at_end, low); + moving = horizontal ? QPointF(at_end, moving.y()) + : QPointF(moving.x(), at_end); + } + + const QPointF p1 = (m_stretch_end == 1) ? moving : fixed; + const QPointF p2 = (m_stretch_end == 1) ? fixed : moving; + + if (p1 != m_p1 || p2 != m_p2) + { + setLine(p1, p2); + m_line_moved = true; + //Only the line is stretched: every colour label goes on + //standing exactly where the user left it, none is + //carried anywhere and none is worked out again. What + //the drawing showed of them before the gesture goes on + //being what the cable holds -- which is what the + //release below records, along with the line. + m_line_drag_cores = m_cores_before; + update(); + } + event->accept(); + return; + } + + if (isSelected() && event->buttons() & Qt::LeftButton) + { + //The line follows the mouse, snapped to the grid like anything + //else drawn on the folio, and keeps its length: it is purely + //optical, so nothing else has to follow it. + const QPointF target = Diagram::snapToGrid( + m_move_p1 + (event->scenePos() - m_press_scene)); + const QPointF delta = target - m_move_p1; + + if (target != m_p1 || m_move_p2 + delta != m_p2) + { + setLine(target, m_move_p2 + delta); + m_line_moved = true; + //Where every colour label stands on the line at this + //moment: carried along by however far the line has + //come, so line, description and colours move as one + //piece and no label ever jumps or disappears. + m_line_drag_cores = m_cores_before; + reconcileCores(m_line_drag_cores, delta); + update(); + } + //Every other line he picked up with this one comes along, + //on every step of the gesture and with the same step: a + //selection of lines is one thing to move. It happens even + //when the line under his finger itself stands still -- a + //pull back to where it began -- or those would be left + //standing out where the gesture reached. + carryCompanions(delta); + //Everything else he marked at the same time comes along too + //-- the elements and the texts of the same selection -- so + //that one gesture moves all of it rather than two halves of + //the selection going different ways. + carrySelection(delta); + event->accept(); + return; + } + + QetGraphicsItem::mouseMoveEvent(event); +} + +/** + @brief CablePart::mouseReleaseEvent + @param event +*/ +void CablePart::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) +{ + //Nothing has been put down by this gesture, so nothing has to + //be settled either: the core is placed by the press itself. + if (m_pending_core >= 0) + { + event->accept(); + return; + } + + + if (m_dragged_core >= 0 && event->button() == Qt::LeftButton) + { + const int core = m_dragged_core; + m_dragged_core = -1; + + //What was drawn while he dragged is what is kept: the label + //goes on standing where he left it, and letting go can never + //take it back to where it was picked up. A press which never + //went anywhere is a click: it keeps the label exactly where + //it already stands and never makes it name some other wire + //for having been touched. + const CableCore held = m_cable ? m_cable->core(core) : CableCore(); + const bool moved_label = (held.core == core) && (m_drag_scene != held.position); + + if (moved_label) { + rebindCore(core, m_drag_scene); + } + update(); + event->accept(); + return; + } + + //The steps of this line, held back until the gesture is over: + //when the sheet is moving elements together with the lines, its + //own undo waits for them so that one single undo step takes all + //of it back. + QList pending; + bool cancelled = false; + + if (event->button() == Qt::LeftButton && m_line_moved && diagram()) + { + const QPointF after_p1 = m_p1; + const QPointF after_p2 = m_p2; + //One undo step for one gesture: taking the drag back puts + //every line of it exactly where it started -- a line which + //moved takes its colour labels back with it, since they are + //written on that line, and one which was stretched leaves + //them exactly where he put them. + if (after_p1 != m_move_p1 || after_p2 != m_move_p2) + { + QList after = m_line_drag_cores; + + //Stretching only changes how long the line is: no + //colour label has been carried anywhere, so nothing of + //the cable has to be worked out again, no wire changes + //hands and nothing at all is asked. One step which puts + //the line -- and only the line -- back where it was. + if (m_stretch_end > 0) + { + pending.append( + new MoveCablePartCommand(m_cable, this, + m_move_p1, m_move_p2, + after_p1, after_p2, + m_cores_before, after)); + } + else + { + //The lines he picked up with this one settle first, + //one after the other: a wire one of them lets go of + //is then already free for the next one instead of + //being asked about as if it were still taken. Each + //of them asks before taking anything away from a + //cable which is not part of this gesture. + QList steps; + cancelled = !settleCompanions(steps); + + //What the drawing already showed during the drag goes + //into the cable now -- and which wire each core names + //is worked out at this moment, and only at it, from + //where its colour label has come to rest: that is + //what makes the entries of the wires right again + //after a move, instead of leaving them standing on + //the wires the line has gone away from. No label is + //moved for it. + if (!cancelled) + { + readyToSettle(after); + cancelled = !settleCores(after); + } + + if (cancelled) + { + //He called the whole gesture off: every line of + //it goes back to where he picked it up, nothing + //at all is written, and there is no step to undo + //-- the drawing is as it was before he started. + putCompanionsBack(); + if (m_cable) { + m_cable->setCores(m_cores_before); + } + setLine(m_move_p1, m_move_p2); + for (QUndoCommand *step : steps) { + delete step; + } + } + else + { + if (m_cable) { + m_cable->setCores(after); + } + steps.append(new MoveCablePartCommand(m_cable, this, + m_move_p1, m_move_p2, + after_p1, after_p2, + m_cores_before, after)); + //Given to the whole gesture rather than pushed + //here, since the sheet may still have the rest + //of the selection to write down with it. + pending = steps; + steps.clear(); + } + } + } + } + + //The lines he picked up with this one go on standing where the + //gesture left them, and none of them is carried any more -- + //whether it was written down or called off. + for (const CarriedLine &carried : std::as_const(m_carried)) + { + if (!carried.part) continue; + carried.part->m_line_moved = false; + carried.part->m_line_drag_cores.clear(); + carried.part->update(); + } + m_carried.clear(); + + m_line_moved = false; + m_stretch_end = 0; + m_line_drag_cores.clear(); + + //One step for one gesture, however mixed the selection is: when + //the sheet is moving the elements he marked together with these + //lines, its own undo waits for the steps above and pushes + //everything at once, so a single undo takes the whole selection + //back. When nothing else was marked they are pushed here exactly + //as they always were -- and he called the gesture off when + //nothing at all is written, the elements included. + if (diagram()) + { + ElementsMover &mover = diagram()->elementsMover(); + if (cancelled) + { + qDeleteAll(pending); + pending.clear(); + mover.cancelMovement(); + } + else if (!pending.isEmpty() && !mover.isReady()) + { + mover.addExtraCommands(pending); + pending.clear(); + } + else if (pending.size() == 1) + { + diagram()->undoStack().push(pending.takeFirst()); + } + else if (!pending.isEmpty()) + { + //One step however many lines he moved: taking the + //gesture back has to take all of it back, or half the + //selection would stay where the drag left it. + diagram()->undoStack().push(new BatchCommand( + pending, + QCoreApplication::translate( + "BatchCommand", "Move %n cable(s)", + "Un geste déplace plusieurs lignes à la fois.", + pending.size()))); + pending.clear(); + } + } + else + { + qDeleteAll(pending); + pending.clear(); + } + + QetGraphicsItem::mouseReleaseEvent(event); + + //The sheet has been told everything this gesture had to tell it: + //the next press starts from nothing again (see carrySelection). + m_mover_started = false; + m_told_mover = QPointF(); +} + +/** + @brief CablePart::mouseDoubleClickEvent + Opening the cable is what a double click on the line is for. On one + of the colour labels it does nothing: they are part of a core, and + the user asking for the whole cable by clicking twice in the same + place never said so. On a line which points at another folio it goes + to that folio instead: the reference leads somewhere, and asking + twice in it is asking to be taken there. + @param event +*/ +void CablePart::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) +{ + //The click which put a core down is reported once more as a + //double click when he clicks twice fast: it was there to place + //something and must not go on to open the dialog. + if (m_just_placed) + { + m_just_placed = false; + event->accept(); + return; + } + + if (event->button() == Qt::LeftButton && coreAt(event->scenePos()) >= 0) + { + event->accept(); + return; + } + + //A line which points at another folio leads there: asking twice + //in that very line is asking to be taken to the section standing + //over there, the way a cross reference of an element does, rather + //than to open this cable. + if (event->button() == Qt::LeftButton && goToCrossRef(event->scenePos())) + { + event->accept(); + return; + } + QetGraphicsItem::mouseDoubleClickEvent(event); +} + +/** + @brief CablePart::hoverMoveEvent + The hand over a line which points at another folio: it says the line + leads somewhere before he clicks and finds out. While a core waits to + be put down the cross of that gesture is showing, and it stays. + @param event +*/ +void CablePart::hoverMoveEvent(QGraphicsSceneHoverEvent *event) +{ + QetGraphicsItem::hoverMoveEvent(event); + + if (m_pending_core >= 0) return; + + if (crossRefAt(event->scenePos()) >= 0) { + setCursor(Qt::PointingHandCursor); + } else { + unsetCursor(); + } +} + +/** + @brief CablePart::rebindCore + Let go of a core the user dragged: it lands where he let it go, and + whether it takes the wire standing there is decided by what he did + rather than by anything being looked up for him. + + Nothing here ever moves the label to reach a wire. It goes on + standing at the place he dropped it -- on the line, at the grid, + the very place it has been drawn for every moment of the drag -- so + that a label never crawls back to the wire he pulled it away from + and never goes off to look for one of its own. What changes, if + anything, is which wire names it: + + - On a wire this line runs across: the core takes it, and the + field of the wire it left is emptied by the refresh which + follows. + - On a wire another cable holds: he is asked about it first. Yes + hands the wire over, and the other cable's core becomes a free + slot of that cable -- its slash and its colour leave the sheet + with it, which is what he saw happen. No leaves the other cable + exactly as it was and makes this core the free one instead: it + names no wire at all any more, so its own slash and colour go off + the sheet as well, its entry leaves the wire it came from, and it + waits as a free slot in the list of existing cables until he puts + it back. + - Directly on the colour label of another core of the same cable: + the two exchange their wires, which is a deliberate exchange -- + he put one colour on top of the other, so he means the second one + to take what the first is giving up. + - On a wire another core of this same cable already holds, anywhere + but on its label: nothing changes. + - On nothing at all: the label stays exactly where he dropped it, + and it stops naming a wire -- an entry follows its label and + stops where its label stopped, so that no entry is left standing + on a wire the label has gone away from. A label which has not + been moved keeps what it had, though: picking one up and letting + it go again leaves the drawing exactly as it was. + + Only the label he dragged may move: no other core of this cable is + hunted down. A core is taken away from its own cable at one single + place -- when he says no to the wire he has dropped its label on, + so that it names nothing -- and that is the very thing the other + cable's core becomes when he says yes to taking its wire. Both + answers therefore leave the drawing the same way: a free slot, no + slash, no colour, and the entry gone from the wire it stood on. + + Whatever comes out of it is kept as one undo step, the whole set of + assignments before and after the gesture. + @param core which core is being moved + @param dropped_at scene coordinates where the slash was let go + @param before_cores the set of assignments as it stood before the + core was added to the cable, when a core is being put down rather + than moved; nullptr otherwise +*/ +bool CablePart::rebindCore(int core, const QPointF &dropped_at, + const QList *before_cores) +{ + if (!m_cable || !diagram() || !diagram()->project()) { + return true; + } + + const CableCore moved = m_cable->core(core); + if (moved.core != core) { + return true; + } + + //What the cable held before this gesture, so undo can put it + //back: the set as it stands while a core already on the sheet + //is merely dragged, or -- when a core is being put down for the + //first time -- the set from before it was added, which takes + //the addition back as well. + const QList before = before_cores ? *before_cores + : m_cable->usedCores(); + + //Where the label has actually been drawn: on the line, at whole + //grid steps from where it stood. It is taken as it is and never + //worked out again, so letting go cannot shift it by a hair. + const QPointF at = dropped_at; + CableCore landed = moved; + landed.position = at; + + //Which wire of this line stands at that very place, worked out + //from where the label stands and never the other way round: + //the label is not sent off to look for a wire, it names the + //one it happens to be standing on. + const QList crossings = CableManager::crossedConductors( + diagram(), QLineF(m_p1, m_p2)); + Conductor *target = wireFor(crossings, moved, at); + + //Set when he refuses a wire another cable holds: this core then + //names no wire at all any more and goes back to being a free + //slot of the cable, so it is taken out rather than written. + bool freed = false; + + if (target) + { + //Which core of this cable, if any, already stands on that + //wire -- everything but the one being dragged. + int holder = -1; + for (const CableCore &other : m_cable->usedCores()) + { + if (other.core == core || other.part != m_part_uuid) continue; + if (other.conductor == target->uuid()) { + holder = other.core; + } + } + + if (holder < 0) + { + const ClaimAnswer answer = mayClaim(target); + if (answer == ClaimAnswer::Cancel) + { + //He called the drop off: the label goes on standing + //where he picked it up, and nothing is written at all. + return false; + } + if (answer == ClaimAnswer::Take) + { + //A wire is one piece of wire: whichever cable held it + //before has to let it go. + CableManager::claimConductor(diagram()->project(), target, m_cable.data()); + + //A conductor keeps the identity a cable binds it by, even + //when it was read back from a file written before it had + //one. + target->setUuid(target->uuid()); + + landed.conductor = target->uuid(); + } + else + { + //He said no: the other cable goes on holding that + //wire, and this label has come to rest on it anyway. + //So this core names no wire at all any more and goes + //back to being a free slot of this cable -- exactly + //what the other cable's core becomes when he says + //yes -- taking its slash and its colour off the sheet + //with it, while its entry leaves the wire the label + //has gone away from. Only the other cable keeps its + //wire, and he can put this core back wherever he + //likes from the list of existing cables. + freed = true; + } + } + else if (labelAt(holder, at)) + { + //One colour dropped straight onto another: the two + //exchange their wires, so neither is left standing on + //a wire the drawing does not name with it. The dragged + //label stays where he put it, on the wire it has just + //taken, and the other one comes to the place it gives + //up -- that exchange is what he asked for. + CableCore swapped = m_cable->core(holder); + if (swapped.core == holder) + { + CableManager::claimConductor(diagram()->project(), target, m_cable.data()); + target->setUuid(target->uuid()); + + landed.conductor = target->uuid(); + + swapped.conductor = moved.conductor; + swapped.position = moved.position; + m_cable->setCore(swapped); + } + } + //Held by another core of this cable but not dropped on its + //label: leave everything alone, the dragged label merely + //stays where it was let go. + } + else if (at != moved.position && !moved.conductor.isNull()) + { + //He let go where this line runs across nothing, having + //really brought the label there: the entry of the wire it + //came from goes with it, while the label itself stays + //exactly where he put it and merely names no wire at all + //any more. Not moved means not touched at all, so picking + //a label up and letting it go again never empties + //anything. + landed.conductor = QUuid(); + } + + if (freed) + { + //No wire names this core any more: it goes back to being a + //free slot of the cable and stops being drawn, and the + //refresh which follows empties the cable field of the wire + //it used to name -- an entry follows its label, and this + //label has come to rest where it may not name anything. + m_cable->removeCore(core); + } + else + { + + if (landed != moved) { + m_cable->setCore(landed); + } + } + + const QList after = m_cable->usedCores(); + if (after != before) { + //A core which has just been put down for the first time is + //not being moved, and the step which takes it back is named + //for what it did rather than for where it went. + const QString text = before_cores + ? QCoreApplication::translate("ChangeCableCoresCommand", + "Place a cable core") + : QString(); + diagram()->undoStack().push( + new ChangeCableCoresCommand(m_cable, before, after, text)); + } + return true; +} + +/** + @brief CablePart::takeCoreOffLine + Take one core off this very line: its colour label disappears from + the sheet and the core goes back to being a free slot of its cable, + listed among the ones which stand nowhere -- the exact opposite of + putting one down from that list. + + Only a core standing on this line is taken off, never one of the + other sections of the same cable: a cable running over two folios + stands on two lines, and the one whose menu was asked for is the one + he is working on. + + Whatever it named goes with it: the refresh which follows empties + the cable field of the wire it used to name, since an entry follows + the label it stands for, and this label no longer stands anywhere. + + One undo step, and a single undo puts the label back exactly where + it stood. + @param core which core standing on this line is taken off + @return true when a core was taken off +*/ +bool CablePart::takeCoreOffLine(int core) +{ + if (!m_cable || !diagram() || !diagram()->project()) return false; + + const CableCore there = m_cable->core(core); + if (there.core != core || there.part != m_part_uuid) return false; + + + const QList before = m_cable->usedCores(); + m_cable->removeCore(core); + const QList after = m_cable->usedCores(); + if (after == before) return false; + + diagram()->undoStack().push(new ChangeCableCoresCommand( + m_cable, before, after, + QCoreApplication::translate("ChangeCableCoresCommand", + "Remove a core from the line"))); + return true; +} + +/** + @brief CablePart::reconcileCores + Where the colour labels of this line stand while the line is being + moved: exactly where they stood, taken along by however far the line + has come. + + Every label is carried along on every single step of the gesture -- + the trunk line, the description above it and the colour labels of the + cores are one thing and move as one thing. Nothing is worked out + again while he drags, so no label ever jumps, ever lands on a + neighbour's wire or ever disappears because the line came a little + too short: pulling the line back puts every label exactly back where + it was. + + The place a label belongs to is worked out afterwards, when the + line is let go (settleCores), and while a single core is moved on + its own -- gestures which are about one wire rather than about the + whole line, and which read the wire off the place of the label + rather than sending the label off to find one. + @param cores the whole set of assignments, changed in place + @param delta how far the line has come in this gesture +*/ +void CablePart::reconcileCores(QList &cores, const QPointF &delta) const +{ + for (CableCore &core : cores) + { + if (core.part != m_part_uuid) continue; + + core.position += delta; + } +} + +/** + @brief CablePart::carryCompanions + Every other line he picked up with this one comes along, with the + same step and with the colours of its own cores: a selection of + lines is one thing to move. + + Nothing of them is written while he drags -- they are drawn where + the gesture has brought them, exactly the way this line draws its + own labels, so pulling back puts every one of them back where it + was and letting go works the entries out afterwards. + @param delta how far the line under his finger has come +*/ +void CablePart::carryCompanions(const QPointF &delta) +{ + for (const CarriedLine &carried : std::as_const(m_carried)) + { + CablePart *other = carried.part; + if (!other) continue; + + other->setLine(carried.p1 + delta, carried.p2 + delta); + other->m_line_moved = true; + other->m_line_drag_cores = carried.cores; + other->reconcileCores(other->m_line_drag_cores, delta); + other->update(); + } +} + +/** + @brief CablePart::carrySelection + Everything else he marked at the same time comes along too: the + elements, the texts and the images of the same selection are moved + by the very step this line has taken, so that one gesture moves + whatever the selection is made of instead of two halves of it going + different ways -- the sheet has its own way of moving those, and + this is the only place which knows how far the lines have come. + + The step handed over is the part of the gesture the sheet has not + been told yet: what this line measures is how far it has come all in + altogether, from where the press found it, while the sheet takes one + step at a time. + @param total_delta how far this line has come since the press +*/ +void CablePart::carrySelection(const QPointF &total_delta) +{ + if (!diagram()) return; + + ElementsMover &mover = diagram()->elementsMover(); + if (!m_mover_started) + { + //Once per gesture rather than once per step: when nothing + //but lines is marked the sheet has nothing to move of its + //own, and must not be asked to start over at every step. + m_mover_started = true; + m_told_mover = QPointF(); + mover.beginMovement(diagram(), this); + } + + const QPointF step = total_delta - m_told_mover; + m_told_mover = total_delta; + if (!step.isNull()) { + mover.continueMovement(step); + } +} + +/** + @brief CablePart::settleCompanions + Where every other line of this gesture comes to rest, worked out + exactly the way this line's own is: from where its colour labels + have come to stand, asking before anything changes hands. + + The lines are settled one after the other rather than all at once, + so that a wire one of them lets go of is already free for the next + one instead of being asked about as if it were still taken. + @param steps receives one undo step per line he let go of + @return false when he cancelled one of their questions, in which + case nothing at all has to be written and the caller puts every + line back where it was picked up +*/ +bool CablePart::settleCompanions(QList &steps) +{ + for (const CarriedLine &carried : std::as_const(m_carried)) + { + CablePart *other = carried.part; + if (!other || !other->m_cable) continue; + + QList after = other->m_line_drag_cores; + other->readyToSettle(after); + + if (!other->settleCores(after)) { + return false; + } + + other->m_cable->setCores(after); + steps.append(new MoveCablePartCommand(other->m_cable, other, + carried.p1, carried.p2, + other->m_p1, other->m_p2, + carried.cores, after)); + } + return true; +} + +/** + @brief CablePart::putCompanionsBack + Put every other line of this gesture back to where he picked it + up, cores included: the wire fields are rewritten along the way, + so that not one entry of the refused move is left standing. +*/ +void CablePart::putCompanionsBack() +{ + for (const CarriedLine &carried : std::as_const(m_carried)) + { + CablePart *other = carried.part; + if (!other) continue; + + other->setLine(carried.p1, carried.p2); + if (other->m_cable) { + other->m_cable->setCores(carried.cores); + } + other->m_line_moved = false; + other->m_line_drag_cores.clear(); + other->update(); + } +} + +/** + @brief CablePart::beginLineGesture + Where this line stood when the sheet began moving the selection it + is part of, kept here so that every step of that movement can carry + the line -- and its colour labels, which are written on it -- along + with it, exactly as if the finger had been on the line itself. + + Nothing is carried along by this line: the sheet carries every line + of the selection itself, one after the other, so no line takes its + neighbours along a second time. +*/ +void CablePart::beginLineGesture() +{ + m_move_p1 = m_p1; + m_move_p2 = m_p2; + m_line_moved = false; + m_line_drag_cores.clear(); + m_carried.clear(); + m_cores_before = m_cable ? m_cable->usedCores() : QList(); + //Where its own labels stand when the gesture begins: they go on + //standing wherever the line is carried to, and undo has to know + //where they were before that. + m_line_drag_cores = m_cores_before; + m_stretch_end = 0; + m_dragged_core = -1; +} + +/** + @brief CablePart::continueLineGesture + Take one more step of a move the sheet is driving: the line follows + by that much and every colour label standing on it comes along with + it, so line, description and colours move as one piece and no label + ever jumps, lands on a neighbour's wire or disappears under his + hand. + @param delta how far to come, in scene units +*/ +void CablePart::continueLineGesture(const QPointF &delta) +{ + if (!m_cable || delta.isNull()) return; + + setLine(m_p1 + delta, m_p2 + delta); + m_line_moved = true; + //Step by step rather than measured from where the gesture + //began: the sheet hands one step over at a time + reconcileCores(m_line_drag_cores, delta); + update(); +} + +/** + @brief CablePart::settleLineGesture + Where this line comes to rest, worked out the same way as after a + drag of its own: which wire every colour label has come to stand on, + asked before anything changes hands, and no label moved for it. + + The step is handed over rather than pushed, so that the movement of + the whole selection ends up as one single undo step. + @param steps receives the undo step of this line, if any + @return false when he called the gesture off -- this line is already + back where it began then, and the sheet puts the rest back too +*/ +bool CablePart::settleLineGesture(QList &steps) +{ + if (!m_line_moved) return true; + //Back where it began: nothing was carried anywhere, so there is + //nothing to work out and nothing to undo + if (m_p1 == m_move_p1 && m_p2 == m_move_p2) return true; + + if (!m_cable || !diagram()) + { + abortLineGesture(); + return true; + } + + QList after = m_line_drag_cores; + readyToSettle(after); + + if (!settleCores(after)) + { + abortLineGesture(); + return false; + } + + m_cable->setCores(after); + steps.append(new MoveCablePartCommand(m_cable, this, + m_move_p1, m_move_p2, + m_p1, m_p2, + m_cores_before, after)); + return true; +} + +/** + @brief CablePart::abortLineGesture + Put this line back where the gesture found it: the line goes back + to its place and its cable gets the labels it held at that moment, + whether they were written down during this gesture or not. +*/ +void CablePart::abortLineGesture() +{ + if (m_line_moved) + { + if (m_cable) { + m_cable->setCores(m_cores_before); + } + setLine(m_move_p1, m_move_p2); + update(); + } + m_line_moved = false; + m_line_drag_cores.clear(); + m_cores_before.clear(); +} + +/** + @brief CablePart::doneLineGesture + The gesture is over for this line: forget where it began, the step + which was worked out from it having been handed over already. +*/ +void CablePart::doneLineGesture() +{ + m_line_moved = false; + m_line_drag_cores.clear(); + m_cores_before.clear(); + m_stretch_end = 0; + m_dragged_core = -1; +} + +/** + @brief CablePart::readyToSettle + Make a set of carried cores ready to be written into the cable: + this line's own labels wherever the gesture has brought them, and + every other section of the cable exactly as the cable holds it at + this moment. + + The other sections are read back rather than carried, because + another line of this very gesture may have just settled and + written them -- and a core which is no longer there has just been + taken by another cable of the gesture, which makes it that + cable's free slot rather than one of ours. + @param cores the carried set, corrected in place +*/ +void CablePart::readyToSettle(QList &cores) const +{ + if (!m_cable) return; + + for (int i = cores.size() - 1; i >= 0; --i) + { + const bool mine = cores.at(i).part == m_part_uuid; + if (m_cable->hasCore(cores.at(i).core)) { + if (!mine) { + cores[i] = m_cable->core(cores.at(i).core); + } + } else { + cores.removeAt(i); + } + } +} + +/** + @brief CablePart::wireFor + Which wire this line runs across at the very place a colour label + stands, so that the entry of a wire follows the label instead of + the label being sent off to find a wire. + + The nearest crossing which is close enough wins, and where two are + as close as that, the wire the core already names wins the draw: a + label standing between two crossings goes on naming the one it had + rather than sliding onto the other, while a label dropped straight + onto another wire does take that one. Close enough means close + enough for the slash and the wire to read as one thing -- and + nothing at all that close means no wire: a label is never moved to + reach one, it keeps whatever it already names and stands where the + user left it. + @param crossings every place this line runs across a wire + @param core the core being worked out + @param at where its label stands, in scene coordinates + @return the wire to give the core, or nullptr +*/ +Conductor *CablePart::wireFor(const QList &crossings, + const CableCore &core, const QPointF &at) const +{ + const CableCrossing *nearest = nullptr; + const qreal tolerance = CableManager::bind_tolerance; + qreal nearest_distance = tolerance; + + for (const CableCrossing &crossing : crossings) + { + if (!crossing.conductor) continue; + + const qreal distance = QLineF(crossing.position, at).length(); + if (distance > tolerance) continue; + if (nearest && distance >= nearest_distance) { + //Where it already stands wins a draw, so that a label + //between two crossings never slides from one to the other + if (distance > nearest_distance + || crossing.conductor->uuid() != core.conductor) { + continue; + } + } + + nearest = &crossing; + nearest_distance = distance; + } + return nearest ? nearest->conductor : nullptr; +} + +/** + @brief CablePart::askClaim + The question every takeover is asked with: three answers, in + French, of which the third one is the way out of the whole gesture + -- he has let go in the wrong place, and would rather have nothing + happen at all than take a wire he did not mean to take. + @param parent the window the question is asked in + @param title the title of the question + @param question what is being asked + @return what he answered +*/ +CablePart::ClaimAnswer CablePart::askClaim(QWidget *parent, + const QString &title, + const QString &question) const +{ + QMessageBox box(QMessageBox::Question, title, question, QMessageBox::NoButton, parent); + QPushButton *take = box.addButton( + tr("Yes", "Prendre le conducteur à l'autre câble."), + QMessageBox::YesRole); + QPushButton *leave = box.addButton( + tr("No", "Laisser le conducteur à l'autre câble, on ne change rien."), + QMessageBox::NoRole); + QPushButton *cancel = box.addButton( + tr("Cancel", "Annuler le geste entier : rien n'est écrit, la ligne et ses couleurs reviennent où elles étaient."), + QMessageBox::RejectRole); + //The safe answer is the one under his finger, and the escape + //key throws the whole gesture away rather than taking a wire. + box.setDefaultButton(leave); + box.setEscapeButton(cancel); + box.exec(); + + const QAbstractButton *clicked = box.clickedButton(); + if (clicked == take) return ClaimAnswer::Take; + if (clicked == leave) return ClaimAnswer::Leave; + return ClaimAnswer::Cancel; +} + +/** + @brief CablePart::mayClaim + Whether a wire may be given to this cable. A wire another cable + already describes is asked about first: taking it empties the cable + field of that other cable, and that is not something a drop or a + movement does behind the back of the user. + @param conductor the wire about to be taken + @return what he answered +*/ +CablePart::ClaimAnswer CablePart::mayClaim(Conductor *conductor) const +{ + if (!conductor || !m_cable || !diagram() || !diagram()->project()) { + return ClaimAnswer::Leave; + } + + const QUuid holder = conductor->properties().m_cable_uuid; + if (holder.isNull() || holder == m_cable->uuid()) return ClaimAnswer::Take; + + const Cable *other = CableManager::cableByUuid(diagram()->project(), holder); + const QString other_name = other ? other->designation() : QString(); + + const QString text = other_name.isEmpty() + ? tr("This conductor is already described by another cable. Do you want to assign it to cable %1?", + "Un conducteur ne peut appartenir qu'à un câble : on demande avant de le prendre à l'autre.") + : tr("This conductor is already described by cable %1. Do you want to assign it to cable %2?", + "Un conducteur ne peut appartenir qu'à un câble : on demande avant de le prendre à l'autre."); + + QWidget *parent = nullptr; + if (!diagram()->views().isEmpty()) { + parent = diagram()->views().constFirst(); + } + + return askClaim(parent, + tr("Conductor already assigned", "Un conducteur ne peut appartenir qu'à un câble."), + other_name.isEmpty() ? text.arg(m_cable->designation()) + : text.arg(other_name, m_cable->designation())); +} + +/** + @brief CablePart::mayClaimSeveral + The same question as mayClaim(), but asked once for a whole handful + of conductors: pulling a line across several wires which another + cable already describes must ask about them too, rather than either + quietly taking them or quietly leaving them -- one question, one + answer, and the other cable releases every wire he says yes to. + @param conductors the wires which other cables hold + @return what he answered +*/ +CablePart::ClaimAnswer CablePart::mayClaimSeveral(const QList &conductors) const +{ + if (conductors.isEmpty() || !m_cable || !diagram() || !diagram()->project()) { + return ClaimAnswer::Leave; + } + + //Which cables hold those wires: he is told the name of one of + //them, and that is the one he is most likely to be looking for. + QString other_name; + QSet others; + for (const Conductor *conductor : conductors) + { + const QUuid holder = conductor->properties().m_cable_uuid; + if (holder.isNull() || holder == m_cable->uuid()) continue; + others.insert(holder); + } + if (others.isEmpty()) return ClaimAnswer::Take; + + if (others.size() == 1) { + const Cable *other = CableManager::cableByUuid(diagram()->project(), *others.constBegin()); + if (other) other_name = other->designation(); + } + //Several other cables at once: no one name would say it all. + const bool unnamed = (others.size() != 1) || other_name.isEmpty(); + + const int n = conductors.size(); + const QString title = n <= 1 + ? tr("Conductor already assigned", "Un conducteur ne peut appartenir qu'à un câble.") + : tr("Conductors already assigned", "Plusieurs conducteurs sont pris en une seule fois.", n); + + const QString text = n <= 1 + ? (unnamed + ? tr("This conductor is already described by another cable. Do you want to assign it to cable %1?", + "Un conducteur ne peut appartenir qu'à un câble : on demande avant de le prendre à l'autre.") + : tr("This conductor is already described by cable %1. Do you want to assign it to cable %2?", + "Un conducteur ne peut appartenir qu'à un câble : on demande avant de le prendre à l'autre.")) + : (unnamed + ? tr("%n conductors are already described by other cables. Do you want to assign them to cable %1?", + "On prend plusieurs conducteurs à la fois : on demande avant de les prendre aux autres câbles.", n) + : tr("%n conductors are already described by cable %1. Do you want to assign them to cable %2?", + "On prend plusieurs conducteurs à la fois : on demande avant de les prendre à l'autre câble.", n)); + + const QString question = unnamed + ? text.arg(m_cable->designation()) + : text.arg(other_name, m_cable->designation()); + + QWidget *parent = nullptr; + if (!diagram()->views().isEmpty()) { + parent = diagram()->views().constFirst(); + } + + return askClaim(parent, title, question); +} + +/** + @brief CablePart::settleCores + Which wire each core names once the line has come to rest: read + off where the colour labels have stood themselves, so that moving a + line as a whole updates the entries of the wires instead of leaving + them behind on the wires it has gone away from. + + While he drags, every label is merely carried along, so nothing + jumps, nothing lands on a neighbour's wire and nothing disappears + because the line came a little short -- the drawing stays readable + over the whole gesture, and pulling the line back puts every label + exactly back where it was. Which wire each core names is worked out + once, at the very end, and always from the place of the label: a + label standing on a wire gives that wire to its core, and the wire + it came from keeps no binding, so the refresh which follows empties + its cable field. Not one label is moved here. A label which came + to rest beside a wire rather than on it -- a whole grid step away, + which is as far as one pull of the line can carry it -- lets go of + that wire, because it no longer stands on it: an entry is always + where its colour label stands, and the grid is what puts a label + down on a crossing in the first place, so standing on that + crossing is what counts. When the line has been drawn off into an + empty part of the sheet, where it crosses no wire at all, every one + of its cores lets go, so that no entry is left standing on a wire + the label has gone away from. Pulling the line back puts every + label on a crossing again, and with it the entries they had. + + The whole set is worked out at once, and not core after core in the + order they happen to be stored. Otherwise a label coming to rest on + a wire another label of this very line is standing on at this moment + would be turned away for a core which is moving off that very wire + in the same gesture -- and its entry would go on standing on a wire + the line has gone away from, which is exactly the untidy entry that + has to disappear. A label which does not move keeps its wire all the + same, and where two labels want one wire, the first of them has it + and the other keeps what it had. + + A core which has no wire at all yet -- a copy put down on a folio + it was not wired on, because a copy is carried whole even where + there is nothing to wire -- takes the wire its own label stands on, + if there is one: pulling a copied line into place is what wires it. + + Only wires nobody else holds are taken -- and when the line comes + to rest on wires another cable already describes, he is asked about + them once, before anything is taken, naming the other cable and + answering no unless he says yes. The answer covers the whole move: + either those wires change hands and the other cable releases them, + or none of them does. + + Whichever way he answers, a core whose label has come to rest on + such a wire and names none of its own any more stops being a core + of its cable: it goes back to being a free slot, so that neither its + slash nor its colour is drawn on a wire which is not its own, and + its entry leaves the wire the label went away from -- an entry + following its label just as it does everywhere else, so that no + entry is left standing where no colour label stands. Yes makes the + other cable's core the free one instead; No makes it this cable's + own, and the other cable keeps its wire either way. That is why + both answers look the same on the sheet: one label standing there, + and a free slot in the list of existing cables. + + And he may call the whole gesture off: nothing is then written at + all, the line goes back to where he picked it up, and the drawing + is once more what it was before he started. + + @param cores the whole set of assignments, changed in place + @return false when he cancelled the gesture +*/ +bool CablePart::settleCores(QList &cores) const +{ + if (!m_cable || !diagram()) return true; + + const QList crossings = CableManager::crossedConductors( + diagram(), QLineF(m_p1, m_p2)); + //A line which runs across nothing at all is not a reason to call + //the move off: it is the moment every core of this section lets + //go of the wire it came from, since an entry belongs to a wire + //the cable still touches. So the walk below happens either way, + //and then simply comes out with nothing to keep. + + //Which wire every core ends up with is worked out for the whole + //set before a single one of them is written down -- a dry run + //first, which merely collects the wires of other cables on the + //way, so that he is asked about all of them before any of them + //changes hands. + // + //Working it out for the set as a whole, and not core by core in + //the order they happen to be stored, is what makes the entries + //come out right: a label coming to rest on a wire another label + //of this very line is standing on at this moment must have that + //wire, because the other label is moving off it in the same + //gesture -- whereas a label which does not move, and keeps the + //wire it names, must not be robbed of it. + const auto settle_once = [this, &crossings]( + QList &list, + const bool dry_run, + QList *foreign_wires, + const bool take_foreign) + { + //A wire another cable already describes only changes hands + //when he has said yes to taking it away from that one. + const auto is_foreign = [this](Conductor *wire) { + return wire && !wire->properties().m_cable_uuid.isNull() + && wire->properties().m_cable_uuid != m_cable->uuid(); + }; + + //The wire each colour label of this line is standing on + //right now -- worked out for every one of them first, so + //that which wire a core gets does not depend on which core + //is looked at first. None at all when a label stands on no + //wire, and then that core lets go of whatever it named. + QHash wanted; + for (const CableCore &core : list) { + if (core.part != m_part_uuid) continue; + wanted.insert(core.core, wireFor(crossings, core, core.position)); + } + + //Who lets go of nothing: a label standing on its own wire, + //and every label of another part of this cable, which this + //line never takes anything from. A label standing on no + //wire at all is not among them: it lets go of what it + //named, a whole grid step away from a crossing being + //already beside that wire rather than on it. + QSet keepers; + //Who ends up naming no wire at all: a label which has come + //to rest on a wire another cable holds, and he has said no + //to taking it. Such a core goes back to being a free slot of + //this cable -- exactly what the other cable's core becomes + //when he says yes -- so neither its slash nor its colour is + //drawn on a wire which is not its own, and its entry leaves + //the wire the label came from with it. The other cable keeps + //its wire either way. + QSet refuses; + for (const CableCore &core : list) + { + if (core.part != m_part_uuid) continue; + Conductor *wire = wanted.value(core.core, nullptr); + //Stands on no wire of this line: not a keeper, so it + //lets go of whatever it named further down. + if (!wire) continue; + + if (wire->uuid() == core.conductor) { + keepers.insert(core.core); + continue; + } + if (!take_foreign && is_foreign(wire)) { + refuses.insert(core.core); + } + } + + //Which core ends up with which wire, over and over until it + //stops moving: a core sent away from its own wire sends the + //one waiting for that wire away too, and that one may in + //turn free the wire the first one was after. The keepers + //only ever grow, so this cannot go round for ever. + QSet given_wires; + bool grew = true; + while (grew) + { + grew = false; + given_wires.clear(); + + //The wires nobody is letting go of + QSet staying; + for (const CableCore &core : list) + { + if (core.conductor.isNull()) continue; + if (core.part != m_part_uuid || keepers.contains(core.core)) { + staying.insert(core.conductor); + } + } + + for (const CableCore &core : list) + { + if (core.part != m_part_uuid + || keepers.contains(core.core) + || refuses.contains(core.core)) continue; + + Conductor *wanted_wire = wanted.value(core.core, nullptr); + if (!wanted_wire) continue; //stands on no wire: a keeper already + + const QUuid uuid = wanted_wire->uuid(); + if (staying.contains(uuid) || given_wires.contains(uuid)) + { + //Another label of this line goes on standing on it, + //or has just been given it: this one keeps the wire + //it names now rather than taking it away from under + //a label which has come to rest on it. + keepers.insert(core.core); + grew = true; + continue; + } + given_wires.insert(uuid); + } + } + + //The whole set has come to rest; now it can be written down + //without anything shifting under the feet of the next core. + //Cores which name no wire at all any more are collected on + //the way and taken out of the set only once the walk is over, + //since a list cannot be shortened while it is being walked. + QList freed; + for (CableCore &core : list) + { + if (core.part != m_part_uuid) continue; + + Conductor *wire = wanted.value(core.core, nullptr); + + if (refuses.contains(core.core)) + { + //He said no, so this core names no wire at all any + //more: it goes back to being a free slot of this + //cable, exactly what the other cable's core becomes + //when he says yes to taking its wire. Neither its + //slash nor its colour stays standing on a wire which + //is not its own, and its entry leaves the wire the + //label came from with it -- while the other cable + //keeps its wire either way. + if (!dry_run) { + freed << core.core; + } + continue; + } + + if (!wire) + { + //The label has come to rest where this line crosses + //nothing: the entry of the wire it came from goes + //with it, while the label itself stays exactly + //where he put it and merely names no wire at all + //any more -- an entry follows its label, and stops + //where its label stopped. A whole grid step away + //from a crossing is already beside that wire + //rather than on it, which is the case he means. + if (!dry_run && !core.conductor.isNull()) { + core.conductor = QUuid(); + } + continue; + } + + if (keepers.contains(core.core)) + { + continue; + } + + const QUuid uuid = wire->uuid(); + //Another cable holds this wire: it only changes hands + //when he has said yes to taking it away from that one. + const bool foreign = is_foreign(wire); + if (foreign && foreign_wires) { + foreign_wires->append(wire); + } + if (!dry_run) { + + wire->setUuid(uuid); + //A wire is one piece of wire: whichever cable held it + //before has to let it go, or its entry would stand on + //the wire all the same. + if (foreign) { + CableManager::claimConductor(diagram()->project(), wire, m_cable.data()); + wire->setUuid(uuid); + } + core.conductor = uuid; + //The wire this core has just left keeps no binding, so + //the refresh which follows empties its cable field: an + //entry follows its label, and stops where its label + //stopped + } + } + + //A core which names no wire is not a core of this cable any + //more: it goes back to being a free slot, and takes its slash + //and its colour off the sheet with it -- the very thing the + //other cable's core becomes when he says yes to taking its + //wire, so that both answers behave the same way. + for (int i = list.size() - 1; i >= 0; --i) { + if (list.at(i).part == m_part_uuid + && freed.contains(list.at(i).core)) { + list.removeAt(i); + } + } + }; + + QList trial = cores; + QList foreign_wires; + settle_once(trial, true, &foreign_wires, true); + + //The whole move is asked about at once, and the wires of other + //cables are taken either all together or not at all. + ClaimAnswer answer = ClaimAnswer::Leave; + if (!foreign_wires.isEmpty()) { + answer = mayClaimSeveral(foreign_wires); + if (answer == ClaimAnswer::Cancel) return false; + } + + settle_once(cores, false, nullptr, answer == ClaimAnswer::Take); + return true; +} + +/** + @brief CablePart::itemChange + Fold any translation of the item into its own geometry: this item + works in scene coordinates and keeps itself at the origin, so that + one number -- the trunk line -- is all the cable ever has to store. + + The colour labels come along with it. A line moved as a whole -- + by a paste, by a duplicate, or together with the rest of a selection + -- is one thing, and its labels belong to it, so they travel rather + than staying behind at the place the line was copied from. The trunk + line dragged by its own gesture does not come through here: it is + given its new geometry directly, which is what lets that gesture + carry the labels step by step and settle them only at the end. + @param change + @param value + @return +*/ +QVariant CablePart::itemChange(GraphicsItemChange change, const QVariant &value) +{ + if (change == ItemSelectedHasChanged) + { + //The two blue points at the ends come and go with the + //selection, so the moment a line is picked it has to be + //drawn again -- otherwise the points only appear at the + //next redraw of any other kind. + update(); + } + if (change == ItemSceneHasChanged) + { + //The folio this line stands in is what its references to the + //other folios of the cable are worked out from, and it is + //also what has to be listened to: only now, once there is + //one, can they be kept up to date -- and once this line + //leaves that folio, they have to stop. + setUpXrefHooks(); + } + if (change == ItemPositionHasChanged) + { + const QPointF new_pos = value.toPointF(); + if (!new_pos.isNull()) + { + prepareGeometryChange(); + m_p1 += new_pos; + m_p2 += new_pos; + if (m_cable) { + m_cable->movePartCores(m_part_uuid, new_pos); + } + syncGeometry(); + update(); + QGraphicsItem::setPos(QPointF()); + } + } + return QetGraphicsItem::itemChange(change, value); +} + +/** + @brief CablePart::editProperty + Open the dialog which edits the properties of the cable this line + belongs to -- the number, the type, the installation, the place. They + belong to the cable, so editing them here changes them for every line + of that cable, on every folio. +*/ +void CablePart::editProperty() +{ + if (!m_cable || !diagram() || diagram()->isReadOnly()) return; + + //Asking for the whole cable is another thing than putting one + //of its cores down: anything which waited for a click stops + //waiting -- on this line or on any other, since only one of + //them can wait at a time -- and he can start over from this + //dialog. + cancelPlacing(); + + CablePropertiesDialog dialog(m_cable, diagram(), this, + diagram()->views().isEmpty() + ? nullptr : diagram()->views().constFirst()); + dialog.exec(); +} + +/** + @brief CablePart::startPlacingCore + Give this line the mouse and wait for the click which puts that core + of its cable down: the core follows the line wherever the mouse comes + near it, drawn where the click would leave it -- on the line, at a + grid step, and never past either end of it. + + It is always this very section which takes the core, never the first + section of the cable: a cable running over two folios stands on two + lines, and the one whose dialog was asked for is the one he is + working on. + @param core which core of the cable is being put down +*/ +void CablePart::startPlacingCore(int core) +{ + if (!m_cable || !diagram() || diagram()->isReadOnly()) return; + if (core < 0 || core >= m_cable->coreCount() || m_cable->hasCore(core)) return; + if (m_dragged_core >= 0 || m_stretch_end > 0 || m_line_moved) return; + if (m_pending_core == core) + { + //He asks again for the very core which is waiting already: + //there is nothing to arm a second time, but the mouse still + //goes to the line -- asking twice has to behave exactly like + //asking once, and the mark is where he is being sent to. + sendMouseToLine(); + return; + } + + cancelPlacement(); + + //Only one line at a time may wait for its click, and it is the + //one whose dialog was asked for: a line standing on another + //folio lets go as well, so Escape and the right button always + //find the very line he is working on. + cancelPlacing(); + + m_pending_core = core; + s_placing = this; + m_drag_scene = m_p1; + m_pending_valid = false; + + //Where the mouse stands at this very moment, so that a dialog + //which happened to cover the line does not have to be closed by + //a click the core would take for itself -- and then straight to + //the line, which is where the next click belongs. + sendMouseToLine(); + + if (!isSelected()) { + setSelected(true); + } + grabMouse(); + setCursor(Qt::CrossCursor); + update(); +} + +/** + @brief CablePart::sendMouseToLine + Send the mouse to this line, onto the grid step the mark of the + waiting core stands on, so that the very next click is the click + which puts that core down: he picks the core out of a list, and the + line may well be across the screen from where that list stands. + + The mouse goes wherever the line stands, as long as that line is + standing in some window he can see -- there is nothing else to ask. + It is never the mark which is held back: he asked for this core, the + very next click is the one which puts it down, and the mouse is + meant to be there when he clicks. +*/ +void CablePart::sendMouseToLine() +{ + if (!scene() || scene()->views().isEmpty()) return; + + QGraphicsView *view = scene()->views().constFirst(); + const QPointF scene_pos = view->mapToScene(view->mapFromGlobal(QCursor::pos())); + m_drag_scene = placePosition(scene_pos); + m_pending_valid = QLineF(scene_pos, m_drag_scene).length() <= place_grab; + view->setFocus(); + + //The window he is actually looking at: a folio may stand in + //more than one window, and only the one which is on screen can + //be sent the mouse. The mark itself is drawn on the line + //whenever he asks for the core, seen or not -- whether a click + //keeps it or sends it away is worked out from the click itself + //(see mousePressEvent), so nothing here may stop the mouse + //from going where he just told it to go. + QGraphicsView *shown = nullptr; + for (QGraphicsView *candidate : scene()->views()) + { + if (candidate && candidate->isVisible()) { + shown = candidate; + break; + } + } + + if (shown) + { + QWidget *viewport = shown->viewport(); + const QPoint there = viewport->mapFrom(shown, shown->mapFromScene(m_drag_scene)); + const QPoint target = viewport->mapToGlobal(there); + const QPointF mark = m_drag_scene; + QPointer self(this); + + QCursor::setPos(target); + + //And again a little later: a pop-up which has just closed + //keeps the mouse in hand for a moment longer, and the system + //only lets go once the menu is really gone from the screen. + //A jump asked for earlier than that is dropped on the floor, + //while one asked for afterwards always goes through -- so the + //very same jump is asked for three times, as long as this + //line is still the one waiting and its mark still stands on + //that very spot. + const int waits[] = {0, 70, 250}; + for (const int wait : waits) + { + QTimer::singleShot(wait, viewport, [self, target, mark]() { + if (!self || self->m_pending_core < 0 || self->m_drag_scene != mark) return; + + //The same pop-up may have kept the mouse out of + //this line's hands while it was closing: the line + //waits for the click, so it has to be the one + //holding the mouse. + if (self->scene() && self->scene()->mouseGrabberItem() != self.data()) { + self->grabMouse(); + } + + QCursor::setPos(target); + }); + } + } + update(); + +} + +/** + @brief CablePart::cancelPlacement + Stop waiting for the click which would put a core down, without + putting anything down anywhere: the core goes back to being a free + one in the dialog it came from. +*/ +void CablePart::cancelPlacement() +{ + if (s_placing == this) s_placing = nullptr; + if (m_pending_core < 0) return; + + m_pending_core = -1; + m_pending_valid = false; + ungrabMouse(); + unsetCursor(); + update(); +} + +/** + @brief CablePart::placingPart + @return the line which waits for the click putting a core down right + now, or nullptr when no line does +*/ +CablePart *CablePart::placingPart() +{ + return s_placing.data(); +} + +/** + @brief CablePart::cancelPlacing + End the wait of whichever line stands in it, without putting anything + down anywhere: the core goes back to being a free one. + @return true when a line was waiting for its click +*/ +bool CablePart::cancelPlacing() +{ + if (!s_placing) return false; + + s_placing->cancelPlacement(); + return true; +} + +/** + @brief CablePart::placeCore + Put a core which is not on the sheet yet onto this line, standing + where the mouse says: on a wire this line runs across it takes that + wire like any other label would -- including asking before taking + one another cable holds -- and where the line runs across nothing it + merely stands and names no wire at all. + + The core belongs to this very section, so a cable standing on two + folios gets it on the line he asked for rather than on the first one. + @param core which core of the cable is being put down + @param at where its label is to stand, in scene coordinates + @return false when nothing was put down +*/ +bool CablePart::placeCore(int core, const QPointF &at) +{ + if (!m_cable || !diagram() || diagram()->isReadOnly()) return false; + if (core < 0 || core >= m_cable->coreCount()) return false; + if (m_cable->hasCore(core)) return false; + + //Every core the cable holds at this moment: the addition is + //kept as one step of undo together with whatever the click + //makes of it, so that taking it back leaves no trace. + const QList before = m_cable->usedCores(); + + CableCore fresh; + fresh.core = core; + fresh.part = m_part_uuid; + fresh.conductor = QUuid(); + fresh.position = at; + m_cable->setCore(fresh); + + if (!rebindCore(core, at, &before)) + { + //He called the question off, so nothing is put down at + //all: the core goes back to being a free one, and no step + //of undo was written for what never happened. + m_cable->setCores(before); + return false; + } + return true; +} diff --git a/sources/cable/cablepart.h b/sources/cable/cablepart.h new file mode 100644 index 000000000..a3b8744fa --- /dev/null +++ b/sources/cable/cablepart.h @@ -0,0 +1,513 @@ +/* + 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 CABLEPART_H +#define CABLEPART_H + +#include "cable.h" +#include "cablemanager.h" +#include "../qetgraphicsitem/qetgraphicsitem.h" + +#include +#include +#include +#include +#include + +class Cable; +class CablePartData; +class Conductor; +class QUndoCommand; +class QWidget; + +/** + @brief The CablePart class draws one section of a cable: the trunk + line of one folio, the number and the type of the cable at its left, + and a slash with the colour of a core wherever that core crosses a + conductor. + + All of it is a view of the Cable: the geometry lives in the cable + itself, so lengthening the trunk line is purely optical. What a core + wires is read off where its colour label stands: dropping a label + gives that core the wire it lands on, and letting go of a moved line + works the entries out the same way, from where the labels have come + to rest. A label is never sent off to look for a wire and never + moved to reach one -- it goes on standing where the user put it -- + and no gesture at all takes a core away from its cable. +*/ +class CablePart : public QetGraphicsItem +{ + Q_OBJECT + + public: + explicit CablePart(const CablePartData &data, QGraphicsItem *parent = nullptr); + explicit CablePart(QGraphicsItem *parent = nullptr); + ~CablePart() override; + + enum {Type = UserType + 1012}; + int type() const override {return Type;} + + void setCable(Cable *cable); + Cable *cable() const; + QUuid partUuid() const; + ///Bind the item to its cable once the project is loaded + void refreshPending(); + + void setLine(const QPointF &p1, const QPointF &p2); + QLineF line() const; + QPointF firstPoint() const {return m_p1;} + QPointF secondPoint() const {return m_p2;} + + QRectF boundingRect() const override; + QPainterPath shape() const override; + void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + QString name() const override; + + void mousePressEvent(QGraphicsSceneMouseEvent *event) override; + void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; + void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; + void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; + ///Shows the hand over a line which points at another folio + void hoverMoveEvent(QGraphicsSceneHoverEvent *event) override; + ///Opens the dialog editing the properties of the whole cable + void editProperty() override; + + /** + Give this line the mouse and wait for the next click to put + down a core of its cable which is not on the sheet yet: the + core follows this line wherever the mouse comes near it and + the click leaves it standing there, snapped to the grid like + anything else drawn. + + It is always this very section which takes it, never the + first section of the cable: a cable running over two folios + stands on two lines, and the one whose dialog was asked for + is the one being worked on. + @param core which core of the cable is being put down + */ + void startPlacingCore(int core); + ///Stop waiting for that click, putting nothing down at all + void cancelPlacement(); + + /** + The line which waits for the click putting one of its cores + down at this very moment, anywhere in the application, or + nullptr when no line does. + + Only one line at a time may wait -- taking the core always + belongs to the line whose dialog was asked for -- so Escape + and the right button can simply ask here and always act on + the right one, however many folios are open. + */ + static CablePart *placingPart(); + /** + End that wait wherever it stands, without putting anything + down anywhere: the core goes back to being a free one. + + Used by the sheet itself, which gets the right button and + Escape before any item does (see DiagramView). + @return true when a line was waiting for its click + */ + static bool cancelPlacing(); + + /** + Which core of this cable has its colour label standing at + that very place on the sheet -- asked by the sheet itself + when the right button comes down on a line, so that the + menu which opens belongs to that one core rather than to + the whole folio. + @param scene_pos a point of the sheet, in scene coordinates + @return the core standing there, -1 when none does + */ + int coreAt(const QPointF &scene_pos) const; + /** + Take one core off this line: it names no wire any more and + goes back to being a free slot of its cable, listed among + the ones which stand nowhere -- the very opposite of + putting one down from that list. + + Kept as one undo step, so a single undo puts the label + back exactly where it stood. + @param core which core standing on this line is taken off + @return true when a core was taken off + */ + bool takeCoreOffLine(int core); + + /** + Which wire this line runs across at the very place a colour + label stands -- so that the entry of a wire follows the label + instead of the label being sent off to find a wire. Also asked + by a copy being put down (CableCopy::wire), which must not + move any label to reach a wire either. + @param crossings every place this line runs across a wire + @param core the core being worked out + @param at where its label stands, in scene coordinates + @return the wire to give the core, or nullptr + */ + Conductor *wireFor(const QList &crossings, + const CableCore &core, const QPointF &at) const; + + /** + Move this line from outside: the sheet moving a whole + selection of elements and lines at once asks every line + of the selection to come along, one step after the other, + exactly as if the finger had been on the line itself, so + that one gesture moves everything which is marked instead + of two halves of the selection going different ways. + + The calls come in order -- begin once, continue as often + as the gesture needs, settle once for the whole + selection, then either done or abort -- and a line which + never moves simply has no undo step to hand over. + */ + void beginLineGesture(); + ///Take one more step of such a move, by that much + void continueLineGesture(const QPointF &delta); + /** + Where this line comes to rest, worked out the same way as + after a drag of its own: which wire every colour label now + stands on, asked before anything changes hands. The step + of this line is handed over rather than pushed, so that + the movement as a whole ends up as one single undo step. + @param steps receives the undo step of this line, if any + @return false when he called the gesture off + */ + bool settleLineGesture(QList &steps); + ///Put this line back where the gesture found it + void abortLineGesture(); + ///The gesture is over for this line: forget where it began + void doneLineGesture(); + + /** + One cross reference of this section as the PDF export + needs it: where the text of the reference stands on this + folio, and on the folio it points at where the section it + names stands -- so that exporting makes it a clickable + link the way a cross reference of an element already is. + + Both rectangles are in scene coordinates, each of its own + folio. + */ + struct PdfRef + { + ///The folio the reference points at + Diagram *diagram = nullptr; + ///Where the text of the reference stands on this folio + QRectF here; + ///Where the section it names stands on that folio + QRectF there; + }; + QList pdfRefs() const; + + protected: + QVariant itemChange(GraphicsItemChange change, const QVariant &value) override; + + private: + /** + One line of the label at the left of the trunk line: + what is written, which field of the cable it comes from + and -- worked out from what that field asks for -- the + font it is written with, the way it lines up and the + rectangle it sits in. + + Every line carries its own font, because every field + may have been given one of its own: Anlage, Ort and + Désignation stand in one block, and their lines may + very well no longer all be the same size once he has + set them apart. + */ + struct LabelLine + { + QString text; + QString key; + QFont font; + Qt::Alignment align = Qt::AlignRight; + qreal width = 0.0; + qreal height = 0.0; + QRectF rect; + }; + + /** + One line of the label which points at a folio this cable + runs on besides this one: that folio, the section of the + cable standing there, and the text this project asks for + such a reference -- the format chosen for the type + "Cable" under Cross-references, written out once per folio, + in the order the folios turn. + */ + struct CrossRef + { + ///The other folio, which always exists while listed + Diagram *diagram = nullptr; + ///Which section of the cable stands on it + QUuid part; + ///The text as it is written on the drawing + QString text; + }; + + QFont textFont() const; + ///The font the colours of the cores are written with + QFont coreFont() const; + ///The font one named text of this cable is written with + QFont fontOf(const QString &key) const; + ///How one named text of this cable lines up at the line + Qt::Alignment alignOf(const QString &key) const; + /** + How far the whole label at the left of the line is + nudged on the drawing: over the line, under it, left + or right of the end of it. A nudge only, which moves + the texts and never the line. + @return the distance in scene units + */ + QPointF textOffset() const; + /** + How far the row of colours is nudged on the drawing: + again a distance on the texts only, the slashes and + the entries behind them staying where they stand. + @return the distance in scene units + */ + QPointF coreOffset() const; + /** + Where a text of this width starts, at the left end of + the trunk line: before the line when it is set to end + there (the way it always did), on the line when it is + centred, after it when it starts there. + @param key the field the text comes from + @param width how wide the text is, in scene units + @return the x of the left of the text + */ + qreal textLeft(const QString &key, qreal width) const; + bool trunkIsHorizontal() const; + QList labelLines() const; + /** + The references to the other folios this cable runs on, + one line each, stacked under the length of the cable: + at the very bottom of everything the line says. + @return those lines, in the order crossRefs() lists them + */ + QList refLines() const; + /** + Every other folio this cable runs on, one entry each, in + the order the folios turn -- empty when the cable runs on + this folio alone, which is the usual case. + */ + QList crossRefs() const; + /** + Which of those references stands at that very place, so + that both the hand of the mouse and a double click know + which line they are about. + @param scene_pos a point of the sheet, in scene coordinates + @return the index of that reference, -1 when none stands there + */ + int crossRefAt(const QPointF &scene_pos) const; + /** + Send the mouse to the section of the cable which stands + on the folio the reference at that place points at -- the + very leap an element's cross reference takes, straight to + the place to look at. + @param scene_pos a point of the sheet, in scene coordinates + @return true when the click stood on a reference + */ + bool goToCrossRef(const QPointF &scene_pos); + /** + Keep the reference lines up to date with the project: a + folio which is added, removed or moved changes what they + say, and so does the format chosen under Cross-references. + */ + void setUpXrefHooks(); + QString typeText() const; + QString lengthText() const; + QRectF labelRect() const; + qreal underTop() const; + QRectF typeRect() const; + QRectF lengthRect() const; + QRectF coreLabelRect(const QPointF &at, const QString &color) const; + QPointF coreLabelPivot(const QPointF &at, const QString &color) const; + QPointF coreDrawPosition(const CableCore &core) const; + bool coreDrawn(const CableCore &core) const; + QPointF dragPosition(const QPointF &scene_pos) const; + /** + Where the mouse would put down a core which waits to be + put down: the mouse projected onto the line itself, at a + grid step, and never past either end of the line -- the + same coordinates a dragged label would be drawn at, only + worked out from the mouse rather than from a gesture. + @param scene_pos where the mouse stands, in scene coordinates + @return where the slash would be drawn + */ + QPointF placePosition(const QPointF &scene_pos) const; + /** + Snap a distance measured along the line to a grid step, in + the coordinates of the sheet -- so a horizontal line steps + along x and a vertical one along y. Holding Ctrl lets go of + the grid, as everywhere else on the sheet. + @param along how far from the first end of the line + @return that distance at a grid step + */ + qreal stepAlong(qreal along) const; + void drawLabel(QPainter *painter, const QColor &ink) const; + void drawCores(QPainter *painter, const QColor &ink) const; + ///Draw the slash of one core and the colour written beside it + void drawCoreMark(QPainter *painter, const QColor &ink, + const QPointF &at, const QString &color) const; + void syncGeometry(); + bool labelAt(int core, const QPointF &scene_pos) const; + ///Answer to the question of taking a wire another cable holds + enum class ClaimAnswer { + ///Take it: the other cable lets it go + Take, + ///Leave it: nothing changes hands, the rest still happens + Leave, + ///Cancel: this whole gesture is called off, as if undone + Cancel + }; + /** + @param core which core is being let go + @param dropped_at where its label comes to rest + @param before every core the cable held before this one was + added to it, so that the one step of undo takes the addition + back as well; nullptr while a core which is already on the + sheet is merely moved + @return false when the user called the whole thing off + */ + bool rebindCore(int core, const QPointF &dropped_at, + const QList *before = nullptr); + /** + Put a core which is not on the sheet yet onto this line: it + stands where the mouse says, and if a wire runs across that + very place it names that wire the same way a dragged label + would. Called off means back to being a free core, with one + step of undo to take it away again. + @param core which core of the cable is being put down + @param at where its label is to stand, in scene coordinates + @return false when nothing was put down + */ + bool placeCore(int core, const QPointF &at); + /** + Send the mouse to this line, to the grid step the mark of + the waiting core stands on, so that the very next click is + the one which puts the core down -- wherever he asked for + the core from, and whether he asks for this very one a + second time or not. The mouse goes there only where the + line is really in sight: a folio hidden behind another + one, or off the edge of the view, is left alone rather + than jumped to. + */ + void sendMouseToLine(); + void reconcileCores(QList &cores, const QPointF &delta) const; + bool settleCores(QList &cores) const; + ClaimAnswer mayClaim(Conductor *conductor) const; + ClaimAnswer mayClaimSeveral(const QList &conductors) const; + ClaimAnswer askClaim(QWidget *parent, + const QString &title, + const QString &question) const; + + private: + ///The one line which waits for the click putting a core down, + ///null once there is none: taking the core belongs to the line + ///whose dialog was asked for, never to the first line of the + ///cable, and the sheet has to be able to cancel that wait + ///without knowing which scene it stands in + static QPointer s_placing; + QPointer m_cable; + ///Identity of the cable this part belongs to, kept until the + ///project has loaded it and setCable() can be called + QUuid m_pending_cable; + QUuid m_part_uuid; + QPointF m_p1; + QPointF m_p2; + + ///Core being dragged by its slash, -1 when none + int m_dragged_core = -1; + ///Core waiting to be put down by the next click, -1 when none + int m_pending_core = -1; + ///True while the mouse stands close enough to this line for + ///the waiting core to be put down where it shows + bool m_pending_valid = false; + ///True between the click which put a core down and the second + ///click Qt counts as a double click, so that a click which was + ///there to place something does not open the dialog as well + bool m_just_placed = false; + ///Where the dragged slash follows the mouse + QPointF m_drag_scene; + QPointF m_press_scene; + QPointF m_move_p1; + QPointF m_move_p2; + ///True while the trunk line is being dragged, so undo knows + ///there is a move to take back + bool m_line_moved = false; + ///Which end of the trunk line is being pulled by one of its + ///blue points: 0 none, 1 the first end, 2 the second + int m_stretch_end = 0; + ///Every core as it stands while the line is being dragged: + ///every label is carried along by however far the line has + ///come, so the line, its description and the colours of its + ///cores move as one thing and no label ever jumps, lands on + ///a neighbour's wire or disappears under his hand + QList m_line_drag_cores; + ///Every core as it was when the line was picked up, so undo + ///can put the labels back with the line + QList m_cores_before; + ///Another line he picked up at the same time: a drag takes + ///the whole selection with it, so each of them is carried + ///along and each of them goes back to where it stood if he + ///calls the gesture off + struct CarriedLine + { + CablePart *part = nullptr; + ///Where that line ran when the gesture began + QPointF p1; + QPointF p2; + ///What its cable held when the gesture began + QList cores; + }; + QList m_carried; + + ///True when the sheet has been told that the rest of the + ///selection comes along with this line (see carrySelection), + ///so that it is told once per gesture rather than once per + ///step -- and never again once it had nothing to move + bool m_mover_started = false; + ///How far of this gesture the sheet has already been told + QPointF m_told_mover; + + ///The lines which keep the references to the other folios of + ///this cable up to date, taken down and taken again whenever + ///this section comes to stand in another folio + QList m_xref_connections; + + void carryCompanions(const QPointF &delta); + /** + Everything else he marked at the same time comes along + too -- the elements, the texts and the images of the same + selection -- by the very step this line has taken, so + that one gesture moves all of it instead of the lines + going one way and the rest another. + + What this line measures is how far it has come all in + altogether, from where the press found it, while the + sheet takes a step at a time: so only the part it has not + been told yet is handed over. + @param total_delta how far this line has come since the press + */ + void carrySelection(const QPointF &total_delta); + bool settleCompanions(QList &steps); + void putCompanionsBack(); + void readyToSettle(QList &cores) const; +}; + +#endif // CABLEPART_H diff --git a/sources/cable/cablepropertiesdialog.cpp b/sources/cable/cablepropertiesdialog.cpp new file mode 100644 index 000000000..f489c4a44 --- /dev/null +++ b/sources/cable/cablepropertiesdialog.cpp @@ -0,0 +1,279 @@ +/* + 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 "cablepropertiesdialog.h" + +#include "cablecreatedialog.h" +#include "cablepart.h" +#include "cablepropertieswidget.h" +#include "editcablecommand.h" +#include "../diagram.h" +#include "../qeticons.h" + +#include +#include +#include +#include +#include +#include +#include + +/** + @brief CablePropertiesDialog::CablePropertiesDialog + @param cable the cable being edited + @param diagram the folio the line was double-clicked on, whose undo + stack takes the edit back + @param part the very line which was double-clicked: a core put down + from this dialog lands on that line, never on the first line of a + cable which stands on two folios + @param parent +*/ +CablePropertiesDialog::CablePropertiesDialog(Cable *cable, + Diagram *diagram, + CablePart *part, + QWidget *parent) : + QDialog(parent), + m_cable(cable), + m_diagram(diagram), + m_part(part) +{ + setWindowTitle(tr("Cable")); + + auto *layout = new QVBoxLayout(this); + m_fields = new CablePropertiesWidget(this); + layout->addWidget(m_fields); + + //The button beside the type field: it opens the window which + //opens when a cable line has just been drawn, in the mode where + //it changes the type of this very cable. + connect(m_fields, &CablePropertiesWidget::typeChangeRequested, + this, &CablePropertiesDialog::changeTypeRequested); + + m_cores = new QLabel(this); + m_cores->setWordWrap(true); + layout->addWidget(m_cores); + + //The cores the type offers which are not on the sheet yet. + //Double-clicking one brings it in: the line it was asked from + //takes the mouse and the next click puts the core down there. + m_free_label = new QLabel(tr("Unplaced cores:"), this); + m_free_cores = new QListWidget(this); + m_free_cores->setSelectionMode(QAbstractItemView::SingleSelection); + m_free_cores->setAlternatingRowColors(true); + m_free_cores->setToolTip(tr("Double-click a core to place it on the " + "line: the line follows the mouse and a " + "click leaves it where you aimed.")); + m_free_cores->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_free_cores, &QWidget::customContextMenuRequested, + this, &CablePropertiesDialog::freeCoreMenu); + connect(m_free_cores, &QListWidget::itemDoubleClicked, + this, &CablePropertiesDialog::placeRequested); + connect(m_free_cores, &QListWidget::itemActivated, + this, &CablePropertiesDialog::placeRequested); + layout->addWidget(m_free_label); + layout->addWidget(m_free_cores); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, &CablePropertiesDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &CablePropertiesDialog::reject); + layout->addWidget(buttons); + + if (m_cable) { + m_fields->setProperties(m_cable->properties()); + } + updateCoreCount(); + + fillFreeCores(); + + setMinimumWidth(400); +} + +/** + @brief CablePropertiesDialog::updateCoreCount + Say how many cores this cable has now and how many of them are wired + to a conductor. It is said again after the type was changed, since + the new type may hold more cores -- or fewer. +*/ +void CablePropertiesDialog::updateCoreCount() +{ + if (!m_cores) return; + + if (!m_cable) + { + m_cores->setText(tr("No cable.")); + return; + } + + m_cores->setText(tr("%1 of %2 cores wired to a conductor", + "how many cores of this cable are wired", + m_cable->usedCoreCount()) + .arg(m_cable->usedCoreCount()) + .arg(m_cable->coreCount())); +} + +/** + @brief CablePropertiesDialog::changeTypeRequested + Open the window which opens when a cable line has just been drawn, + in the mode where it changes the type of this very cable: he picks + the new type there out of the cable type file, and the cable takes + it -- cores reworked, marks redrawn -- as one single undo step. + + Whatever he typed into the form is written first, so the change and + the typing cannot overwrite each other; and the form is filled in + again afterwards, since pressing OK right after would otherwise + write the old type back. +*/ +void CablePropertiesDialog::changeTypeRequested() +{ + if (!m_cable) return; + + apply(); + + CableCreateDialog dialog(m_diagram, m_cable, this); + if (dialog.exec() != QDialog::Accepted) return; + + m_fields->setProperties(m_cable->properties()); + updateCoreCount(); + fillFreeCores(); +} + +/** + @brief CablePropertiesDialog::fillFreeCores + List every core the type of this cable offers and which stands + nowhere on the sheet: those are the ones he can still put down. The + list is of no use when there is none, so it takes no room then. +*/ +void CablePropertiesDialog::fillFreeCores() +{ + if (!m_free_cores) return; + + m_free_cores->clear(); + for (int i = 0; m_cable && i < m_cable->coreCount(); ++i) + { + if (m_cable->hasCore(i)) continue; + + const QString color = m_cable->colorOfCore(i); + auto *item = new QListWidgetItem( + color.isEmpty() ? tr("Core %1").arg(i + 1) + : tr("Core %1 — %2").arg(i + 1).arg(color), + m_free_cores); + item->setData(Qt::UserRole, i); + } + + const bool any = m_part && m_free_cores->count() > 0; + m_free_label->setVisible(any); + m_free_cores->setVisible(any); + if (!any) return; + + //As high as the entries it really shows, six of them at most: + //a handful of free cores must not turn the dialog into a wall. + const int row = qMax(m_free_cores->sizeHintForRow(0), 18); + m_free_cores->setFixedHeight(row * qMin(6, m_free_cores->count()) + + 2 * m_free_cores->frameWidth() + 4); +} + +/** + @brief CablePropertiesDialog::placeRequested + He picked one of the cores which stand nowhere: whatever is typed in + the form is written first, the dialog closes, and the line he asked + it from takes the mouse -- so the core goes onto that very line, and + the next click leaves it standing there. + @param item the entry he double-clicked +*/ +void CablePropertiesDialog::placeRequested(QListWidgetItem *item) +{ + //A double click reports itself twice on some platforms (once as + //activated, once as double-clicked): the first one takes the + //mouse away, the second must not ask for it again. + if (!item || !m_part || m_placing) return; + + m_placing = true; + const int core = item->data(Qt::UserRole).toInt(); + QPointer part = m_part; + + apply(); + QDialog::accept(); + + part->startPlacingCore(core); +} + +/** + @brief CablePropertiesDialog::freeCoreMenu + The small menu a right click on a core which stands nowhere opens: + one entry, doing what a double click does -- this dialog writes + whatever he typed, closes itself, the line he asked it from takes + the mouse, the mark shows on that line at once and the mouse is put + down there, so that the very next click leaves the core standing on + that line. + + @param pos where he right-clicked, in list coordinates +*/ +void CablePropertiesDialog::freeCoreMenu(const QPoint &pos) +{ + QListWidgetItem *item = m_free_cores ? m_free_cores->itemAt(pos) : nullptr; + if (!item || !m_part || m_placing) return; + + m_free_cores->setCurrentItem(item); + + QMenu menu(m_free_cores); + QAction *place = menu.addAction(tr("Place this core on the line")); + place->setIcon(QET::Icons::Cable); + + if (menu.exec(m_free_cores->viewport()->mapToGlobal(pos)) == place) { + //Gone before he is sent off: this dialog is closing, the + //mouse goes to the line and nothing may be left standing + //over the place it is sent to. + menu.hide(); + placeRequested(item); + } +} + +/** + @brief CablePropertiesDialog::accept + Whatever was typed here is written into the cable as one undo step, + and the cable works out the cable field of every conductor it feeds + again on its own: rename the cable and every drawing which shows it + follows. +*/ +void CablePropertiesDialog::accept() +{ + apply(); + QDialog::accept(); +} + +/** + @brief CablePropertiesDialog::apply + Everything the form holds goes into the cable as one single undo + step -- and only when something really changed, so that closing the + dialog by any means leaves no step of undo to walk back through. +*/ +void CablePropertiesDialog::apply() +{ + if (!m_cable) return; + + const CableProperties before = m_cable->properties(); + const CableProperties after = m_fields->properties(); + if (after != before) + { + if (m_diagram) { + m_diagram->undoStack().push( + new ChangeCablePropertiesCommand(m_cable, before, after)); + } else { + m_cable->setProperties(after); + } + } +} diff --git a/sources/cable/cablepropertiesdialog.h b/sources/cable/cablepropertiesdialog.h new file mode 100644 index 000000000..b0eddcf1e --- /dev/null +++ b/sources/cable/cablepropertiesdialog.h @@ -0,0 +1,99 @@ +/* + 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 CABLEPROPERTIESDIALOG_H +#define CABLEPROPERTIESDIALOG_H + +#include "cable.h" + +#include +#include + +class CablePropertiesWidget; +class CablePart; +class Diagram; +class QLabel; +class QListWidget; +class QListWidgetItem; + +/** + @brief The dialog which edits a cable once it is drawn. + + Everything it holds belongs to the cable itself rather than to the + line which was drawn: whatever is typed here applies to every section + of that cable, on every folio, and the conductors it feeds get their + cable field worked out again as soon as it says it changed. + + Under the count of cores it lists the cores of the type which stand + nowhere on the sheet yet: double-clicking one sends it to the very + line this dialog was asked from, which takes the mouse until he + clicks and leaves the core standing there. +*/ +class CablePropertiesDialog : public QDialog +{ + Q_OBJECT + + public: + /** + @param cable the cable being edited + @param diagram the folio whose undo stack takes the edit + @param part the very line which was double-clicked, where a + core chosen in this dialog is to be put down + @param parent + */ + explicit CablePropertiesDialog(Cable *cable, + Diagram *diagram = nullptr, + CablePart *part = nullptr, + QWidget *parent = nullptr); + + void accept() override; + + private: + ///Everything the form holds goes into the cable, as one step + void apply(); + ///What the count of cores under the form says right now + void updateCoreCount(); + ///He asked for the window choosing another type for this cable + void changeTypeRequested(); + ///Lists the cores of the type which stand nowhere yet + void fillFreeCores(); + ///He double-clicked one of them: put it down on the line + void placeRequested(QListWidgetItem *item); + /** + The small menu a right click on a core which stands + nowhere opens: one entry doing what a double click does + -- the dialog closes, the line takes the mouse, the mark + shows on it at once and the mouse is put down there. + @param pos where he right-clicked, in list coordinates + */ + void freeCoreMenu(const QPoint &pos); + + private: + QPointer m_cable; + Diagram *m_diagram = nullptr; + ///The line whose dialog this is, and the one a core chosen + ///here is put down on + QPointer m_part; + CablePropertiesWidget *m_fields = nullptr; + QLabel *m_cores = nullptr; + QLabel *m_free_label = nullptr; + QListWidget *m_free_cores = nullptr; + ///True from the moment he picks a core to put down + bool m_placing = false; +}; + +#endif // CABLEPROPERTIESDIALOG_H diff --git a/sources/cable/cablepropertieseditorwidget.cpp b/sources/cable/cablepropertieseditorwidget.cpp new file mode 100644 index 000000000..a5516aaeb --- /dev/null +++ b/sources/cable/cablepropertieseditorwidget.cpp @@ -0,0 +1,389 @@ +/* + 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 "cablepropertieseditorwidget.h" + +#include "cablecreatedialog.h" +#include "cablepart.h" +#include "cablepropertieswidget.h" +#include "editcablecommand.h" +#include "../qeticons.h" +#include "../qetproject.h" + +#include +#include +#include +#include +#include +#include +#include + +/** + @brief CablePropertiesEditorWidget::CablePropertiesEditorWidget + @param cable the cable being edited + @param part the line of that cable this panel was opened by, which + is the line a core picked here is put down on + @param parent parent widget +*/ +CablePropertiesEditorWidget::CablePropertiesEditorWidget(Cable *cable, + CablePart *part, + QWidget *parent) : + PropertiesEditorWidget(parent), + m_fields(new CablePropertiesWidget(this)), + m_info(new QLabel(this)), + m_free_label(new QLabel(this)), + m_free_cores(new QListWidget(this)) +{ + m_info->setWordWrap(true); + + //The cores which stand nowhere yet, listed under the count of + //wired ones: double-clicking one puts it down on this very line + //without any dialog having to be opened for it. The list takes + //no keyboard focus, so clicking it never takes the drawing away + //from the user -- startPlacingCore() hands the focus to the + //sheet itself, which is where Escape has to be heard. + m_free_label->setText(tr("Unplaced cores:")); + m_free_label->setVisible(false); + m_free_cores->setSelectionMode(QAbstractItemView::SingleSelection); + m_free_cores->setAlternatingRowColors(true); + m_free_cores->setFocusPolicy(Qt::NoFocus); + m_free_cores->setToolTip(tr("Double-click a core to place it on the " + "line: the line follows the mouse and a " + "click leaves it where you aimed.")); + m_free_cores->setVisible(false); + m_free_cores->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_free_cores, &QWidget::customContextMenuRequested, + this, &CablePropertiesEditorWidget::freeCoreMenu); + connect(m_free_cores, &QListWidget::itemDoubleClicked, + this, &CablePropertiesEditorWidget::placeRequested); + connect(m_free_cores, &QListWidget::itemActivated, + this, &CablePropertiesEditorWidget::placeRequested); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(4); + layout->addWidget(m_fields); + + //The button beside the type field: it opens the window which + //opens when a cable line has just been drawn, in the mode where + //it changes the type of this very cable. + connect(m_fields, &CablePropertiesWidget::typeChangeRequested, + this, &CablePropertiesEditorWidget::changeTypeRequested); + + layout->addWidget(m_info); + layout->addWidget(m_free_label); + layout->addWidget(m_free_cores); + layout->addStretch(1); + + //Wide enough for the labels, the ticks, the fields, the buttons + //setting how a text is written and the two distances nudging + //the label and the row of colours -- they sit side by side. + setMinimumWidth(250); + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + + setDisabled(true); + setCable(cable, part); +} + +/** + @brief CablePropertiesEditorWidget::setCable + Set (or change) the cable whose properties are edited. + @param cable + @param part the line of that cable this panel was opened by: the one + a core picked in the list is put down on, and null while no single + line is selected -- then nothing can be put down and the list stays + out of the way +*/ +void CablePropertiesEditorWidget::setCable(Cable *cable, CablePart *part) +{ + if (!cable) return; + m_part = part; + if (m_cable && m_cable != cable) + disconnect(m_cable, nullptr, this, nullptr); + + m_cable = cable; + //Keep the panel in sync when the cable is edited elsewhere (the + //dialog reached by double-clicking its line, for one): otherwise + //the panel would write back what it held before that edit. + connect(m_cable, &Cable::changed, + this, &CablePropertiesEditorWidget::updateUi, Qt::UniqueConnection); + setEnabled(true); + updateUi(); +} + +/** + @brief CablePropertiesEditorWidget::setLiveEdit + In live-edit mode (how the panel uses every editor) an edit is + applied as soon as a field is finished, rather than waiting for a + call to apply(). On top of that the text itself is written onto the + cable with every character, so what he types shows up on the drawing + while he is still typing it -- the whole editing of one field stays + one single undo step, which is worked out when the field is left. + @param live_edit true to enable live edit + @return always true +*/ +bool CablePropertiesEditorWidget::setLiveEdit(bool live_edit) +{ + if (m_live_edit == live_edit) return true; + m_live_edit = live_edit; + + if (m_live_edit) { + m_live_connection = connect(m_fields, &CablePropertiesWidget::propertiesEdited, + this, &CablePropertiesEditorWidget::apply); + connect(m_fields, &CablePropertiesWidget::propertiesTyped, + this, &CablePropertiesEditorWidget::applyLive); + } else { + disconnect(m_live_connection); + disconnect(m_fields, &CablePropertiesWidget::propertiesTyped, + this, &CablePropertiesEditorWidget::applyLive); + } + return true; +} + +/** + @brief CablePropertiesEditorWidget::applyLive + Put what is being typed straight onto the cable, without pushing an + undo step: the whole editing of one field is one step, which + apply() works out from what the panel showed when the field is left. +*/ +void CablePropertiesEditorWidget::applyLive() +{ + //Ignore the signals sent while the form is being filled in: at + //that moment it holds a half loaded state. + if (m_updating) return; + if (!m_cable) return; + + m_live_cable = m_cable; + m_cable->setProperties(m_fields->properties()); +} + +/** + @brief CablePropertiesEditorWidget::changeTypeRequested + Open the window which opens when a cable line has just been drawn, + in the mode where it changes the type of this very cable: he picks + the new type there out of the cable type file, and the cable takes + it -- cores reworked, marks redrawn -- as one single undo step. + + The form is filled in again afterwards rather than through the usual + path: the type has just changed under it, and it may not go on + offering the one which was left. +*/ +void CablePropertiesEditorWidget::changeTypeRequested() +{ + if (!m_cable) return; + + CableCreateDialog dialog(m_part ? m_part->diagram() : nullptr, + m_cable, + this); + if (dialog.exec() != QDialog::Accepted) return; + + m_updating = true; + m_fields->setProperties(m_cable->properties()); + m_shown = m_cable->properties(); + m_updating = false; + updateInfo(); +} + +/** + @brief CablePropertiesEditorWidget::apply + Put what was typed onto the cable as one undo step. +*/ +void CablePropertiesEditorWidget::apply() +{ + //Ignore the signals sent while the form is being filled in: at + //that moment it holds a half loaded state. + if (m_updating) return; + if (!m_cable) return; + + if (QUndoCommand *undo = associatedUndo()) + { + auto *project = qobject_cast(m_cable->parent()); + if (project && project->undoStack()) { + project->undoStack()->push(undo); + } else { + m_cable->setProperties(m_fields->properties()); + delete undo; + } + } + m_shown = m_fields->properties(); +} + +/** + @brief CablePropertiesEditorWidget::associatedUndo + @return the edit as one undo step, or nullptr when nothing changed +*/ +QUndoCommand *CablePropertiesEditorWidget::associatedUndo() const +{ + if (!m_cable) return nullptr; + + const CableProperties after = m_fields->properties(); + if (after == m_shown) return nullptr; + return new ChangeCablePropertiesCommand(m_cable, m_shown, after); +} + +/** + @brief CablePropertiesEditorWidget::reset + Discard the edit in progress, putting back what the panel showed. +*/ +void CablePropertiesEditorWidget::reset() +{ + if (!m_cable) return; + m_updating = true; + m_fields->setProperties(m_shown); + m_updating = false; +} + +/** + @brief CablePropertiesEditorWidget::updateInfo + Say how many cores of this cable are wired to a conductor. +*/ +void CablePropertiesEditorWidget::updateInfo() +{ + if (!m_cable) return; + + m_info->setText(tr("%1 of %2 cores wired to a conductor", + "how many cores of this cable are wired", + m_cable->usedCoreCount()) + .arg(m_cable->usedCoreCount()) + .arg(m_cable->coreCount())); + + updateFreeCores(); +} + +/** + @brief CablePropertiesEditorWidget::updateFreeCores + List the cores the type of this cable offers which stand nowhere on + the sheet yet: those are the ones he can still put down, and they + come down straight from here, on the line this panel was opened by. + + The list is of no use when there is none -- and it cannot work at + all when no single line of the cable is the selected one, since a + core then has no line to be put down on -- so it takes no room then. +*/ +void CablePropertiesEditorWidget::updateFreeCores() +{ + if (!m_free_cores) return; + + const bool usable = m_cable && m_part && m_part->cable() == m_cable; + + m_free_cores->clear(); + if (usable) + { + for (int i = 0; i < m_cable->coreCount(); ++i) + { + if (m_cable->hasCore(i)) continue; + + const QString color = m_cable->colorOfCore(i); + auto *item = new QListWidgetItem( + color.isEmpty() ? tr("Core %1").arg(i + 1) + : tr("Core %1 — %2").arg(i + 1).arg(color), + m_free_cores); + item->setData(Qt::UserRole, i); + } + } + + const bool any = usable && m_free_cores->count() > 0; + m_free_label->setVisible(any); + m_free_cores->setVisible(any); + if (!any) return; + + //As high as the entries it really shows, four of them at most: + //the dock is small, and a handful of free cores must not turn + //it into a wall. + const int row = qMax(m_free_cores->sizeHintForRow(0), 18); + m_free_cores->setFixedHeight(row * qMin(4, m_free_cores->count()) + + 2 * m_free_cores->frameWidth() + 4); +} + +/** + @brief CablePropertiesEditorWidget::placeRequested + He double-clicked one of the cores which stand nowhere: this very + line takes the mouse and waits for the click which puts the core + down. There is no dialog to close here -- the panel stays where it + is, and it reads the cable back as soon as the core lands. + + @param item the entry he double-clicked +*/ +void CablePropertiesEditorWidget::placeRequested(QListWidgetItem *item) +{ + if (!item || !m_part) return; + + m_part->startPlacingCore(item->data(Qt::UserRole).toInt()); +} + +/** + @brief CablePropertiesEditorWidget::freeCoreMenu + The small menu a right click on a core which stands nowhere opens: + one entry, doing what a double click does -- the line takes the + mouse, the mark shows on it at once and the mouse is put down there, + so the very next click leaves the core standing on that line. + + @param pos where he right-clicked, in list coordinates +*/ +void CablePropertiesEditorWidget::freeCoreMenu(const QPoint &pos) +{ + QListWidgetItem *item = m_free_cores ? m_free_cores->itemAt(pos) : nullptr; + if (!item || !m_part) return; + + m_free_cores->setCurrentItem(item); + + QMenu menu(m_free_cores); + QAction *place = menu.addAction(tr("Place this core on the line")); + place->setIcon(QET::Icons::Cable); + + if (menu.exec(m_free_cores->viewport()->mapToGlobal(pos)) == place) { + //Gone before he is sent off: the mouse goes to the line + //and nothing may be left standing over the place it is + //sent to. + menu.hide(); + placeRequested(item); + } +} + +/** + @brief CablePropertiesEditorWidget::updateUi + Read the cable back into the panel. + + Not while a field is being typed in: writing what was just typed + back into that field would take the cursor away from the user with + every key, and it would take back the state the whole editing of + that field is measured against when the undo step is worked out. +*/ +void CablePropertiesEditorWidget::updateUi() +{ + if (!m_cable) return; + + if (m_fields->editing() && m_live_cable == m_cable) { + updateInfo(); + return; + } + + m_updating = true; + m_fields->setProperties(m_cable->properties()); + m_shown = m_cable->properties(); + updateInfo(); + m_updating = false; +} + +/** + @brief CablePropertiesEditorWidget::title + @return the panel title +*/ +QString CablePropertiesEditorWidget::title() const +{ + return tr("Cable"); +} diff --git a/sources/cable/cablepropertieseditorwidget.h b/sources/cable/cablepropertieseditorwidget.h new file mode 100644 index 000000000..52268a0ce --- /dev/null +++ b/sources/cable/cablepropertieseditorwidget.h @@ -0,0 +1,115 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef CABLEPROPERTIESEDITORWIDGET_H +#define CABLEPROPERTIESEDITORWIDGET_H + +#include "cable.h" + +#include "../PropertiesEditor/propertieseditorwidget.h" + +#include +#include +#include + +class CablePropertiesWidget; +class CablePart; +class QLabel; +class QListWidget; +class QListWidgetItem; + +/** + @brief The cable shown in the "Propriétés de la sélection" panel. + + Selecting a line of a cable used to bring up nothing at all there: + the panel only knew the item types it already had an editor for. + Now it holds the very fields the cable is filled in with -- each of + them with its own tick saying whether the drawing shows it -- and + what stands there is written back live, one undo step per edit. +*/ +class CablePropertiesEditorWidget : public PropertiesEditorWidget +{ + Q_OBJECT + + public: + explicit CablePropertiesEditorWidget(Cable *cable = nullptr, + CablePart *part = nullptr, + QWidget *parent = nullptr); + + void setCable(Cable *cable, CablePart *part = nullptr); + + void apply() override; + void reset() override; + void updateUi() override; + QUndoCommand *associatedUndo() const override; + QString title() const override; + bool setLiveEdit(bool live_edit) override; + + private: + ///Write what is being typed onto the cable right away + void applyLive(); + ///He asked for the window choosing another type for this cable + void changeTypeRequested(); + ///Say how many cores of this cable are wired + void updateInfo(); + /** + List the cores which stand nowhere on the sheet yet, under + the count of wired ones: those are the ones he can still + put down, and double-clicking one puts it down straight + from here, on the line this panel was opened for. + */ + void updateFreeCores(); + ///He double-clicked one of those cores: give it this line + void placeRequested(QListWidgetItem *item); + /** + He right-clicked one of them: a small menu with the one + thing which can be done to a core which stands nowhere + -- putting it down -- for whoever does not know that a + double click does the same. + @param pos where he clicked, in list coordinates + */ + void freeCoreMenu(const QPoint &pos); + + private: + CablePropertiesWidget *m_fields = nullptr; + QLabel *m_info = nullptr; + ///The cores which stand nowhere yet, listed under the count + QLabel *m_free_label = nullptr; + QListWidget *m_free_cores = nullptr; + QPointer m_cable; + /** + The line this panel was opened by, which is the one a core + picked here is put down on: taking a core always belongs to + the line whose dialog was asked for, never to the first + line of the cable. Null while no single line is selected, + and then there is nothing to put a core down on and the + list takes no room. + */ + QPointer m_part; + ///What the panel showed before the edit: the fields which + ///differ from it are the ones the user changed + CableProperties m_shown; + ///The cable the text is being typed straight into, so that + ///reading the cable back only skips the very field which is + ///under the fingers of the user -- and never for another + ///cable which has just become the selected one + QPointer m_live_cable; + QMetaObject::Connection m_live_connection; + bool m_updating = false; +}; + +#endif // CABLEPROPERTIESEDITORWIDGET_H diff --git a/sources/cable/cablepropertieswidget.cpp b/sources/cable/cablepropertieswidget.cpp new file mode 100644 index 000000000..5728c777f --- /dev/null +++ b/sources/cable/cablepropertieswidget.cpp @@ -0,0 +1,409 @@ +/* + 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 "cablepropertieswidget.h" + +#include "cabletextformatdialog.h" +#include "../qetapp.h" +#include "../qeticons.h" +#include "../utils/qetutils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + @brief CablePropertiesWidget::CablePropertiesWidget + One row per field: its name, the tick saying whether the drawing + shows it, the field itself, then the button setting how that one + text is written -- one last row for the colours of the cores, which + are written all together along the line, and one row per distance + he may nudge the whole label and that row of colours with. + @param parent +*/ +CablePropertiesWidget::CablePropertiesWidget(QWidget *parent) : + QWidget(parent) +{ + auto *grid = new QGridLayout(this); + grid->setContentsMargins(0, 0, 0, 0); + grid->setHorizontalSpacing(6); + grid->setVerticalSpacing(3); + + auto *plan = new QLabel(tr("Drawing", + "column header: the drawing shows this field"), this); + plan->setAlignment(Qt::AlignCenter); + plan->setToolTip(tr("Tick to write this field on the drawing.", + "tooltip of the tick before each field")); + grid->addWidget(plan, 0, 1, Qt::AlignCenter); + + int row = 1; + addField(grid, row++, QStringLiteral("designation"), + tr("Designation"), &m_designation, &m_show_designation); + const int type_row = row; + addField(grid, row++, QStringLiteral("type"), + tr("Type"), &m_type, &m_show_type); + + //A button beside the type field, opening the very window which + //opens when a cable line has just been drawn: the type is + //picked from the cable type file there -- with its search, its + //filter and its own button for a type the file does not hold + //yet -- rather than typed in as plain text. + auto *pick_type = new QPushButton(this); + pick_type->setFocusPolicy(Qt::NoFocus); + pick_type->setIcon(QET::Icons::Cable); + pick_type->setToolTip(tr("Choose the type of this cable from the list of " + "cable types…")); + connect(pick_type, &QPushButton::clicked, + this, &CablePropertiesWidget::typeChangeRequested); + grid->addWidget(pick_type, type_row, 4, Qt::AlignRight | Qt::AlignVCenter); + + addField(grid, row++, QStringLiteral("installation"), + tr("Plant (=)"), &m_installation, &m_show_installation); + addField(grid, row++, QStringLiteral("location"), + tr("Location (+)"), &m_location, &m_show_location); + addField(grid, row++, QStringLiteral("length"), + tr("Length"), &m_length, &m_show_length); + + //The colours of the cores: one setting for every one of them at + //once, since they are written in one row along the line and are + //read as one row. + auto *cores_label = new QLabel(tr("Core colors"), this); + cores_label->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + cores_label->setToolTip(tr("The font of the colors of all the cores " + "of this cable, written along the line.")); + grid->addWidget(cores_label, row, 0, Qt::AlignRight | Qt::AlignVCenter); + addFormatButton(grid, row, QStringLiteral("cores")); + + //Where that whole label stands: one distance along the line and + //one across it, in pixels, moving the label as one thing -- + //installation, place and designation over the line, type and + //length under it -- and the line not at all. + addOffsetRow(grid, row + 1, tr("Text position"), + tr("Shifts all the cable text: plant, location " + "and designation on one side, type and length " + "on the other. The line, the slashes and the " + "wiring stay in place. 10 px make one grid cell."), + &m_text_offset_x, &m_text_offset_y); + + //And where the row of colours stands from the slashes it names. + addOffsetRow(grid, row + 2, tr("Core colors position"), + tr("Shifts the core colors relative to their slashes. " + "The slashes, the entries and the wiring stay in place. " + "10 px make one grid cell."), + &m_core_offset_x, &m_core_offset_y); + + grid->setColumnStretch(2, 1); +} + +/** + @brief CablePropertiesWidget::addField + Build one row of the form. + @param grid the form being built + @param row where to put it + @param key the name this text goes by in the file + @param name what the field holds + @param edit receives the line edit + @param tick receives the tick before it +*/ +void CablePropertiesWidget::addField(QGridLayout *grid, + int row, + const QString &key, + const QString &name, + QLineEdit **edit, + QCheckBox **tick) +{ + auto *label = new QLabel(name, this); + auto *check = new QCheckBox(this); + check->setToolTip(tr("Tick to write this field on the drawing.", + "tooltip of the tick before each field")); + auto *field = new QLineEdit(this); + field->setClearButtonEnabled(true); + + connect(field, &QLineEdit::editingFinished, + this, &CablePropertiesWidget::propertiesEdited); + //Every character too: what is typed is written onto the cable + //as it stands, so the drawing follows while he is still typing + //rather than only once he leaves the field. + connect(field, &QLineEdit::textChanged, + this, &CablePropertiesWidget::propertiesTyped); + connect(check, &QCheckBox::toggled, + this, &CablePropertiesWidget::propertiesEdited); + + grid->addWidget(label, row, 0, Qt::AlignRight | Qt::AlignVCenter); + grid->addWidget(check, row, 1, Qt::AlignCenter); + grid->addWidget(field, row, 2); + addFormatButton(grid, row, key); + + *edit = field; + *tick = check; +} + +/** + @brief CablePropertiesWidget::addFormatButton + The small button standing at the right of a text: it says which + size that text is written with, and opens the window where that + size -- and the rest of how the text is written -- is set. + @param grid the form being built + @param row where to put it + @param key the name of the text + @return the button, so the caller may reach it again +*/ +QPushButton *CablePropertiesWidget::addFormatButton(QGridLayout *grid, + int row, + const QString &key) +{ + auto *button = new QPushButton(this); + button->setFocusPolicy(Qt::NoFocus); + connect(button, &QPushButton::clicked, this, + [this, key]() {editFormat(key);}); + + grid->addWidget(button, row, 3, Qt::AlignRight | Qt::AlignVCenter); + m_format_buttons.insert(key, button); + refreshFormatButton(key); + return button; +} + +/** + @brief CablePropertiesWidget::addOffsetRow + Build one of the two rows nudging what is drawn: a name at the left + of the form, then two distances in pixels -- one measured along the + line, one across it -- the way the cross references of a slave offer + theirs. + + Both boxes speak of the drawing and nothing else: what he types here + moves texts, and the line, the slashes and the wires behind them keep + standing exactly where they were. + @param grid the form being built + @param row where to put it + @param name what that distance moves + @param tip what it does and does not touch + @param dx receives the spin box for the distance along the line + @param dy receives the spin box for the distance across it +*/ +void CablePropertiesWidget::addOffsetRow(QGridLayout *grid, + int row, + const QString &name, + const QString &tip, + QSpinBox **dx, + QSpinBox **dy) +{ + auto *label = new QLabel(name, this); + label->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + label->setToolTip(tip); + + const auto spin = [this, tip]() { + auto *box = new QSpinBox(this); + box->setRange(-200, 200); + box->setSingleStep(1); + box->setSuffix(QStringLiteral(" px")); + box->setAlignment(Qt::AlignRight); + box->setToolTip(tip); + connect(box, &QSpinBox::valueChanged, + this, &CablePropertiesWidget::propertiesEdited); + return box; + }; + QSpinBox *x_box = spin(); + QSpinBox *y_box = spin(); + + auto *along = new QLabel(tr("X :"), this); + along->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + auto *across = new QLabel(tr("Y :"), this); + across->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + + auto *values = new QHBoxLayout; + values->setContentsMargins(0, 0, 0, 0); + values->setSpacing(4); + values->addWidget(along); + values->addWidget(x_box); + values->addWidget(across); + values->addWidget(y_box); + values->addStretch(1); + + grid->addWidget(label, row, 0, Qt::AlignRight | Qt::AlignVCenter); + grid->addLayout(values, row, 2); + + *dx = x_box; + *dy = y_box; +} + +/** + @brief CablePropertiesWidget::editFormat + Open the window where one text of the cable is set, and -- when he + came out of it having really changed something -- write the answer + back into the form. One window is one change, so one step of undo. + @param key the name of the text +*/ +void CablePropertiesWidget::editFormat(const QString &key) +{ + CableTextFormatDialog dialog(m_formats.value(key), coresKey(key), this); + if (dialog.exec() != QDialog::Accepted) return; + + const CableTextFormat answer = dialog.format(); + if (m_formats.value(key) == answer) return; + + if (answer == CableTextFormat()) m_formats.remove(key); + else m_formats.insert(key, answer); + + refreshFormatButton(key); + emit propertiesEdited(); +} + +/** + @brief CablePropertiesWidget::refreshFormatButton + Let the button of one text say which size that text is written with, + so that the size is read off the form rather than only in the + drawing. + @param key the name of the text +*/ +void CablePropertiesWidget::refreshFormatButton(const QString &key) +{ + QPushButton *button = m_format_buttons.value(key); + if (!button) return; + + const QFont font = effectiveFont(key); + button->setText(tr("%1 pt").arg(QString::number(font.pointSizeF(), 'g', 3))); + + const QString tip = coresKey(key) + ? tr("Font, size and style of the core colors.") + : tr("Font, size, style and alignment of this text."); + const CableTextFormat format = m_formats.value(key); + const bool its_own = !format.font.isEmpty() || format.alignment != 0; + button->setToolTip(tip + QLatin1Char(' ') + (its_own + ? tr("Set for this cable.") + : tr("Value defined in the preferences."))); +} + +/** + @brief CablePropertiesWidget::effectiveFont + @param key the name of a text of the form + @return the font that text really is written with on the drawing: + the one this cable asks for, or -- when it asks for none -- the one + the preferences offer +*/ +QFont CablePropertiesWidget::effectiveFont(const QString &key) const +{ + const CableTextFormat format = m_formats.value(key); + if (!format.font.isEmpty()) + { + QFont font; + if (QETUtils::fontFromString(font, format.font)) { + return font; + } + } + return coresKey(key) ? QETApp::cableCoreFont() : QETApp::cableTextsFont(); +} + +/** + @brief CablePropertiesWidget::properties + @return everything the form holds right now +*/ +CableProperties CablePropertiesWidget::properties() const +{ + CableProperties properties; + properties.designation = m_designation->text().trimmed(); + //A name which is not the one read from the cable can only have + //come from him typing it in: from that moment on the cable + //carries a name of his own rather than one the numbering rule + //handed out. Putting the very same name back by hand keeps the + //cable as it stood, which is what the field says. + properties.designation_by_hand = properties.designation == m_designation_text + ? m_designation_by_hand : true; + properties.type = m_type->text().trimmed(); + properties.installation = m_installation->text().trimmed(); + properties.location = m_location->text().trimmed(); + properties.length = m_length->text().trimmed(); + properties.show_designation = m_show_designation->isChecked(); + properties.show_type = m_show_type->isChecked(); + properties.show_installation = m_show_installation->isChecked(); + properties.show_location = m_show_location->isChecked(); + properties.show_length = m_show_length->isChecked(); + properties.texts = m_formats; + properties.text_offset = QPointF(m_text_offset_x->value(), + m_text_offset_y->value()); + properties.core_offset = QPointF(m_core_offset_x->value(), + m_core_offset_y->value()); + return properties; +} + +/** + @brief CablePropertiesWidget::setProperties + Fill the form in. The ticks say what is drawn, so a field which is + switched off still keeps the text standing in it -- hiding it on the + drawing never throws away what the user typed. + @param properties +*/ +void CablePropertiesWidget::setProperties(const CableProperties &properties) +{ + m_designation->setText(properties.designation); + //What the field holds now is what the cable said: anything he + //types from here on is a name of his own + m_designation_text = properties.designation; + m_designation_by_hand = properties.designation_by_hand; + m_type->setText(properties.type); + m_installation->setText(properties.installation); + m_location->setText(properties.location); + m_length->setText(properties.length); + m_show_designation->setChecked(properties.show_designation); + m_show_type->setChecked(properties.show_type); + m_show_installation->setChecked(properties.show_installation); + m_show_location->setChecked(properties.show_location); + m_show_length->setChecked(properties.show_length); + + //Filling the form in is not the user nudging anything: those + //two rows are muted while they are set, so that loading a cable + //can never look like an edit which then has to be undone. + const QSignalBlocker block_text_x(m_text_offset_x); + const QSignalBlocker block_text_y(m_text_offset_y); + const QSignalBlocker block_core_x(m_core_offset_x); + const QSignalBlocker block_core_y(m_core_offset_y); + m_text_offset_x->setValue(qRound(properties.text_offset.x())); + m_text_offset_y->setValue(qRound(properties.text_offset.y())); + m_core_offset_x->setValue(qRound(properties.core_offset.x())); + m_core_offset_y->setValue(qRound(properties.core_offset.y())); + + //The texts this cable writes its own way. Only what really has + //an answer of its own is kept, so that a stray empty entry can + //never make the form claim a change it never made. + m_formats.clear(); + for (auto it = properties.texts.cbegin(); it != properties.texts.cend(); ++it) + { + if (it.value() == CableTextFormat()) continue; + m_formats.insert(it.key(), it.value()); + } + for (auto it = m_format_buttons.cbegin(); it != m_format_buttons.cend(); ++it) { + refreshFormatButton(it.key()); + } +} + +/** + @brief CablePropertiesWidget::editing + @return true when the keyboard stands in one of the fields of this + form. The panel uses it to keep its hands off what is being typed: + writing the cable back into the form while he is typing would take + the cursor away from him with every key. +*/ +bool CablePropertiesWidget::editing() const +{ + QWidget *focus = QApplication::focusWidget(); + return focus && (focus == this || isAncestorOf(focus)); +} diff --git a/sources/cable/cablepropertieswidget.h b/sources/cable/cablepropertieswidget.h new file mode 100644 index 000000000..ea4b83fef --- /dev/null +++ b/sources/cable/cablepropertieswidget.h @@ -0,0 +1,158 @@ +/* + 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 CABLEPROPERTIESWIDGET_H +#define CABLEPROPERTIESWIDGET_H + +#include "cable.h" + +#include + +class QCheckBox; +class QGridLayout; +class QLineEdit; +class QPushButton; +class QSpinBox; + +/** + @brief The fields a cable is filled in with, and the tick before each + of them saying whether what stands there is written on the drawing. + + It holds no cable of its own: it is a plain form, filled in with + setProperties() and read back with properties(). That way the dialog + editing one cable and the selection panel of QElectroTech offer + exactly the same fields in the same order, and neither of them can + drift away from the other. + + Every field is followed by a checkbox: what the checkbox says is + written next to the trunk line of the drawing, what it does not say + stays in the cable alone and only shows up in lists. + + Behind every field stands a button opening the window where that one + text is written: its font, its size, its style and -- for the texts + drawn next to the line -- the way it lines up there. The colours of + the cores get a button of their own at the foot of the form, all of + them at once, because they are written in one row along the line and + are never set apart. + + The type field carries one more button, on the right of that row. It + does not say how the word is written: it asks for the window which + opens when a cable line has just been drawn, where a type is picked + out of the cable type file -- with its search, its filter and its own + button for a type the file does not hold yet -- and taken onto the + cable. Whoever holds this form answers typeChangeRequested(). + + At the foot of the form stand the two distances he may nudge: one for + the whole label, one for the row of colours. Both are on the drawing + only -- the line, the slashes and the wiring stay where they are. +*/ +class CablePropertiesWidget : public QWidget +{ + Q_OBJECT + + public: + explicit CablePropertiesWidget(QWidget *parent = nullptr); + + ///What the form holds right now + CableProperties properties() const; + ///Fill the form in + void setProperties(const CableProperties &properties); + ///True while one of the fields is being typed in + bool editing() const; + + signals: + ///Emitted when a field or a tick was really changed by hand + void propertiesEdited(); + ///Emitted with every character typed, so the drawing can + ///follow the text as it is written rather than only once the + ///field is left + void propertiesTyped(); + ///Emitted when he asks for the window choosing the type of + ///this cable out of the cable type file + void typeChangeRequested(); + + private: + void addField(QGridLayout *grid, + int row, + const QString &key, + const QString &name, + QLineEdit **edit, + QCheckBox **tick); + ///The button opening the window which sets one text + QPushButton *addFormatButton(QGridLayout *grid, int row, const QString &key); + /** + Build one row of the two spin boxes nudging what is + drawn: one for the label of the cable, one for the row + of its colours. + @param grid the form being built + @param row where to put it + @param name what the row moves + @param tip what that distance does and does not touch + @param dx receives the spin box for the distance along + the line + @param dy receives the spin box for the distance across + it + */ + void addOffsetRow(QGridLayout *grid, + int row, + const QString &name, + const QString &tip, + QSpinBox **dx, + QSpinBox **dy); + ///Open the window setting one text, then say so if it changed + void editFormat(const QString &key); + ///Let the button say which size that text is written with + void refreshFormatButton(const QString &key); + ///The font this text is really written with right now + QFont effectiveFont(const QString &key) const; + ///True when this key names the colours of the cores + static bool coresKey(const QString &key) {return key == QLatin1String("cores");} + + private: + QLineEdit *m_designation = nullptr; + /** + The name exactly as it was read from the cable, and + whether that name was typed in: anything else he puts + into the field from then on is his own work, and the + cable is marked as carrying a name he chose (see + properties()) + */ + QString m_designation_text; + bool m_designation_by_hand = false; + QLineEdit *m_type = nullptr; + QLineEdit *m_installation = nullptr; + QLineEdit *m_location = nullptr; + QLineEdit *m_length = nullptr; + QCheckBox *m_show_designation = nullptr; + QCheckBox *m_show_type = nullptr; + QCheckBox *m_show_installation = nullptr; + QCheckBox *m_show_location = nullptr; + QCheckBox *m_show_length = nullptr; + ///What each text of this form is written with; a text which + ///is not in here follows what the preferences say + QHash m_formats; + ///The button opening the window of each text, by name + QHash m_format_buttons; + ///How far the label of the cable is nudged on the drawing + QSpinBox *m_text_offset_x = nullptr; + QSpinBox *m_text_offset_y = nullptr; + ///How far the row of colours is nudged on the drawing + QSpinBox *m_core_offset_x = nullptr; + QSpinBox *m_core_offset_y = nullptr; +}; + +#endif // CABLEPROPERTIESWIDGET_H diff --git a/sources/cable/cablerenumber.cpp b/sources/cable/cablerenumber.cpp new file mode 100644 index 000000000..4d9b23159 --- /dev/null +++ b/sources/cable/cablerenumber.cpp @@ -0,0 +1,292 @@ +/* + 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 "cablerenumber.h" + +#include "cable.h" +#include "../autoNum/assignvariables.h" +#include "../autoNum/numerotationcontextcommands.h" +#include "../diagram.h" +#include "../qetproject.h" + +#include +#include + +#include +#include + +namespace { + +/** + @brief One cable of the project, and where it stands so that the + cables can be laid out in an order everybody can see again. +*/ +struct OrderedCable +{ + Cable *cable = nullptr; + ///The lowest folio the cable is drawn on + int folio = 0; + ///How far it reaches to the left and how far to the top on + ///that folio + qreal left = 0.0; + qreal top = 0.0; + ///The folio the rule is read on for this cable + Diagram *diagram = nullptr; +}; + +/** + @brief Read a rule once, the way the numbering of a cable reads it. + + The rule is read on a copy, since reading it fills in what its + folio-bound parts are worth on that one folio, and that must not + creep into the rule itself. + @param context the rule as it stands at this point of the numbering + @param diagram the folio the number is worked out on + @param key the name the rule is kept under in the project + @return the number, empty when the rule says nothing here +*/ +QString readRule(NumerotationContext context, Diagram *diagram, const QString &key) +{ + const QString formula = autonum::numerotationContextToFormula(context); + autonum::sequentialNumbers seq; + autonum::setSequential(formula, seq, context, diagram, key); + return autonum::AssignVariables::formulaToLabel(formula, seq, diagram); +} + +} // namespace + +/** + @brief CableRenumber::byHandCount + @param project + @return how many cables of that project carry a name typed in by hand +*/ +int CableRenumber::byHandCount(QETProject *project) +{ + if (!project) return 0; + + int count = 0; + for (Cable *cable : project->cables()) + { + if (cable && cable->designationByHand()) { + ++count; + } + } + return count; +} + +/** + @brief CableRenumber::plan + Work the number of every cable of the project out, without writing + anywhere. The cables are put in the order they are numbered in -- + folio by folio, and on each folio along whichever axis leads -- and + the rule is then read from its beginning once for each of them, so + that the numbering runs on without a hole. + @param project the project whose cables are numbered + @param rule the numbering rule to read + @param x_axis_first true when the leftmost cable of a folio comes first + @param include_by_hand false to leave the names typed in by hand alone + @return the numbers, or what stopped the numbering +*/ +CableRenumberPlan CableRenumber::plan(QETProject *project, + const NumerotationContext &rule, + bool x_axis_first, + bool include_by_hand) +{ + CableRenumberPlan result; + if (!project) + { + result.error = QCoreApplication::translate( + "CableRenumber", "No project is open."); + return result; + } + if (project->isReadOnly()) + { + result.error = QCoreApplication::translate( + "CableRenumber", + "This project is read-only: its cables cannot be " + "renumbered."); + return result; + } + //The rule may be one the numbering window is drawing right now + //and has not written into the project yet: the numbering writes + //it together with the numbers themselves, so the project does + //not have to hold it beforehand. What the counters are kept + //under is the name the rule is stored with, which for cables is + //fixed either way -- so numbering starts from a project which + //has no rule saved yet just as well. + QString key = project->cableCurrentAutoNum(); + if (key.isEmpty()) { + key = QETProject::cableAutoNumRuleName(); + } + if (rule.isEmpty()) + { + result.error = QCoreApplication::translate( + "CableRenumber", + "No cable numbering rule is defined for this " + "project. Define one in this window before " + "renumbering its cables."); + return result; + } + + //Where every cable stands, and which ones are left alone: their + //names stay as they are, so the numbering must not hand the + //same number out again + QList ordered; + QSet taken; + for (Cable *cable : project->cables()) + { + if (!cable) continue; + if (!include_by_hand && cable->designationByHand()) + { + if (!cable->designation().isEmpty()) { + taken.insert(cable->designation()); + } + continue; + } + + OrderedCable entry; + entry.cable = cable; + + for (const CablePartData &part : cable->parts()) + { + Diagram *diagram = project->diagramByUuid(part.diagram); + if (!diagram) continue; + + const int index = project->folioIndex(diagram); + const qreal left = qMin(part.p1.x(), part.p2.x()); + const qreal top = qMin(part.p1.y(), part.p2.y()); + + if (!entry.diagram || index < entry.folio) + { + entry.folio = index; + entry.diagram = diagram; + entry.left = left; + entry.top = top; + } else if (index == entry.folio) { + entry.left = qMin(entry.left, left); + entry.top = qMin(entry.top, top); + } + } + //A cable which stands on no folio the project still knows + //is numbered last rather than first by accident + if (!entry.diagram) { + entry.folio = std::numeric_limits::max(); + } + ordered.append(entry); + } + + //Folio by folio, and on each folio along the axis which leads: + //the other axis is only asked when the leading one says two + //cables are level, so that the numbering never depends on the + //order the cables happen to be stored in + std::sort(ordered.begin(), ordered.end(), + [x_axis_first](const OrderedCable &a, const OrderedCable &b) + { + if (a.folio != b.folio) return a.folio < b.folio; + + const qreal a_first = x_axis_first ? a.left : a.top; + const qreal b_first = x_axis_first ? b.left : b.top; + if (a_first != b_first) return a_first < b_first; + + const qreal a_second = x_axis_first ? a.top : a.left; + const qreal b_second = x_axis_first ? b.top : b.left; + if (a_second != b_second) return a_second < b_second; + + return a.cable->uuid().toString() < b.cable->uuid().toString(); + }); + + //Read the rule from the beginning, once for each cable: its + //counters are put back first, since numbering a project over + //starts at the beginning whatever the last number was + NumerotationContext context = autonum::resetContextCounters(rule); + int current_folio = -1; + + for (const OrderedCable &entry : std::as_const(ordered)) + { + if (!entry.diagram) + { + result.error = QCoreApplication::translate( + "CableRenumber", + "A cable is not on any sheet of the project: " + "nothing was changed."); + return result; + } + + //A counter bound to the folio counts from the beginning of + //that folio: the first cable of a folio gets the first + //number of it, and the folios after it start again rather + //than carrying the number of the folio before on + if (entry.folio != current_folio) + { + context = autonum::resetFolioCounters(context); + current_folio = entry.folio; + } + + const QString candidate = readRule(context, entry.diagram, key); + if (candidate.isEmpty()) + { + result.error = QCoreApplication::translate( + "CableRenumber", + "The current rule gives no number here: " + "nothing was changed."); + return result; + } + //A rule which does not count hands out the same number + //twice, and two cables of one project may not be called + //the same thing + if (result.designations.contains(candidate)) + { + result.error = QCoreApplication::translate( + "CableRenumber", + "The current rule gives the same number (%1) twice: " + "it must count to renumber a whole project. " + "Nothing was changed.") + .arg(candidate); + return result; + } + //A name which stays because it was typed in keeps its + //number, and the numbering may not hand that number out + if (taken.contains(candidate)) + { + result.error = QCoreApplication::translate( + "CableRenumber", + "Number %1 is already used by a cable whose name " + "is left as it is: nothing was changed.") + .arg(candidate); + return result; + } + + taken.insert(candidate); + result.cables.append(entry.cable); + result.designations.append(candidate); + + //Step the rule on for the next cable; a rule which cannot + //step stays where it is and is caught by the check above + NumerotationContextCommands ncc(context, entry.diagram); + const NumerotationContext following = ncc.next(); + if (following.size() == context.size()) { + context = following; + } + } + + //Where the rule stands after the last cable has been numbered: + //the caller writes that into the project, so numbering goes on + //from there instead of from wherever the rule happened to stand + //before this numbering ran + result.after = context; + return result; +} diff --git a/sources/cable/cablerenumber.h b/sources/cable/cablerenumber.h new file mode 100644 index 000000000..c97513e8d --- /dev/null +++ b/sources/cable/cablerenumber.h @@ -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 . +*/ +#ifndef CABLERENUMBER_H +#define CABLERENUMBER_H + +#include +#include +#include + +#include "../autoNum/numerotationcontext.h" + +class Cable; +class QETProject; + +/** + @brief What numbering a whole project would do, worked out before a + single cable is written to. + + Working it out first means the answer can be refused as a whole: a + rule which hands out the same number twice, or a number some cable + which is left alone already carries, stops the whole numbering + rather than numbering half the project and then getting stuck. +*/ +struct CableRenumberPlan +{ + ///The cables to number, in the order they are numbered in + QList cables; + ///The number each of them gets, one for each cable + QStringList designations; + ///The rule as it stands once the last cable has been numbered: + ///its counters are where the next number would be taken from + NumerotationContext after; + ///What stopped the numbering, empty while it may be carried out + QString error; + + bool ok() const {return error.isEmpty();} +}; + +/** + @brief Working out the numbers a whole project should carry. + + Everything here is pure: nothing is written to the project and + nothing is put on the undo stack, so a caller may look at the plan + and decide against it without anything having happened. +*/ +namespace CableRenumber +{ + /** + How many cables of this project carry a name which was typed in + rather than handed out by the numbering rule. Those are the ones + renumbering asks about before it takes their name away. + @param project + @return the number of such cables + */ + int byHandCount(QETProject *project); + + /** + Work the number of every cable of the project out of @p rule. + + The cables are laid out first -- folio by folio, and on each + folio either from the left to the right or from the top to the + bottom, whichever axis leads -- and are then numbered one after + the other from the beginning of the rule, so that a hole left by + a cable which has been deleted closes behind it. + + @param project the project whose cables are numbered + @param rule the numbering rule to read; its counters are put + back to 1 first, since numbering starts from the beginning + @param x_axis_first true to number the cables of a folio from + the left to the right, false to number them from the top to the + bottom + @param include_by_hand false to leave the cables whose name was + typed in exactly as they are + @return the numbers, or what stopped the numbering. The plan + also carries the rule as it stands once every cable has been + given its number, so that the caller may put that into the + project: the counters have to stay where the last number was + taken from, or the next cable drawn would skip over every + number in between + */ + CableRenumberPlan plan(QETProject *project, + const NumerotationContext &rule, + bool x_axis_first, + bool include_by_hand); +} + +#endif // CABLERENUMBER_H diff --git a/sources/cable/cabletextformatdialog.cpp b/sources/cable/cabletextformatdialog.cpp new file mode 100644 index 000000000..550b46516 --- /dev/null +++ b/sources/cable/cabletextformatdialog.cpp @@ -0,0 +1,278 @@ +/* + 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 "cabletextformatdialog.h" + +#include "../qetapp.h" +#include "../utils/qetutils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + /** + @brief The way a text lines up, as the dialog lists it: right + first, which is where every text stands unless he says otherwise. + @param index the place in the list of the dialog + @return the flags the drawing understands + */ + Qt::Alignment alignmentFromIndex(int index) + { + switch (index) + { + case 1: return Qt::AlignHCenter; + case 2: return Qt::AlignLeft; + default: return Qt::AlignRight; + } + } + + /** + @brief The place in the list of the dialog of an alignment. + @param alignment Qt::AlignLeft, Qt::AlignHCenter or Qt::AlignRight + @return the index, 0 (right) when nothing is named + */ + int alignmentIndex(int alignment) + { + switch (alignment) + { + case int(Qt::AlignHCenter): return 1; + case int(Qt::AlignLeft): return 2; + default: return 0; + } + } + + /** + Whether two fonts would be read the very same way on the sheet: + the family he picks, the size he gives it, and the style he + chose among the four offered. + @param a the font he has chosen + @param b the font the preferences would write it with + @return true when nothing of them differs + */ + bool sameFont(const QFont &a, const QFont &b) + { + return a.family() == b.family() + && qFuzzyCompare(a.pointSizeF(), b.pointSizeF()) + && a.bold() == b.bold() + && a.italic() == b.italic(); + } +} + +/** + @brief CableTextFormatDialog::CableTextFormatDialog + @param format what this cable writes that text with right now + @param cores true for the colours of the cores + @param parent +*/ +CableTextFormatDialog::CableTextFormatDialog(const CableTextFormat &format, + bool cores, + QWidget *parent) : + QDialog(parent), + m_cores(cores) +{ + setWindowTitle(cores ? tr("Core colors") + : tr("Text format")); + buildUi(); + + //The font this text is written with right now: the one this + //cable asks for, or the one the preferences offer when it asks + //for none -- which is what he sees on the sheet as he opens this. + QFont font; + bool restored = false; + if (!format.font.isEmpty()) { + restored = QETUtils::fontFromString(font, format.font); + } + if (!restored) { + font = cores ? QETApp::cableCoreFont() : QETApp::cableTextsFont(); + } + + //Where this text stands right now, in the same way: the answer + //of the cable itself, or -- when it has none -- the one the + //preferences offer, so that he opens this onto what the sheet + //really shows rather than onto whatever the default used to be. + m_initial_alignment = cores ? 0 + : (format.alignment ? format.alignment + : int(QETApp::cableTextAlignment())); + + m_family->setCurrentFont(font); + m_size->setValue(font.pointSizeF()); + if (font.bold() && font.italic()) m_style->setCurrentIndex(3); + else if (font.italic()) m_style->setCurrentIndex(2); + else if (font.bold()) m_style->setCurrentIndex(1); + else m_style->setCurrentIndex(0); + if (m_alignment) { + m_alignment->setCurrentIndex(alignmentIndex(m_initial_alignment)); + } + + //Going back to the preferences means something only while this + //cable has an answer of its own: when it already follows them, + //there is nothing here to give back. + m_reset->setEnabled(!format.font.isEmpty() || format.alignment != 0); + updatePreview(); +} + +/** + @brief CableTextFormatDialog::buildUi + One row per thing he may set, then what it looks like -- because a + size means nothing as a number alone. +*/ +void CableTextFormatDialog::buildUi() +{ + auto *form = new QFormLayout; + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + form->setHorizontalSpacing(12); + + m_family = new QFontComboBox(this); + m_family->setFontFilters(QFontComboBox::AllFonts); + form->addRow(tr("Font:"), m_family); + + m_size = new QDoubleSpinBox(this); + m_size->setRange(1.0, 300.0); + m_size->setDecimals(1); + m_size->setSingleStep(0.5); + m_size->setSuffix(tr(" pt")); + form->addRow(tr("Size:"), m_size); + + m_style = new QComboBox(this); + m_style->addItem(tr("Normal")); + m_style->addItem(tr("Bold")); + m_style->addItem(tr("Italic")); + m_style->addItem(tr("Bold italic")); + form->addRow(tr("Style:"), m_style); + + //The colours of the cores stand turned by a quarter along their + //own line: there is nothing for them to line up with. + if (!m_cores) + { + m_alignment = new QComboBox(this); + m_alignment->addItem(tr("Right aligned")); + m_alignment->addItem(tr("Centred")); + m_alignment->addItem(tr("Left aligned")); + form->addRow(tr("Alignment:"), m_alignment); + } + + m_preview = new QLabel(tr("15W1 · H07V-K 3G1.5"), this); + m_preview->setFrameStyle(QFrame::Box | QFrame::Plain); + m_preview->setMinimumHeight(30); + m_preview->setIndent(6); + form->addRow(tr("Preview:"), m_preview); + + auto *buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + m_reset = buttons->addButton(tr("Reset"), QDialogButtonBox::ResetRole); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(m_reset, &QPushButton::clicked, this, [this]() { + m_reset_chosen = true; + accept(); + }); + + auto *layout = new QVBoxLayout(this); + layout->addLayout(form); + layout->addWidget(buttons); + setLayout(layout); + + connect(m_family, &QFontComboBox::currentFontChanged, + this, &CableTextFormatDialog::updatePreview); + connect(m_size, &QDoubleSpinBox::valueChanged, + this, &CableTextFormatDialog::updatePreview); + connect(m_style, &QComboBox::currentIndexChanged, + this, &CableTextFormatDialog::updatePreview); + if (m_alignment) { + connect(m_alignment, &QComboBox::currentIndexChanged, + this, &CableTextFormatDialog::updatePreview); + } +} + +/** + @brief CableTextFormatDialog::chosenFont + What the dialog offers right now: the family he picked, the size he + gave it and the style he chose among the four offered -- rather than + whatever that family happened to be called in the list. + @return the font +*/ +QFont CableTextFormatDialog::chosenFont() const +{ + QFont font = m_family->currentFont(); + font.setPointSizeF(m_size->value()); + const bool italic = m_style->currentIndex() == 2 || m_style->currentIndex() == 3; + const bool bold = m_style->currentIndex() == 1 || m_style->currentIndex() == 3; + font.setBold(bold); + font.setItalic(italic); + return font; +} + +/** + @brief CableTextFormatDialog::updatePreview + Write the line offered as an example the way it would be written + after what he has just set -- the only place where a size becomes + something one can judge. +*/ +void CableTextFormatDialog::updatePreview() +{ + m_preview->setFont(chosenFont()); + if (m_alignment) { + m_preview->setAlignment( + alignmentFromIndex(m_alignment->currentIndex()) | Qt::AlignVCenter); + } else { + m_preview->setAlignment(Qt::AlignCenter); + } +} + +/** + @brief CableTextFormatDialog::format + @return what the cable is to write that text with from now on. + + An empty answer means "the way the preferences say", and the two + halves of it are worked out apart from each other: he may set the + way one text lines up without taking away its freedom to follow a + new default font later, and the other way round. What already + stands the way the preferences say is left out, so that a cable + which said nothing keeps saying nothing -- and a later change of + what those preferences are still reaches it. +*/ +CableTextFormat CableTextFormatDialog::format() const +{ + if (m_reset_chosen) return CableTextFormat(); + + const QFont font = chosenFont(); + const QFont default_font = m_cores ? QETApp::cableCoreFont() + : QETApp::cableTextsFont(); + + CableTextFormat answer; + if (!sameFont(font, default_font)) { + answer.font = QETUtils::fontToString(font); + } + //The colours of the cores stand turned by a quarter: they have + //no line to line up with, and saying so would only be noise in + //the file. + if (!m_cores) + { + const int alignment = int(alignmentFromIndex(m_alignment->currentIndex())); + if (alignment != int(QETApp::cableTextAlignment())) { + answer.alignment = alignment; + } + } + return answer; +} diff --git a/sources/cable/cabletextformatdialog.h b/sources/cable/cabletextformatdialog.h new file mode 100644 index 000000000..49db070f2 --- /dev/null +++ b/sources/cable/cabletextformatdialog.h @@ -0,0 +1,83 @@ +/* + 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 CABLETEXTFORMATDIALOG_H +#define CABLETEXTFORMATDIALOG_H + +#include "cable.h" + +#include + +class QComboBox; +class QFontComboBox; +class QDoubleSpinBox; +class QLabel; +class QPushButton; + +/** + @brief The small window where one text of one cable is set: the font + it is written with -- family, size and style -- and, for the texts + written next to the trunk line, the way it lines up at the line. + + The colours of the cores come here as well, all of them together + because they are drawn together: they have no alignment, being + turned by a quarter along their own line. + + Saying nothing is the normal case: the cable then follows what the + preferences offer on the "Textes" page, and OK keeps an answer of + its own only when he really changed something -- so that picking + another default later on still reaches this cable. +*/ +class CableTextFormatDialog : public QDialog +{ + Q_OBJECT + + public: + /** + @param format what this cable writes that text with right + now, an empty format when it merely follows the preferences + @param cores true for the colours of the cores: they have + no alignment and follow QETApp::cableCoreFont() + @param parent + */ + CableTextFormatDialog(const CableTextFormat &format, + bool cores, + QWidget *parent = nullptr); + + ///What the cable is to write that text with from now on + CableTextFormat format() const; + + private: + void buildUi(); + void updatePreview(); + QFont chosenFont() const; + + bool m_cores = false; + ///True when he asked to go back to following the preferences + bool m_reset_chosen = false; + ///The alignment this text has right now, 0 for the cores + int m_initial_alignment = int(Qt::AlignRight); + + QFontComboBox *m_family = nullptr; + QDoubleSpinBox *m_size = nullptr; + QComboBox *m_style = nullptr; + QComboBox *m_alignment = nullptr; + QLabel *m_preview = nullptr; + QPushButton *m_reset = nullptr; +}; + +#endif // CABLETEXTFORMATDIALOG_H diff --git a/sources/cable/editcablecommand.cpp b/sources/cable/editcablecommand.cpp new file mode 100644 index 000000000..eb27596d8 --- /dev/null +++ b/sources/cable/editcablecommand.cpp @@ -0,0 +1,494 @@ +/* + 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 "editcablecommand.h" + +#include "cablemanager.h" +#include "cablepart.h" +#include "../diagram.h" +#include "../qetproject.h" + +#include + +#include + +/** + @brief BatchCommand::BatchCommand + @param first step to run first, kept by this command + @param second step to run right after it, kept by this command +*/ +BatchCommand::BatchCommand(QUndoCommand *first, QUndoCommand *second) : + m_steps({first, second}) +{ + m_steps.removeAll(nullptr); + setText(QCoreApplication::translate("BatchCommand", "Delete")); +} + +/** + @brief BatchCommand::BatchCommand + @param steps every step of one single gesture, in the order the + user did them, kept by this command + @param text what the gesture was, as the undo history shows it +*/ +BatchCommand::BatchCommand(const QList &steps, const QString &text) : + m_steps(steps) +{ + m_steps.removeAll(nullptr); + setText(text); +} + +/** + @brief BatchCommand::~BatchCommand + The steps it holds go with it: they were never given to anything else. +*/ +BatchCommand::~BatchCommand() +{ + for (QUndoCommand *step : std::as_const(m_steps)) { + delete step; + } +} + +/** + @brief BatchCommand::redo + Every step, in the order the user did them. +*/ +void BatchCommand::redo() +{ + for (QUndoCommand *step : std::as_const(m_steps)) { + if (step) step->redo(); + } +} + +/** + @brief BatchCommand::undo + Every step taken back in the reverse order, so what one step changed + is still there when the next one is undone. +*/ +void BatchCommand::undo() +{ + for (auto it = m_steps.crbegin(); it != m_steps.crend(); ++it) { + if (*it) (*it)->undo(); + } +} + +/** + @brief MoveCablePartCommand::MoveCablePartCommand + @param cable the cable the section belongs to + @param part the line being dragged + @param before_p1 + @param before_p2 + @param after_p1 + @param after_p2 + @param before_cores where every colour label stood before the drag + @param after_cores where it stands after it +*/ +MoveCablePartCommand::MoveCablePartCommand(Cable *cable, + CablePart *part, + const QPointF &before_p1, + const QPointF &before_p2, + const QPointF &after_p1, + const QPointF &after_p2, + const QList &before_cores, + const QList &after_cores) : + m_cable(cable), + m_part(part), + m_before_p1(before_p1), + m_before_p2(before_p2), + m_after_p1(after_p1), + m_after_p2(after_p2), + m_before_cores(before_cores), + m_after_cores(after_cores) +{ + setText(QCoreApplication::translate("MoveCablePartCommand", "Move a cable")); +} + +void MoveCablePartCommand::undo() +{ + apply(m_before_p1, m_before_p2, m_before_cores); +} + +void MoveCablePartCommand::redo() +{ + apply(m_after_p1, m_after_p2, m_after_cores); +} + +/** + @brief MoveCablePartCommand::apply + Put the line where it belongs and the colour labels with it. The + positions are written last: they are stored in scene coordinates, so + they only make sense once the line itself is back in place. + @param p1 + @param p2 + @param cores +*/ +void MoveCablePartCommand::apply(const QPointF &p1, + const QPointF &p2, + const QList &cores) +{ + if (m_part) { + m_part->setLine(p1, p2); + } + if (m_cable) { + m_cable->setCores(cores); + } +} + +/** + @brief ChangeCablePropertiesCommand::ChangeCablePropertiesCommand + @param cable + @param before every field as it was before the edit + @param after every field as the user left it +*/ +ChangeCablePropertiesCommand::ChangeCablePropertiesCommand(Cable *cable, + const CableProperties &before, + const CableProperties &after) : + m_cable(cable), + m_before(before), + m_after(after) +{ + setText(QCoreApplication::translate("ChangeCablePropertiesCommand", + "Edit the properties of a cable")); +} + +void ChangeCablePropertiesCommand::undo() +{ + if (m_cable) { + m_cable->setProperties(m_before); + } +} + +void ChangeCablePropertiesCommand::redo() +{ + if (m_cable) { + m_cable->setProperties(m_after); + } +} + +/** + @brief RenumberCablesCommand::RenumberCablesCommand + @param cables the cables to number, in the order they are numbered in + @param designations the number each of them gets + @param project the project whose numbering rule moves along with + the numbering + @param num_key the name that rule is kept under in the project + @param num_before where its counters stood before the numbering ran + @param num_after where the numbering leaves them +*/ +RenumberCablesCommand::RenumberCablesCommand(const QList &cables, + const QStringList &designations, + QETProject *project, + const QString &num_key, + const NumerotationContext &num_before, + const NumerotationContext &num_after) : + m_project(project), + m_num_key(num_key), + m_num_before(num_before), + m_num_after(num_after) +{ + for (int i = 0; i < cables.size() && i < designations.size(); ++i) + { + Cable *cable = cables.at(i); + if (!cable) continue; + + m_cables.append(cable); + m_before.append(cable->designation()); + m_by_hand_before.append(cable->designationByHand()); + m_after.append(designations.at(i)); + //A number the rule has just handed out is the rule's, so + //nothing here stays marked as having been typed in + m_by_hand_after.append(false); + } + + setText(QCoreApplication::translate("RenumberCablesCommand", + "Renumber %n cable(s)", + "", m_cables.size())); +} + +void RenumberCablesCommand::undo() +{ + apply(false); +} + +void RenumberCablesCommand::redo() +{ + apply(true); +} + +/** + @brief RenumberCablesCommand::apply + @param use_after true to write the numbers the rule handed out, + false to put the ones back which were there before +*/ +void RenumberCablesCommand::apply(bool use_after) +{ + for (int i = 0; i < m_cables.size(); ++i) + { + Cable *cable = m_cables.at(i).data(); + if (!cable) continue; + + //Where the name comes from is written first: a number the + //rule has just handed out is the rule's whatever the cable + //was called before, and undo has to say so again + cable->setDesignationByHand(use_after ? m_by_hand_after.at(i) + : m_by_hand_before.at(i)); + cable->setDesignation(use_after ? m_after.at(i) : m_before.at(i)); + } + + //Where the numbering rule itself stands moves along with the + //numbering: once it has run, its counters are where the last + //number was taken from, so the next cable drawn goes on from + //there rather than skipping over every number in between. Undo + //puts them back like the names, since numbering a project over + //is one single act. + if (m_project && !m_num_key.isEmpty()) + { + const NumerotationContext context = use_after ? m_num_after + : m_num_before; + //An empty rule is never written: losing the rule altogether + //is not something numbering may do, not even on the way back + if (!context.isEmpty()) + { + m_project->addCableAutoNum(m_num_key, context); + m_project->setCurrentCableAutoNum(m_num_key); + } + } +} + +/** + @brief ChangeCableTypeCommand::ChangeCableTypeCommand + @param cable + @param before the type, the count, the colours and the cores as + they were before the change + @param after the very same four as the chosen type says +*/ +ChangeCableTypeCommand::ChangeCableTypeCommand(Cable *cable, + const CableTypeState &before, + const CableTypeState &after) : + m_cable(cable), + m_before(before), + m_after(after) +{ + setText(QCoreApplication::translate("ChangeCableTypeCommand", + "Change the cable type")); +} + +void ChangeCableTypeCommand::undo() +{ + apply(m_before); +} + +void ChangeCableTypeCommand::redo() +{ + apply(m_after); +} + +/** + @brief ChangeCableTypeCommand::apply + One block in one pass: name, colours, count, then the cores. The + count has to stand before the cores, since a cable with fewer cores + cannot hold the others -- and the other way round, undoing a type + with fewer cores has to open the room before the cores come back + into it. + @param state the block to write +*/ +void ChangeCableTypeCommand::apply(const CableTypeState &state) +{ + if (m_cable) { + m_cable->applyTypeState(state); + } +} + +/** + @brief ChangeCableCoresCommand::ChangeCableCoresCommand + @param cable + @param before every core the cable wired before the change + @param after every core it wires after it + @param text what undo says about this step, empty for a core which + is merely moved +*/ +ChangeCableCoresCommand::ChangeCableCoresCommand(Cable *cable, + const QList &before, + const QList &after, + const QString &text) : + m_cable(cable), + m_before(before), + m_after(after) +{ + setText(text.isEmpty() + ? QCoreApplication::translate("ChangeCableCoresCommand", + "Move a cable core") + : text); +} + +void ChangeCableCoresCommand::undo() +{ + apply(m_before); +} + +void ChangeCableCoresCommand::redo() +{ + apply(m_after); +} + +/** + @brief ChangeCableCoresCommand::apply + Write one whole set of assignments in place of another. The cable + says it changed, which is what makes the project work out the cable + field of every conductor it feeds again, so the drawing follows. + @param cores +*/ +void ChangeCableCoresCommand::apply(const QList &cores) +{ + if (m_cable) { + m_cable->setCores(cores); + } +} + +/** + @brief RemoveCableCommand::RemoveCableCommand + @param diagram the folios the lines are drawn on + @param parts the lines the user asked to delete +*/ +RemoveCableCommand::RemoveCableCommand(Diagram *diagram, const QList &parts) : + m_diagram(diagram) +{ + setText(QCoreApplication::translate("RemoveCableCommand", "Delete a cable")); + + for (CablePart *part : parts) + { + if (!part) continue; + + Entry entry; + entry.part = part; + entry.cable = part->cable(); + entry.data.uuid = part->partUuid(); + entry.data.diagram = m_diagram ? m_diagram->uuid() : QUuid(); + entry.data.p1 = part->firstPoint(); + entry.data.p2 = part->secondPoint(); + m_entries.append(entry); + + if (entry.cable && !m_cables.contains(entry.cable)) { + m_cables.append(entry.cable); + m_cores.insert(entry.cable.data(), entry.cable->usedCores()); + } + } +} + +/** + @brief RemoveCableCommand::~RemoveCableCommand + Whatever the stack leaves behind has to be cleaned up here: a cable + which is not held by the project any more, and a line which never + made it back onto a folio, belong to this command. +*/ +RemoveCableCommand::~RemoveCableCommand() +{ + QETProject *proj = project(); + for (const QPointer &cable : std::as_const(m_cables)) + { + if (cable && (!proj || !proj->cables().contains(cable))) { + delete cable; + } + } + for (const Entry &entry : std::as_const(m_entries)) + { + if (entry.part && !entry.part->scene()) { + delete entry.part; + } + } +} + +/** + @brief RemoveCableCommand::project + @return the project this command works in, or nullptr +*/ +QETProject *RemoveCableCommand::project() const +{ + return m_diagram ? m_diagram->project() : nullptr; +} + +/** + @brief RemoveCableCommand::redo + Take the lines off their folios. Each cable loses the sections which + are gone together with the cores drawn on them, and a cable which has + no section left anywhere leaves the project: a cable nothing draws is + no cable of this project. +*/ +void RemoveCableCommand::redo() +{ + QETProject *proj = project(); + + for (const Entry &entry : std::as_const(m_entries)) + { + if (entry.cable) { + entry.cable->removePart(entry.data.uuid); + } + if (entry.part && entry.part->scene() && m_diagram) { + m_diagram->removeItem(entry.part); + } + } + + if (proj) + { + for (const QPointer &cable : std::as_const(m_cables)) + { + if (cable && cable->parts().isEmpty() && proj->cables().contains(cable)) { + proj->removeCable(cable); + } + } + } + + CableManager::refreshLabels(proj); +} + +/** + @brief RemoveCableCommand::undo + Put the lines back on their folios, the sections back in their cable + and the cores back on those sections: exactly the state the delete + action took away. +*/ +void RemoveCableCommand::undo() +{ + QETProject *proj = project(); + + if (proj) + { + for (const QPointer &cable : std::as_const(m_cables)) + { + if (cable && !proj->cables().contains(cable)) { + proj->addCable(cable); + } + } + } + + for (const Entry &entry : std::as_const(m_entries)) + { + if (entry.cable) + { + if (!entry.cable->hasPart(entry.data.uuid)) { + entry.cable->addPart(entry.data); + } + for (const CableCore &core : m_cores.value(entry.cable.data())) { + entry.cable->setCore(core); + } + } + if (entry.part && !entry.part->scene() && m_diagram) { + m_diagram->addItem(entry.part); + } + } + + CableManager::refreshLabels(proj); +} diff --git a/sources/cable/editcablecommand.h b/sources/cable/editcablecommand.h new file mode 100644 index 000000000..1308b6b66 --- /dev/null +++ b/sources/cable/editcablecommand.h @@ -0,0 +1,285 @@ +/* + 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 EDITCABLECOMMAND_H +#define EDITCABLECOMMAND_H + +#include "cable.h" + +#include "../autoNum/numerotationcontext.h" + +#include +#include +#include +#include + +class CablePart; +class Diagram; +class QETProject; + +/** + @brief Puts a dragged cable line back where it was, or where the user + just dragged it to. + + The colour labels of its cores follow the line -- they are drawn on + it -- and come back with it when the move is undone. Which core lands + on which conductor never depends on where the trunk line stands, so + the assignments are only carried along by this step, never changed. +*/ +class MoveCablePartCommand : public QUndoCommand +{ + public: + MoveCablePartCommand(Cable *cable, + CablePart *part, + const QPointF &before_p1, + const QPointF &before_p2, + const QPointF &after_p1, + const QPointF &after_p2, + const QList &before_cores, + const QList &after_cores); + + void undo() override; + void redo() override; + + private: + void apply(const QPointF &p1, + const QPointF &p2, + const QList &cores); + + private: + QPointer m_cable; + QPointer m_part; + QPointF m_before_p1; + QPointF m_before_p2; + QPointF m_after_p1; + QPointF m_after_p2; + QList m_before_cores; + QList m_after_cores; +}; + +/** + @brief Rewrites which cores a cable wires, the way dragging the slash + of a core does: the whole set of assignments is taken before and + after the gesture, so undoing puts every one of them back -- the core + which was moved, but also the one it swapped colours with. +*/ +class ChangeCableCoresCommand : public QUndoCommand +{ + public: + /** + @param cable the cable whose assignments are rewritten + @param before every core it held before + @param after every core it holds after + @param text what undo says about this step: an empty string + for the usual step of moving a core around, since putting a + core down for the first time is not a move at all + */ + ChangeCableCoresCommand(Cable *cable, + const QList &before, + const QList &after, + const QString &text = QString()); + + void undo() override; + void redo() override; + + private: + void apply(const QList &cores); + + private: + QPointer m_cable; + QList m_before; + QList m_after; +}; + +/** + @brief Writes what the user typed into the fields of a cable -- and + what he ticks off again -- as one single undo step. + + All the fields belong to the cable rather than to one line of it, so + one change puts every line of that cable, on every folio, right. +*/ +class ChangeCablePropertiesCommand : public QUndoCommand +{ + public: + ChangeCablePropertiesCommand(Cable *cable, + const CableProperties &before, + const CableProperties &after); + + void undo() override; + void redo() override; + + private: + QPointer m_cable; + CableProperties m_before; + CableProperties m_after; +}; + +/** + @brief Gives every cable of a project the number the numbering rule + says it should have, laid out in the order the cables are met along + the sheets. + + That is how a project is numbered again after a cable has been taken + away: the hole its number leaves closes because the cables behind it + move up one place, and a rule which has just been changed reaches + every cable of the project at once. + + One command covers them all -- numbering a whole project is one + single act, so undo takes all of it back together. What the + numbering rule itself holds is part of that act: once the cables + are numbered, the counters of the rule stand where the last number + was taken from, and undo puts them back where they stood. +*/ +class RenumberCablesCommand : public QUndoCommand +{ + public: + /** + @param cables the cables to number, in the order they are + numbered in + @param designations the number each of them gets, one for + each cable + @param project the project whose numbering rule moves along + with the numbering + @param num_key the name that rule is kept under in the + project + @param num_before where its counters stood before the + numbering ran + @param num_after where the numbering leaves them + */ + RenumberCablesCommand(const QList &cables, + const QStringList &designations, + QETProject *project, + const QString &num_key, + const NumerotationContext &num_before, + const NumerotationContext &num_after); + + void undo() override; + void redo() override; + + private: + void apply(bool use_after); + + private: + QList> m_cables; + QStringList m_before; + QStringList m_after; + QList m_by_hand_before; + QList m_by_hand_after; + QETProject *m_project = nullptr; + QString m_num_key; + NumerotationContext m_num_before; + NumerotationContext m_num_after; +}; + +/** + @brief Changes which type a cable is -- its name, how many cores it + has and the colour of each of them -- together with the cores it + may lose on the way, as one single step. + + The cores belong to this step rather than to a step of their own: + a type with fewer cores takes marks off the drawing, and undoing has + to bring them back along with the name and the colours, never half + of it. +*/ +class ChangeCableTypeCommand : public QUndoCommand +{ + public: + /** + @param cable the cable whose type is changed + @param before the state the cable was in + @param after the state it is put into + */ + ChangeCableTypeCommand(Cable *cable, + const CableTypeState &before, + const CableTypeState &after); + + void undo() override; + void redo() override; + + private: + void apply(const CableTypeState &state); + + private: + QPointer m_cable; + CableTypeState m_before; + CableTypeState m_after; +}; + +/** + @brief Holds the steps of one single gesture together, so a single + undo takes all of them back -- an ordinary deletion with the cable + deletions which go with it, or a drag which moved several cable + lines at once. + + It keeps its steps rather than handing them to QUndoCommand as + children: Qt6.11 has no addChild() any more. +*/ +class BatchCommand : public QUndoCommand +{ + public: + BatchCommand(QUndoCommand *first, QUndoCommand *second); + BatchCommand(const QList &steps, const QString &text); + ~BatchCommand() override; + + void undo() override; + void redo() override; + + private: + QList m_steps; +}; + +/** + @brief Takes one or more drawn cable lines off their folios -- and the + cable itself with them when no line of it is left anywhere. + + Undo puts everything back: the lines on their folios, the sections in + the cable, the cores on those sections, and the cable in the project + when it had to leave it. +*/ +class RemoveCableCommand : public QUndoCommand +{ + public: + RemoveCableCommand(Diagram *diagram, const QList &parts); + ~RemoveCableCommand() override; + + void undo() override; + void redo() override; + + private: + QETProject *project() const; + + private: + struct Entry + { + ///The line as it is drawn on the folio + QPointer part; + ///Where the cable holds that section + CablePartData data; + ///The cable the section belongs to + QPointer cable; + }; + + QPointer m_diagram; + QList m_entries; + ///Every cable which loses a section here, with all the cores + ///it wires at this moment: removing a section drops the cores + ///drawn on it, and undo has to bring them back + QList> m_cables; + QHash> m_cores; +}; + +#endif // EDITCABLECOMMAND_H diff --git a/sources/cablelist/cableexportdialog.cpp b/sources/cablelist/cableexportdialog.cpp new file mode 100644 index 000000000..f929f476d --- /dev/null +++ b/sources/cablelist/cableexportdialog.cpp @@ -0,0 +1,155 @@ +/* + 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 3 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 "cableexportdialog.h" + +#include "cablelistmodel.h" +#include "cablelistrows.h" +#include "cablequerywidget.h" + +#include "../bomexport.h" +#include "../qet.h" +#include "../qetapp.h" +#include "../qetproject.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + @brief CableExportDialog::CableExportDialog + @param project the project whose cables are exported + @param parent +*/ +CableExportDialog::CableExportDialog(QETProject *project, QWidget *parent) : + QDialog(parent), + m_project(project) +{ + setWindowTitle(tr("Export the cable list as CSV")); + QET::trackDialogGeometry(this); + + auto *main_layout = new QVBoxLayout(this); + + //The same column picker the cable list is built with, so the + //export asks for its columns the same way the list does + m_query_widget = new CableQueryWidget(this); + main_layout->addWidget(m_query_widget); + + auto *page_group = new QGroupBox(tr("Layout"), this); + auto *page_layout = new QVBoxLayout(page_group); + m_include_headers = new QCheckBox(tr("include headers"), page_group); + m_include_headers->setChecked(true); + page_layout->addWidget(m_include_headers); + page_layout->addStretch(); + main_layout->addWidget(page_group); + + auto *preview_layout = new QHBoxLayout; + auto *preview_pb = new QPushButton(tr("Preview"), this); + preview_layout->addWidget(preview_pb); + preview_layout->addStretch(); + main_layout->addLayout(preview_layout); + + m_preview_table = new QTableView(this); + m_preview_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + main_layout->addWidget(m_preview_table); + + auto *buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + main_layout->addWidget(buttons); + connect(buttons, &QDialogButtonBox::accepted, + this, &CableExportDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, + this, &CableExportDialog::reject); + connect(preview_pb, &QPushButton::clicked, + this, &CableExportDialog::preview); + + m_model = new CableListModel(m_project, this); + m_preview_table->setModel(m_model); + preview(); + + setMinimumSize(QSize(760, 560)); +} + +/** + @brief CableExportDialog::exec + Ask for the file once the user accepted, and write the chosen + columns to it -- the same flow as the material list export + @return +*/ +int CableExportDialog::exec() +{ + const auto r = QDialog::exec(); + if (r == QDialog::Accepted) + { + QString dir = m_project->currentDir(); + if (dir.isEmpty()) dir = QETApp::documentDir(); + const QString file_name = dir + QLatin1Char('/') + + tr("cable_list_") + m_project->title() + + QStringLiteral(".csv"); + const QString file_path = QFileDialog::getSaveFileName( + this, tr("Save As... "), file_name, + tr("CSV files (*.csv)")); + if (!file_path.isEmpty()) + { + QString error; + const auto csv = CableList::toCsv( + m_project, selectedColumns(), + m_include_headers->isChecked()); + if (!BomExport::writeCsv(file_path, csv, &error)) { + QMessageBox::critical( + this, tr("Error"), + tr("Cannot save the cable list to %1.\n%2") + .arg(file_path, error)); + } + } + } + return r; +} + +/** + @brief CableExportDialog::selectedColumns + @return the picked columns, and the default ones when the user + picked nothing -- an export of everything is what a dialog + confirmed without choosing asks for, the same fallback the drawn + table uses +*/ +QStringList CableExportDialog::selectedColumns() const +{ + auto keys = m_query_widget->selectedKeys(); + if (keys.isEmpty()) { + keys = CableList::defaultKeys(); + } + return keys; +} + +/** + @brief CableExportDialog::preview + Show what will be written: the chosen columns, worked out from the + cables of the project as they are right now +*/ +void CableExportDialog::preview() +{ + m_model->setFields(selectedColumns()); + m_preview_table->resizeColumnsToContents(); +} diff --git a/sources/cablelist/cableexportdialog.h b/sources/cablelist/cableexportdialog.h new file mode 100644 index 000000000..e14d63ec7 --- /dev/null +++ b/sources/cablelist/cableexportdialog.h @@ -0,0 +1,58 @@ +/* + 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 3 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 CABLEEXPORTDIALOG_H +#define CABLEEXPORTDIALOG_H + +#include +#include + +class QCheckBox; +class QTableView; + +class CableListModel; +class CableQueryWidget; +class QETProject; + +/** + @brief The CableExportDialog asks which columns of the cable list + are exported and writes them to a CSV file, built the same way the + BOMExportDialog does it for the material list: the column picker + the cable list is built with on top, the page settings under it, a + preview of what will be written, and the file asked for when the + dialog is accepted. +*/ +class CableExportDialog : public QDialog +{ + Q_DECLARE_TR_FUNCTIONS(CableExportDialog) + + public: + explicit CableExportDialog(QETProject *project, QWidget *parent = nullptr); + int exec() override; + + private: + QStringList selectedColumns() const; + void preview(); + + QETProject *m_project; + CableQueryWidget *m_query_widget; + CableListModel *m_model; + QCheckBox *m_include_headers; + QTableView *m_preview_table; +}; + +#endif // CABLEEXPORTDIALOG_H diff --git a/sources/cablelist/cablelistmodel.cpp b/sources/cablelist/cablelistmodel.cpp new file mode 100644 index 000000000..8991a9522 --- /dev/null +++ b/sources/cablelist/cablelistmodel.cpp @@ -0,0 +1,421 @@ +/* + 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 3 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 "cablelistmodel.h" + +#include "cablelistrows.h" + +#include "../cable/cable.h" +#include "../diagram.h" +#include "../qetgraphicsitem/element.h" +#include "../qetproject.h" +#include "../qetxml.h" +#include "../utils/qetutils.h" + +#include +#include + +#include +#include + +/** + @brief CableListModel::CableListModel + @param project : project whose cables this model lists + @param parent : parent QObject +*/ +CableListModel::CableListModel(QETProject *project, QObject *parent) : + QAbstractTableModel(parent), + m_project(project) +{ + //Left is what a table starts with and stays with: the factory + //overwrites it with the alignment chosen in the dialog, and a + //table which never got that far (no column selected at + //placement) still draws its cells left rather than from an + //empty role + m_index_0_0_data.insert(Qt::TextAlignmentRole, int(Qt::AlignLeft)); + //The same gap between the text and the cell lines the creation + //dialog offers for the material list, already in place for a + //table which starts out empty and gets its columns later -- + //the factory overwrites it with the margins chosen in the + //dialog as soon as the table has a cell to configure + m_index_0_0_data.insert(Qt::UserRole+1, + QETUtils::marginsToString(QMargins(5,5,10,5))); + connectProject(); + setFields(CableList::defaultKeys()); +} + +/** + @brief CableListModel::CableListModel + @param other_model +*/ +CableListModel::CableListModel(const CableListModel &other_model) : + QAbstractTableModel(other_model.parent()) +{ + setParent(other_model.parent()); + m_project = other_model.m_project; + m_identifier = other_model.m_identifier; + connectProject(); + setFields(other_model.m_fields); + m_header_data = other_model.m_header_data; + m_index_0_0_data = other_model.m_index_0_0_data; +} + +/** + @brief CableListModel::rowCount + Reimplemented for QAbstractTableModel + @param parent + @return +*/ +int CableListModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + + //A table built without a single column shows no rows either: + //the empty frame a nomenclature table makes when it is + //created without entries, not a list of rows nobody can see + if (m_fields.isEmpty()) { + return 0; + } + + return m_rows.count(); +} + +/** + @brief CableListModel::columnCount + Reimplemented for QAbstractTableModel + @param parent + @return +*/ +int CableListModel::columnCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + + return m_fields.count(); +} + +/** + @brief CableListModel::setHeaderData + Reimplemented from QAbstractTableModel. + Only horizontal orientation is accepted. + @param section + @param orientation + @param value + @param role + @return +*/ +bool CableListModel::setHeaderData(int section, Qt::Orientation orientation, + const QVariant &value, int role) +{ + if (orientation == Qt::Vertical) { + return false; + } + auto hash_ = m_header_data.value(section); + hash_.insert(role, value); + m_header_data.insert(section, hash_); + emit headerDataChanged(orientation, section, section); + return true; +} + +/** + @brief CableListModel::headerData + Reimplemented for QAbstractTableModel + @param section + @param orientation + @param role + @return +*/ +QVariant CableListModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation == Qt::Vertical) { + return QVariant(); + } + + if (m_header_data.contains(section)) + { + auto hash_ = m_header_data.value(section); + if (role == Qt::DisplayRole && !hash_.contains(Qt::DisplayRole)) { //special case to have the same behavior as Qt + return hash_.value(Qt::EditRole); + } + return m_header_data.value(section).value(role); + } + return QVariant(); +} + +/** + @brief CableListModel::setData + Only store the data for the index 0.0. An empty table has no cells + yet -- index(0,0) does not exist without a single column -- but + its look is still settable: the factory configuring a freshly made + table must not be thrown away just because the table was placed + without entries and gains its columns afterwards. The data is read + as soon as cells exist. + @param index + @param value + @param role + @return +*/ +bool CableListModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + if (index.isValid() && (index.row() != 0 || index.column() != 0)) { + return false; + } + m_index_0_0_data.insert(role, value); + if (index.isValid()) { + emit dataChanged(index, index, {role}); + } + return true; +} + +/** + @brief CableListModel::data + Reimplemented for QAbstractTableModel + @param index + @param role + @return +*/ +QVariant CableListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + if (index.row() == 0 && + index.column() == 0 && + role != Qt::DisplayRole) { + return m_index_0_0_data.value(role); + } + + if (role == Qt::DisplayRole) { + return m_rows.at(index.row()).value(m_fields.at(index.column())); + } + + return QVariant(); +} + +/** + @brief CableListModel::project + @return the project whose cables this model lists +*/ +QETProject *CableListModel::project() const +{ + return m_project.data(); +} + +/** + @brief CableListModel::setFields + Set which columns this table shows, in that order, and work the rows + out along the way. Unknown keys are dropped rather than shown as + empty columns: a file naming a column this program does not know + would draw a blank header nobody can fill. + @param fields the keys of CableList::allColumns() +*/ +void CableListModel::setFields(const QStringList &fields) +{ + QStringList kept; + for (const QString &field : fields) + { + if (kept.contains(field)) { + continue; + } + if (CableList::labelOf(field).isEmpty()) { + continue; + } + kept << field; + } + + beginResetModel(); + m_fields = kept; + //The columns themselves change, their labels are written again + //below -- but HOW a header is drawn (font, alignment, margins) + //belongs to the table, not to the column set, and stays as it + //is across a column edit. Sections which no longer exist are + //dropped, so a stale header of an old column cannot linger + for (auto it = m_header_data.begin(); it != m_header_data.end(); ) { + if (it.key() >= m_fields.count()) { + it = m_header_data.erase(it); + } else { + ++it; + } + } + for (int i = 0; i < m_fields.count(); ++i) { + m_header_data[i].insert(Qt::DisplayRole, CableList::labelOf(m_fields.at(i))); + } + m_rows = m_project ? CableList::rows(m_project) + : QVector>(); + endResetModel(); +} + +/** + @brief CableListModel::queryString + @return the fields joined the way the project file stores them +*/ +QString CableListModel::queryString() const +{ + return m_fields.join(QLatin1Char(',')); +} + +/** + @brief CableListModel::refresh + Work the rows out again, and watch everything they are worked out + from: a cable of the project, a folio, a piece a cable lands on + (its label is the BMK of an end, its Installation and Localisation + are what those two columns of an end read). +*/ +void CableListModel::refresh() +{ + for (const QMetaObject::Connection &connection : std::as_const(m_watchers)) { + QObject::disconnect(connection); + } + m_watchers.clear(); + + if (m_project) + { + const QVector cables = m_project->cables(); + for (Cable *cable : cables) { + m_watchers << connect(cable, &Cable::changed, + this, &CableListModel::refresh); + } + + const QList diagrams = m_project->diagrams(); + for (Diagram *diagram : diagrams) + { + m_watchers << connect(diagram, &Diagram::diagramInformationChanged, + this, &CableListModel::refresh); + const QList elements = diagram->elements(); + for (Element *element : elements) { + m_watchers << connect(element, &Element::elementInfoChange, + this, &CableListModel::refresh); + } + } + } + + beginResetModel(); + m_rows = m_project ? CableList::rows(m_project) + : QVector>(); + endResetModel(); +} + +/** + @brief CableListModel::connectProject + Watch the project itself: cables and folios coming and going reach + this model even though no refresh of ours was running to catch them + (the rows of a project being read from its file are filled cable by + cable, one signal at a time). +*/ +void CableListModel::connectProject() +{ + if (!m_project) { + return; + } + + connect(m_project, &QETProject::cableAdded, this, &CableListModel::refresh); + connect(m_project, &QETProject::cableRemoved, this, &CableListModel::refresh); + connect(m_project, &QETProject::diagramAdded, this, &CableListModel::refresh); + connect(m_project, &QETProject::diagramRemoved, this, &CableListModel::refresh); +} + +/** + @brief CableListModel::toXml + Save the model to xml: which columns are shown, the look of the + cells and of the header -- the rows themselves are not saved, they + are worked out again from the cables whenever the project opens. + @param document + @return +*/ +QDomElement CableListModel::toXml(QDomDocument &document) const +{ + auto dom_element = document.createElement(xmlTagName()); + + //Identifier + auto dom_identifier = document.createElement("identifier"); + auto dom_identifier_text = document.createTextNode(m_identifier); + dom_identifier.appendChild(dom_identifier_text); + dom_element.appendChild(dom_identifier); + + //Fields + auto dom_fields = document.createElement("fields"); + auto dom_fields_text = document.createTextNode(queryString()); + dom_fields.appendChild(dom_fields_text); + dom_element.appendChild(dom_fields); + + //Add index 0,0 data + auto index_00 = document.createElement("index00"); + index_00.setAttribute("font", QETUtils::fontToString(m_index_0_0_data.value(Qt::FontRole).value())); + auto me = QMetaEnum::fromType(); + index_00.setAttribute("alignment", me.valueToKey(m_index_0_0_data.value(Qt::TextAlignmentRole).toInt())); + dom_element.appendChild(index_00); + index_00.setAttribute("margins", m_index_0_0_data.value(Qt::UserRole+1).toString()); + + //header data + QHash> horizontal_; + for (auto key : m_header_data.keys()) + { + //We save all data except the display role, because it is + //generated from the fields above (same reason and same sorted + //order as ProjectDBModel, so the file stays reproducible) + auto list = m_header_data.value(key).keys(); + list.removeAll(Qt::DisplayRole); + std::sort(list.begin(), list.end()); + + horizontal_.insert(key, list); + } + + dom_element.appendChild(QETXML::modelHeaderDataToXml(document, this, horizontal_, QHash>())); + + return dom_element; +} + +/** + @brief CableListModel::fromXml + Restore the model from xml + @param element +*/ +void CableListModel::fromXml(const QDomElement &element) +{ + if (element.tagName() != xmlTagName()) + return; + + setIdentifier(element.firstChildElement("identifier").text()); + setFields(element.firstChildElement("fields").text() + .split(QLatin1Char(','), Qt::SkipEmptyParts)); + + //Index 0,0 + auto index_00 = element.firstChildElement("index00"); + QFont font_; + QETUtils::fontFromString(font_, index_00.attribute("font")); + m_index_0_0_data.insert(Qt::FontRole, font_); + auto me = QMetaEnum::fromType(); + const int alignment = me.keyToValue(index_00.attribute("alignment") + .toStdString().data()); + //A file written without an alignment (an empty or unknown enum + //key) would restore as -1, whose bits draw every cell right- + //aligned -- left is the default instead + m_index_0_0_data.insert(Qt::TextAlignmentRole, + alignment > 0 ? alignment : int(Qt::AlignLeft)); + //A file saved without cell margins (one written before the + //look of an empty table was kept) restores with the same + //default the creation dialog offers, instead of text drawn + //against the cell lines + const QString margins_ = index_00.attribute("margins"); + m_index_0_0_data.insert(Qt::UserRole+1, + margins_.isEmpty() + ? QETUtils::marginsToString(QMargins(5,5,10,5)) + : margins_); + + QETXML::modelHeaderDataFromXml(element.firstChildElement("header_data"), this); +} diff --git a/sources/cablelist/cablelistmodel.h b/sources/cablelist/cablelistmodel.h new file mode 100644 index 000000000..376c790a4 --- /dev/null +++ b/sources/cablelist/cablelistmodel.h @@ -0,0 +1,107 @@ +/* + 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 3 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 CABLELISTMODEL_H +#define CABLELISTMODEL_H + +#include +#include +#include +#include +#include +#include +#include +#include + +class Cable; +class Diagram; +class Element; +class QETProject; + +/** + @brief The CableListModel class + + The rows of a cable list, shown by QetGraphicsTableItem the way the + project database model shows a nomenclature -- and shaped after it + on purpose: same roles, same header behaviour, same way of saving + itself to the project file, so that the table on the sheet treats + this model exactly like any other. + + Whereas ProjectDBModel asks the project database, this model asks + CableList::rows(), straight from the cables of the project. It + refreshes itself whenever anything those rows are worked out from + changes: a cable edited, a cable added or removed, a folio added, + removed or re-informed (its title block carries the installation and + the place), a label typed over on a piece a cable lands on. +*/ +class CableListModel : public QAbstractTableModel +{ + Q_OBJECT + + public: + explicit CableListModel(QETProject *project, QObject *parent = nullptr); + CableListModel(const CableListModel &other_model); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + int columnCount(const QModelIndex &parent = QModelIndex()) const override; + bool setHeaderData(int section, + Qt::Orientation orientation, + const QVariant &value, + int role = Qt::EditRole) override; + QVariant headerData(int section, + Qt::Orientation orientation, + int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex &index, + const QVariant &value, + int role = Qt::EditRole) override; + QVariant data(const QModelIndex &index, + int role = Qt::DisplayRole) const override; + + /// The columns this table shows, in order: keys of CableList + void setFields(const QStringList &fields); + QStringList fields() const {return m_fields;} + /// The same, joined the way the project file stores it + QString queryString() const; + void setIdentifier(const QString &identifier) {m_identifier = identifier;} + QString identifier() const {return m_identifier;} + QETProject *project() const; + + QDomElement toXml(QDomDocument &document) const; + void fromXml(const QDomElement &element); + static QString xmlTagName() {return QStringLiteral("cable_list_model");} + + public slots: + /// Work the rows out again and tell whoever shows them + void refresh(); + + private: + /// Watch everything the rows are worked out from + void connectProject(); + + QPointer m_project; + QStringList m_fields; + QVector> m_rows; + //First int = section, second int = Qt::role, QVariant = value + QHash> m_header_data; + QHash m_index_0_0_data; + QString m_identifier = QStringLiteral("cable_list"); + /// The current watchers of this model, dropped and taken + /// again on every refresh + QList m_watchers; +}; + +#endif // CABLELISTMODEL_H diff --git a/sources/cablelist/cablelistmodelpropertieswidget.cpp b/sources/cablelist/cablelistmodelpropertieswidget.cpp new file mode 100644 index 000000000..990e85b08 --- /dev/null +++ b/sources/cablelist/cablelistmodelpropertieswidget.cpp @@ -0,0 +1,100 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 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 "cablelistmodelpropertieswidget.h" + +#include "cablelistmodel.h" +#include "cablequerywidget.h" + +#include +#include +#include +#include +#include + +/** + @brief CableListModelPropertiesWidget::CableListModelPropertiesWidget + @param model + @param parent +*/ +CableListModelPropertiesWidget::CableListModelPropertiesWidget( + CableListModel *model, + QWidget *parent) : + PropertiesEditorWidget(parent) +{ + auto *layout = new QHBoxLayout(this); + m_edit_pb = new QPushButton(tr("Edit columns..."), this); + m_refresh_pb = new QPushButton(tr("Refresh"), this); + layout->addWidget(m_edit_pb); + layout->addWidget(m_refresh_pb); + layout->addStretch(); + + connect(m_edit_pb, &QPushButton::clicked, this, &CableListModelPropertiesWidget::editColumns); + connect(m_refresh_pb, &QPushButton::clicked, this, &CableListModelPropertiesWidget::refreshRows); + + setModel(model); +} + +/** + @brief CableListModelPropertiesWidget::setModel + @param model +*/ +void CableListModelPropertiesWidget::setModel(CableListModel *model) +{ + m_model = model; + m_edit_pb->setEnabled(m_model != nullptr); + m_refresh_pb->setEnabled(m_model != nullptr); +} + +/** + @brief CableListModelPropertiesWidget::editColumns + Ask for the columns again and put them on the table right away -- + the same immediate, not-undoable way the database table changes its + query +*/ +void CableListModelPropertiesWidget::editColumns() +{ + if (!m_model) { + return; + } + + QDialog d(this); + d.setWindowTitle(tr("Cable list columns")); + auto *layout = new QVBoxLayout(&d); + auto *widget = new CableQueryWidget(&d); + widget->setFields(m_model->fields()); + layout->addWidget(widget); + + auto *button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + layout->addWidget(button_box); + connect(button_box, &QDialogButtonBox::accepted, &d, &QDialog::accept); + connect(button_box, &QDialogButtonBox::rejected, &d, &QDialog::reject); + + if (d.exec()) { + m_model->setFields(widget->selectedKeys()); + } +} + +/** + @brief CableListModelPropertiesWidget::refreshRows +*/ +void CableListModelPropertiesWidget::refreshRows() +{ + if (m_model) { + m_model->refresh(); + } +} diff --git a/sources/cablelist/cablelistmodelpropertieswidget.h b/sources/cablelist/cablelistmodelpropertieswidget.h new file mode 100644 index 000000000..908a8b178 --- /dev/null +++ b/sources/cablelist/cablelistmodelpropertieswidget.h @@ -0,0 +1,55 @@ +/* + 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 3 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 CABLELISTMODELPROPERTIESWIDGET_H +#define CABLELISTMODELPROPERTIESWIDGET_H + +#include "../../PropertiesEditor/propertieseditorwidget.h" + +class CableListModel; +class QPushButton; + +/** + @brief The CableListModelPropertiesWidget class + The editor shown when a cable list table is selected: change which + columns the table shows, or work its rows out again. Shaped after + ProjectDBModelPropertiesWidget, which changes its query the same + immediate, not-undoable way -- the table itself is undoable to move + or delete, its content always follows the cables. +*/ +class CableListModelPropertiesWidget : public PropertiesEditorWidget +{ + Q_OBJECT + + public: + explicit CableListModelPropertiesWidget(CableListModel *model = nullptr, + QWidget *parent = nullptr); + ~CableListModelPropertiesWidget() override = default; + + void setModel(CableListModel *model); + + private slots: + void editColumns(); + void refreshRows(); + + private: + CableListModel *m_model = nullptr; + QPushButton *m_edit_pb = nullptr; + QPushButton *m_refresh_pb = nullptr; +}; + +#endif // CABLELISTMODELPROPERTIESWIDGET_H diff --git a/sources/cablelist/cablelistrows.cpp b/sources/cablelist/cablelistrows.cpp new file mode 100644 index 000000000..34df4998e --- /dev/null +++ b/sources/cablelist/cablelistrows.cpp @@ -0,0 +1,468 @@ +/* + 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 3 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 "cablelistrows.h" + +#include "../bomexport.h" +#include "../cable/cable.h" +#include "../conductorproperties.h" +#include "../diagram.h" +#include "../qetgraphicsitem/conductor.h" +#include "../qetgraphicsitem/element.h" +#include "../qetgraphicsitem/terminal.h" +#include "../qetinformation.h" +#include "../qetproject.h" + +#include +#include + +#include +#include +#include + +namespace { + +/** + @brief One place where the line of a cable ends. + + An end which lands on a piece carries that piece (its uuid for + grouping and its pointer for reading the label, both valid as long + as the plan lives); an end taken from the drawn line carries none, + and its tie names the section instead. +*/ +struct CableEnd +{ + Diagram *diagram = nullptr; + QPointF scene; + Element *element = nullptr; + QUuid element_uuid; + QString tie; + + bool isLanding() const {return !element_uuid.isNull();} +}; + +/** + @brief endBefore + The order the ends of the cables are read in: the earlier sheet + first, then the higher point of that sheet, then the left one, then + a stable name so that a redraw can never swap two ends and shuffle + the rows around. + @param a first end + @param b second end + @param project the project the sheet numbers come from + @return true when a comes before b +*/ +bool endBefore(const CableEnd &a, const CableEnd &b, const QETProject *project) +{ + const int folio_a = a.diagram ? project->folioIndex(a.diagram) : INT_MAX; + const int folio_b = b.diagram ? project->folioIndex(b.diagram) : INT_MAX; + if (folio_a != folio_b) { + return folio_a < folio_b; + } + if (a.scene.y() != b.scene.y()) { + return a.scene.y() < b.scene.y(); + } + if (a.scene.x() != b.scene.x()) { + return a.scene.x() < b.scene.x(); + } + return a.tie < b.tie; +} + +/** + @brief folioOf + @param end + @return the sheet number the end lies on, empty when it lies on no + sheet at all (a cable nothing was ever drawn for) +*/ +QString folioOf(const CableEnd &end, const QETProject *project) +{ + return end.diagram ? QString::number(project->folioIndex(end.diagram) + 1) + : QString(); +} + +/** + @brief plantOf + @return the Installation the piece the end lands on carries -- the + same field every other list reads for a piece. A piece without one + leaves the cell empty: there is no second place to look for it, and + an end of the drawn line, which lands on no piece at all, fills + nothing either. +*/ +QString plantOf(const CableEnd &end) +{ + if (!end.element) { + return QString(); + } + return end.element->elementInformations() + .value(QETInformation::ELMT_PLANT).toString(); +} + +/** + @brief locmachOf + @return the Localisation the piece the end lands on carries, with + the same rule as the Installation: what the piece carries or + nothing at all. +*/ +QString locmachOf(const CableEnd &end) +{ + if (!end.element) { + return QString(); + } + return end.element->elementInformations() + .value(QETInformation::ELMT_LOCATION).toString(); +} + +/** + @brief labelOfEnd + @return the label (the BMK) of the piece the end lands on, empty for + an end of the drawn line -- there is no piece there to carry one +*/ +QString labelOfEnd(const CableEnd &end) +{ + if (!end.element) { + return QString(); + } + return end.element->elementInformations() + .value(QETInformation::ELMT_LABEL).toString(); +} + +} // namespace + +/** + @brief CableList::allColumns + @return every column of the cable list, in the order they are + offered, drawn and exported +*/ +const QList &CableList::allColumns() +{ + static const QList columns = { + {QStringLiteral("nr"), tr("No.")}, + {QStringLiteral("installation_cable"), tr("Plant (cable)")}, + {QStringLiteral("location_cable"), tr("Location (cable)")}, + {QStringLiteral("bmk_cable"), tr("Label (cable)")}, + {QStringLiteral("installation_start"), tr("Plant (start)")}, + {QStringLiteral("location_start"), tr("Location (start)")}, + {QStringLiteral("bmk_start"), tr("Label (start)")}, + {QStringLiteral("folio_start"), tr("Sheet (start)")}, + {QStringLiteral("installation_end"), tr("Plant (end)")}, + {QStringLiteral("location_end"), tr("Location (end)")}, + {QStringLiteral("bmk_end"), tr("Label (end)")}, + {QStringLiteral("folio_end"), tr("Sheet (end)")}, + {QStringLiteral("type"), tr("Cable type")}, + {QStringLiteral("length"), tr("Length")}, + {QStringLiteral("cores_used"), tr("Used cores")}, + }; + return columns; +} + +/** + @brief CableList::defaultKeys + @return the columns a freshly drawn cable list starts with: all of + them +*/ +const QStringList &CableList::defaultKeys() +{ + static const QStringList keys = []() { + QStringList list; + for (const Column &column : allColumns()) { + list << column.key; + } + return list; + }(); + return keys; +} + +/** + @brief CableList::labelOf + @param key + @return the header text of that column, empty when the key names no + column of this list +*/ +QString CableList::labelOf(const QString &key) +{ + for (const Column &column : allColumns()) { + if (column.key == key) { + return column.label; + } + } + return QString(); +} + +/** + @brief CableList::rows + Work out one row per cable, in plan order. + @param project the project whose cables are listed + @return the rows, sorted, the number column already filled in +*/ +QVector> CableList::rows(QETProject *project) +{ + QVector> result; + if (!project) { + return result; + } + + //Every conductor of every cable is walked once, and where one + //lands on a real piece becomes an arrival of that cable. A core + //which ends on a folio report is skipped on that side: the + //cable goes on over there, and the landing on the far sheet is + //what counts as its end. + QHash> arrivals; + for (Diagram *diagram : project->diagrams()) + { + for (Conductor *conductor : diagram->conductors()) + { + const ConductorProperties properties = conductor->properties(); + if (properties.m_cable_uuid.isNull()) { + continue; + } + if (!conductor->terminal1 || !conductor->terminal2) { + continue; + } + + Terminal *terminals[2] = {conductor->terminal1, conductor->terminal2}; + for (Terminal *terminal : terminals) + { + Element *element = terminal->parentElement(); + if (!element) { + //a free end is not an arrival + continue; + } + if (element->linkType() & Element::AllReport) { + //the cable continues on the linked sheet + continue; + } + + CableEnd end; + end.diagram = element->diagram(); + end.scene = terminal->scenePos(); + end.element = element; + end.element_uuid = element->uuid(); + end.tie = conductor->uuid().toString(); + arrivals[properties.m_cable_uuid].append(end); + } + } + } + + struct Entry + { + Cable *cable = nullptr; + CableEnd start; + CableEnd target; + }; + + auto byPlan = [project](const CableEnd &a, const CableEnd &b) { + return endBefore(a, b, project); + }; + + QVector entries; + entries.reserve(project->cables().count()); + + for (Cable *cable : project->cables()) + { + //Several cores landing on one piece are one end: the highest + //of their landings speaks for it. + QHash grouped; + for (const CableEnd &end : arrivals.value(cable->uuid())) + { + auto it = grouped.find(end.element_uuid); + if (it == grouped.end() || byPlan(end, it.value())) { + grouped.insert(end.element_uuid, end); + } + } + QList landings = grouped.values(); + std::sort(landings.begin(), landings.end(), byPlan); + + //The free ends of the drawn line, for the sides no core ever + //landed on + QList line_ends; + for (const CablePartData &part : cable->parts()) + { + Diagram *diagram = project->diagramByUuid(part.diagram); + if (!diagram) { + continue; + } + for (int i = 0; i < 2; ++i) + { + CableEnd end; + end.diagram = diagram; + end.scene = i ? part.p2 : part.p1; + end.tie = part.uuid.toString() + (i ? QStringLiteral("2") + : QStringLiteral("1")); + line_ends.append(end); + } + } + std::sort(line_ends.begin(), line_ends.end(), byPlan); + + Entry entry; + entry.cable = cable; + + if (landings.size() >= 2) + { + //The rule as asked for: the first finding counts for + //both ends. First landing in plan order is the start, + //the very next finding is the target -- not the last + //one. A sheet further along finds the same cable again + //but start and target are already taken by then, so it + //adds nothing: on the first sheet, the upper piece is + //the start and the lower one the target. + entry.start = landings.first(); + entry.target = landings.at(1); + } + else if (landings.size() == 1) + { + //One side is wired: that side carries the label, the + //other side falls back to the drawn line, farthest end + //first so a tie goes to the later point of the plan. + entry.start = landings.first(); + entry.target = landings.first(); + qreal farthest = -1.0; + for (const CableEnd &candidate : std::as_const(line_ends)) + { + const qreal distance = QLineF(entry.start.scene, candidate.scene).length(); + if (distance >= farthest) { + farthest = distance; + entry.target = candidate; + } + } + } + else if (line_ends.size() >= 2) + { + entry.start = line_ends.first(); + entry.target = line_ends.last(); + } + else if (line_ends.size() == 1) + { + entry.start = line_ends.first(); + entry.target = line_ends.first(); + } + //A cable with neither a core nor a line draws no ends at all; + //its own fields still fill the row. + + entries.append(entry); + } + + //Plan order again for the rows themselves: by the sheet the start + //lies on, then from the top of that sheet down. The name and the + //uuid only ever break ties, so the numbering is stable between + //two openings of the same project. + std::sort(entries.begin(), entries.end(), [project](const Entry &a, const Entry &b) { + if (endBefore(a.start, b.start, project)) return true; + if (endBefore(b.start, a.start, project)) return false; + if (endBefore(a.target, b.target, project)) return true; + if (endBefore(b.target, a.target, project)) return false; + if (a.cable->designation() != b.cable->designation()) { + return a.cable->designation() < b.cable->designation(); + } + return a.cable->uuid().toString() < b.cable->uuid().toString(); + }); + + int number = 0; + for (const Entry &entry : std::as_const(entries)) + { + Cable *cable = entry.cable; + QMap row; + row.insert(QStringLiteral("nr"), QString::number(++number)); + row.insert(QStringLiteral("installation_cable"), cable->installation()); + row.insert(QStringLiteral("location_cable"), cable->location()); + row.insert(QStringLiteral("bmk_cable"), cable->designation()); + row.insert(QStringLiteral("installation_start"), plantOf(entry.start)); + row.insert(QStringLiteral("location_start"), locmachOf(entry.start)); + row.insert(QStringLiteral("bmk_start"), labelOfEnd(entry.start)); + row.insert(QStringLiteral("folio_start"), folioOf(entry.start, project)); + row.insert(QStringLiteral("installation_end"), plantOf(entry.target)); + row.insert(QStringLiteral("location_end"), locmachOf(entry.target)); + row.insert(QStringLiteral("bmk_end"), labelOfEnd(entry.target)); + row.insert(QStringLiteral("folio_end"), folioOf(entry.target, project)); + row.insert(QStringLiteral("type"), cable->type()); + row.insert(QStringLiteral("length"), cable->length()); + row.insert(QStringLiteral("cores_used"), + QStringLiteral("%1/%2") + .arg(cable->usedCoreCount()) + .arg(cable->coreCount())); + result.append(row); + } + + return result; +} + +/** + @brief CableList::toCsv + The whole list as CSV bytes: every column of the list, every cable + of the project, in the format every CSV export of this program + writes. + @param project the project whose cables are listed + @return the bytes to write +*/ +/** + @brief CableList::toCsv + @param project the project whose cables are listed + @param keys the columns to write, in that order; unknown keys are + dropped, and a list without a single usable one falls back to the + default columns rather than writing a blank file + @param include_headers whether the line of column names is written + as the first line + @return the CSV bytes in the format every CSV export of this + program writes (UTF-8 BOM, ';', every field quoted) +*/ +QByteArray CableList::toCsv(QETProject *project, const QStringList &keys, + bool include_headers) +{ + QStringList kept; + for (const QString &key : keys) + { + if (kept.contains(key) || labelOf(key).isEmpty()) { + continue; + } + kept << key; + } + if (kept.isEmpty()) { + kept = defaultKeys(); + } + + QByteArray csv("\xEF\xBB\xBF"); + if (include_headers) { + QStringList headers; + for (const QString &key : std::as_const(kept)) { + headers << labelOf(key); + } + csv += BomExport::csvRecord(headers); + } + + const QVector> rows_ = rows(project); + for (const QMap &row : rows_) + { + QStringList values; + for (const QString &key : std::as_const(kept)) { + values << row.value(key); + } + csv += BomExport::csvRecord(values); + } + + return csv; +} + +/** + @brief CableList::toCsv + The whole list as it has always been written: every column, every + cable, the line of column names included + @param project the project whose cables are listed + @return the CSV bytes +*/ +QByteArray CableList::toCsv(QETProject *project) +{ + return toCsv(project, defaultKeys(), true); +} diff --git a/sources/cablelist/cablelistrows.h b/sources/cablelist/cablelistrows.h new file mode 100644 index 000000000..4f3f80988 --- /dev/null +++ b/sources/cablelist/cablelistrows.h @@ -0,0 +1,101 @@ +/* + 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 3 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 CABLELISTROWS_H +#define CABLELISTROWS_H + +#include +#include +#include +#include +#include +#include + +class QETProject; + +/** + @brief The CableList class + + The columns and the rows of the cable list (menu "Listes": the entry + which puts such a table on a sheet, and the CSV export which writes + the same table to a file). + + Everything which decides what a cable list row says lives here, so + that the table drawn on the sheet and the exported CSV can never + disagree: one row per cable, worked out once and read twice. + + The two ends of a cable are worked out the way the user asked for + it: + + - wherever the cores of the cable land on a real piece (a terminal + strip, a device) is an end. Several cores landing on the same + piece are one end. A core which ends on a folio report is not an + end at all -- the cable goes on over there, and the landing on the + far sheet is the end instead, which is what makes a cable running + over several sheets show its start on the earlier sheet and its + target on the later one; + - ends are ordered by sheet first (the earlier sheet wins), then by + height in the plan (the higher one wins), then left to right; + the first end is the start, the last one the target; + - a cable whose cores never landed anywhere (not drawn yet, or only + wired in the middle) falls back to the two free ends of its drawn + line; when there is only one landing, the other side falls back to + the line end farthest away from it. +*/ +class CableList +{ + Q_DECLARE_TR_FUNCTIONS(CableList) + + public: + struct Column + { + /// The name the row hash, the saved file and the CSV use + QString key; + /// What the header over the column says + QString label; + }; + + /// Every column the list can offer, in the order they are + /// offered and exported + static const QList &allColumns(); + /// The columns a fresh list starts with: all of them but the + /// ones marked optional above + static const QStringList &defaultKeys(); + /// The header text of one column, empty when the key is not + /// a column of this list + static QString labelOf(const QString &key); + + /// One row per cable, sorted: first by the sheet its start + /// lies on, then from the top of that sheet down, the number + /// column filled in along the way + static QVector> rows(QETProject *project); + + /// The whole list as CSV bytes, in the format every CSV + /// export of this program writes (UTF-8 BOM, ';', every + /// field quoted): every column, every cable, the line of + /// column names included + static QByteArray toCsv(QETProject *project); + /// The same, but only the given columns, in the given + /// order, and only with the line of column names when asked + /// for -- what the export dialog writes. Unknown keys are + /// dropped, and no usable key at all falls back to the + /// default columns + static QByteArray toCsv(QETProject *project, const QStringList &keys, + bool include_headers); +}; + +#endif // CABLELISTROWS_H diff --git a/sources/cablelist/cablequerywidget.cpp b/sources/cablelist/cablequerywidget.cpp new file mode 100644 index 000000000..4b32dc71f --- /dev/null +++ b/sources/cablelist/cablequerywidget.cpp @@ -0,0 +1,319 @@ +/* + 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 3 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 "cablequerywidget.h" + +#include "cablelistrows.h" + +#include "../qetapp.h" +#include "../ui/configsaveloaderwidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +/** + @brief CableQueryWidget::CableQueryWidget + @param parent +*/ +CableQueryWidget::CableQueryWidget(QWidget *parent) : + QWidget(parent) +{ + auto *layout = new QVBoxLayout(this); + + auto *lists = new QHBoxLayout; + + auto *left = new QVBoxLayout; + left->addWidget(new QLabel(tr("Available columns"), this)); + m_available_list = new QListWidget(this); + left->addWidget(m_available_list); + + auto *buttons = new QVBoxLayout; + buttons->addStretch(); + auto make_button = [this](QStyle::StandardPixmap icon, const QString &tip) { + auto *button = new QPushButton(this); + button->setIcon(style()->standardIcon(icon)); + button->setToolTip(tip); + return button; + }; + auto *add_pb = make_button(QStyle::SP_ArrowRight, tr("Add the selected column")); + auto *remove_pb = make_button(QStyle::SP_ArrowLeft, tr("Remove the selected column")); + auto *up_pb = make_button(QStyle::SP_ArrowUp, tr("Move the selected column up")); + auto *down_pb = make_button(QStyle::SP_ArrowDown, tr("Move the selected column down")); + buttons->addWidget(add_pb); + buttons->addWidget(remove_pb); + buttons->addWidget(up_pb); + buttons->addWidget(down_pb); + buttons->addStretch(); + + auto *right = new QVBoxLayout; + right->addWidget(new QLabel(tr("Table columns"), this)); + m_choosen_list = new QListWidget(this); + right->addWidget(m_choosen_list); + + lists->addLayout(left); + lists->addLayout(buttons); + lists->addLayout(right); + layout->addLayout(lists); + + m_config_gb = new ConfigSaveLoaderWidget(this); + layout->addWidget(m_config_gb); + + setMinimumSize(QSize(520, 420)); + + setUpItems(); + fillSavedQuery(); + + connect(m_config_gb, &ConfigSaveLoaderWidget::saveClicked, this, &CableQueryWidget::saveConfig); + connect(m_config_gb, &ConfigSaveLoaderWidget::loadClicked, this, &CableQueryWidget::loadConfig); + + connect(add_pb, &QPushButton::clicked, this, &CableQueryWidget::addField); + connect(remove_pb, &QPushButton::clicked, this, &CableQueryWidget::removeField); + connect(up_pb, &QPushButton::clicked, this, &CableQueryWidget::moveFieldUp); + connect(down_pb, &QPushButton::clicked, this, &CableQueryWidget::moveFieldDown); + + connect(m_available_list, &QListWidget::itemDoubleClicked, + this, &CableQueryWidget::on_m_available_list_itemDoubleClicked); + connect(m_choosen_list, &QListWidget::itemDoubleClicked, + this, &CableQueryWidget::on_m_choosen_list_itemDoubleClicked); +} + +/** + @brief CableQueryWidget::~CableQueryWidget +*/ +CableQueryWidget::~CableQueryWidget() +{ +} + +/** + @brief CableQueryWidget::selectedKeys + @return the chosen columns, in order +*/ +QStringList CableQueryWidget::selectedKeys() const +{ + QStringList keys; + int row = 0; + while (auto *item = m_choosen_list->item(row)) + { + keys.append(item->data(Qt::UserRole).toString()); + ++row; + } + + return keys; +} + +/** + @brief CableQueryWidget::setFields + Put exactly these columns in the right list, in that order. Keys + this list does not know are ignored, which is what keeps a file + coming from a newer program readable here. + @param fields +*/ +void CableQueryWidget::setFields(const QStringList &fields) +{ + //Ugly hack to force to remove all chosen columns, the same way + //SummaryQueryWidget::reset() does it + while (auto *item = m_choosen_list->takeItem(0)) { + m_available_list->addItem(item); + } + + for (const QString &key : fields) + { + for (int row = 0; row < m_available_list->count(); ++row) + { + QListWidgetItem *item = m_available_list->item(row); + if (item->data(Qt::UserRole).toString() == key) { + m_available_list->takeItem(row); + m_choosen_list->addItem(item); + break; + } + } + } +} + +/** + @brief CableQueryWidget::setUpItems + Fill the available list with every column the list offers and + leave them there: a fresh dialog opens with its entries on the + left, the same way the material list dialog does. What a table or + an export starts with when nothing has been picked is the default + set of columns, applied where the table or the file is made. +*/ +void CableQueryWidget::setUpItems() +{ + for (const CableList::Column &column : CableList::allColumns()) + { + auto *item = new QListWidgetItem(column.label, m_available_list); + item->setData(Qt::UserRole, column.key); + } +} + +/** + @brief CableQueryWidget::fillSavedQuery + Fill the combo box of the saved configurations +*/ +void CableQueryWidget::fillSavedQuery() +{ + QFile file(QETApp::configDir() % QStringLiteral("/cable_list.json")); + if (file.open(QFile::ReadOnly)) + { + QJsonDocument jsd(QJsonDocument::fromJson(file.readAll())); + QJsonObject jso = jsd.object(); + + for (auto it = jso.begin() ; it != jso.end() ; ++it) { + m_config_gb->addItem(it.key()); + } + } +} + +/** + @brief CableQueryWidget::on_m_available_list_itemDoubleClicked + @param item +*/ +void CableQueryWidget::on_m_available_list_itemDoubleClicked(QListWidgetItem *item) +{ + Q_UNUSED(item) + addField(); +} + +/** + @brief CableQueryWidget::on_m_choosen_list_itemDoubleClicked + @param item +*/ +void CableQueryWidget::on_m_choosen_list_itemDoubleClicked(QListWidgetItem *item) +{ + Q_UNUSED(item) + removeField(); +} + +/** + @brief CableQueryWidget::addField +*/ +void CableQueryWidget::addField() +{ + if (auto *item = m_available_list->takeItem(m_available_list->currentRow())) { + m_choosen_list->addItem(item); + } +} + +/** + @brief CableQueryWidget::removeField +*/ +void CableQueryWidget::removeField() +{ + if (auto *item = m_choosen_list->takeItem(m_choosen_list->currentRow())) { + m_available_list->addItem(item); + } +} + +/** + @brief CableQueryWidget::moveFieldUp +*/ +void CableQueryWidget::moveFieldUp() +{ + auto row = m_choosen_list->currentRow(); + if (row <= 0) { + return; + } + + auto *item = m_choosen_list->takeItem(row); + m_choosen_list->insertItem(row - 1, item); + m_choosen_list->setCurrentItem(item); +} + +/** + @brief CableQueryWidget::moveFieldDown +*/ +void CableQueryWidget::moveFieldDown() +{ + auto row = m_choosen_list->currentRow(); + if (row == -1) { + return; + } + + auto *item = m_choosen_list->takeItem(row); + m_choosen_list->insertItem(row + 1, item); + m_choosen_list->setCurrentItem(item); +} + +/** + @brief CableQueryWidget::saveConfig +*/ +void CableQueryWidget::saveConfig() +{ + QFile file_(QETApp::configDir() % QStringLiteral("/cable_list.json")); + + if (file_.open(QFile::ReadWrite)) + { + QJsonDocument doc_(QJsonDocument::fromJson(file_.readAll())); + QJsonObject root_object; + + if (!doc_.isEmpty()) + { + root_object = doc_.object(); + if (root_object.contains(m_config_gb->text())) { + root_object.remove(m_config_gb->text()); + } + } + + QJsonObject object_; + object_.insert(QStringLiteral("fields"), selectedKeys().join(QLatin1Char(','))); + root_object[m_config_gb->text()] = object_; + + doc_.setObject(root_object); + file_.resize(0); + file_.write(doc_.toJson()); + } +} + +/** + @brief CableQueryWidget::loadConfig +*/ +void CableQueryWidget::loadConfig() +{ + auto name = m_config_gb->selectedText(); + if (name.isEmpty()) { + return; + } + + QFile file_(QETApp::configDir() % QStringLiteral("/cable_list.json")); + if (!file_.open(QFile::ReadOnly)) { + return; + } + + QJsonDocument doc_(QJsonDocument::fromJson(file_.readAll())); + QJsonObject object_ = doc_.object(); + + auto value = object_.value(name); + if (!value.isObject()) { + return; + } + + auto fields = value.toObject().value(QStringLiteral("fields")); + if (fields.isString()) { + setFields(fields.toString().split(QLatin1Char(','), Qt::SkipEmptyParts)); + } +} diff --git a/sources/cablelist/cablequerywidget.h b/sources/cablelist/cablequerywidget.h new file mode 100644 index 000000000..d1d4d7738 --- /dev/null +++ b/sources/cablelist/cablequerywidget.h @@ -0,0 +1,71 @@ +/* + 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 3 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 CABLEQUERYWIDGET_H +#define CABLEQUERYWIDGET_H + +#include + +class ConfigSaveLoaderWidget; +class QListWidget; +class QListWidgetItem; +class QPushButton; + +/** + @brief The CableQueryWidget class + The content tab of the dialog which puts a cable list on a sheet: + which columns the list shows, and in which order. Shaped after + SummaryQueryWidget -- two lists with arrows between them, a saved + configuration per name -- but the columns are chosen out of the + fixed set of CableList instead of out of a SQL query, so there is no + query line to edit. +*/ +class CableQueryWidget : public QWidget +{ + Q_OBJECT + + public: + explicit CableQueryWidget(QWidget *parent = nullptr); + ~CableQueryWidget(); + + static QString modelIdentifier() {return "cable_list";} + /// The chosen columns, in order + QStringList selectedKeys() const; + /// Put exactly these columns in the right list, in that + /// order; keys this list does not know are ignored + void setFields(const QStringList &fields); + + private slots: + void on_m_available_list_itemDoubleClicked(QListWidgetItem *item); + void on_m_choosen_list_itemDoubleClicked(QListWidgetItem *item); + void addField(); + void removeField(); + void moveFieldUp(); + void moveFieldDown(); + void saveConfig(); + void loadConfig(); + + private: + void setUpItems(); + void fillSavedQuery(); + + QListWidget *m_available_list = nullptr; + QListWidget *m_choosen_list = nullptr; + ConfigSaveLoaderWidget *m_config_gb = nullptr; +}; + +#endif // CABLEQUERYWIDGET_H diff --git a/sources/cablelist/cabletypelist.cpp b/sources/cablelist/cabletypelist.cpp new file mode 100644 index 000000000..4e8250341 --- /dev/null +++ b/sources/cablelist/cabletypelist.cpp @@ -0,0 +1,824 @@ +/* + 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 "cabletypelist.h" + +#include "../materiallist/materiallist.h" +#include "../qetinformation.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +/** + @brief The columns of a cable type file -- the whole catalogue, and + the only ones the program reads anything from. + + Three of them: the name of the type, how many cores it has and the + colour of each of them. In that order they are written into a brand + new file, and a label line is matched against them so that a + catalogue read back never depends on the wording of its first line. + + The material file the pattern was copied from holds more columns + (section, sheath colour, manufacturer and so on); they were never + read here and are not listed. A file which has them anyway loses + nothing: a header cell outside this list keeps its own name and its + own data, it carries no meaning, and the machine header line is + recognized by its shape rather than by this list, so an extra + column there never turns that line into a record of cables. +*/ +const QStringList &canonicalColumns() +{ + static const QStringList columns = { + QStringLiteral("designation"), + QStringLiteral("cores"), + QStringLiteral("core_colors") + }; + return columns; +} + +/** + @brief Tell whether a cell of the machine header can be a column key. + + A key is one plain lowercase word -- core_colors and + manufacturer_reference are typical -- written the same whichever + language the file is read in, which is exactly why it can sit under + the label line. A key the program doesn't know is still a key : its + column is kept and shown the way it is written, it carries no + meaning. What the shape really protects is the other side: a cable + ("H07V-K 3G1,5") never looks like this, so a line of cables is + never mistaken for a header. + @param cell + @return true when the cell can be part of the machine header line +*/ +bool isMachineKey(const QString &cell) +{ + if (cell.isEmpty()) { + return false; + } + for (const QChar c : cell) + { + const ushort u = c.unicode(); + const bool key = (u >= 'a' && u <= 'z') + || (u >= '0' && u <= '9') + || u == '_'; + if (!key) { + return false; + } + } + //A key starts with a letter, so that a row of numbers can never + //pass for one. + return cell.at(0).unicode() >= 'a' && cell.at(0).unicode() <= 'z'; +} + +/** + @brief Decode a csv file content. + The cable type file is written in utf-8 with a BOM, the same way the + material file is, so spreadsheets open it with the right encoding. A + file written by hand in the local encoding is still decodable: + fromUtf8() is only used when the bytes are valid utf-8. + @param raw + @return +*/ +QString decode(const QByteArray &raw) +{ + if (raw.startsWith("\xEF\xBB\xBF")) { + return QString::fromUtf8(raw.constData() + 3, raw.size() - 3); + } + + const QByteArray utf8 = QString::fromUtf8(raw).toUtf8(); + if (utf8 == raw) { + return QString::fromUtf8(raw); + } + return QString::fromLocal8Bit(raw); +} + +/** + @brief Tell whether a file exists and holds something. + An existing but empty file is the same as no file at all: it can be + filled with the template without losing anything. + @param path + @return +*/ +bool hasContent(const QString &path) +{ + const QFileInfo info(path); + return info.exists() && info.isFile() && info.size() > 0; +} + +} // namespace + +/** + @brief CableTypeList::settingsKey + @return the QSettings key holding the path of the cable type file +*/ +QString CableTypeList::settingsKey() +{ + return QStringLiteral("cable-management/cable-type-list-path"); +} + +/** + @brief CableTypeList::configuredPath + @return the path of the cable type file as chosen in the preferences, + or a null QString when the user never set one. +*/ +QString CableTypeList::configuredPath() +{ + QSettings settings; + const QString path = settings.value(settingsKey(), QStringLiteral("default")).toString(); + if (path.isEmpty() || path == QLatin1String("default")) { + return QString(); + } + return path; +} + +/** + @brief CableTypeList::setConfiguredPath + Store the path of the cable type file in the preferences. + @param path +*/ +void CableTypeList::setConfiguredPath(const QString &path) +{ + QSettings settings; + settings.setValue(settingsKey(), path.isEmpty() ? QStringLiteral("default") : path); +} + +/** + @brief CableTypeList::defaultFileName + @return the file name used when the cable type file is created without + an explicit location. +*/ +QString CableTypeList::defaultFileName() +{ + return QStringLiteral("qet_cable_types.csv"); +} + +/** + @brief CableTypeList::defaultPath + @return where the cable type file is created when no path is + configured yet : the standard documents folder. +*/ +QString CableTypeList::defaultPath() +{ + QString dir = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + while (dir.endsWith(QLatin1Char('/'))) { + dir.chop(1); + } + if (dir.isEmpty()) { + dir = QDir::currentPath(); + } + return dir + QLatin1Char('/') + defaultFileName(); +} + +/** + @brief CableTypeList::savedHeaderState + @return the saved column layout of the selection window, empty when the + window was never opened or the layout could not be read back. +*/ +QByteArray CableTypeList::savedHeaderState() +{ + QSettings settings; + return settings.value(QStringLiteral("cable-management/cable-type-list-header")).toByteArray(); +} + +/** + @brief CableTypeList::saveHeaderState + Remember the column layout of the selection window. + @param state the header state as saved by QHeaderView::saveState() +*/ +void CableTypeList::saveHeaderState(const QByteArray &state) +{ + QSettings settings; + settings.setValue(QStringLiteral("cable-management/cable-type-list-header"), state); +} + +/** + @brief CableTypeList::savedDialogSize + @return the size the selection window had the last time it was closed, + an invalid size when it was never opened. +*/ +QSize CableTypeList::savedDialogSize() +{ + QSettings settings; + const QSize size(settings.value(QStringLiteral("cable-management/cable-type-list-width")).toInt(), + settings.value(QStringLiteral("cable-management/cable-type-list-height")).toInt()); + //A size which was never written reads back as 0x0, which Qt still + //counts as valid : it must not be handed to resize(). + return size.isEmpty() ? QSize() : size; +} + +/** + @brief CableTypeList::saveDialogSize + Remember the size of the selection window. + @param size +*/ +void CableTypeList::saveDialogSize(const QSize &size) +{ + if (size.isEmpty()) { + return; + } + QSettings settings; + settings.setValue(QStringLiteral("cable-management/cable-type-list-width"), size.width()); + settings.setValue(QStringLiteral("cable-management/cable-type-list-height"), size.height()); +} + +/** + @brief CableTypeList::defaultColumns + @return the columns of a brand new cable type file, in writing order. +*/ +QStringList CableTypeList::defaultColumns() +{ + return canonicalColumns(); +} + +/** + @brief CableTypeList::translatedColumn + @param column a column of the cable type file + @return that column named the way it is shown to the user, in the + current language. A column QElectroTech doesn't know is returned as it + is, since it can only have been named by the user. +*/ +QString CableTypeList::translatedColumn(const QString &column) +{ + //The columns which are specific to a cable keep their own wording, + //the ones shared with the material file reuse the element + //information wording, so both catalogues read the same. The type + //column is the exception: it is called Designation here, the way + //the cable form calls it, and not "Article number", which is + //what an element means by it. + static const QMap own = { + {QStringLiteral("designation"), + QCoreApplication::translate("CableTypeList", "Designation")}, + {QStringLiteral("cores"), + QCoreApplication::translate("CableTypeList", "Number of cores")}, + {QStringLiteral("core_colors"), + QCoreApplication::translate("CableTypeList", "Core colors")}, + {QStringLiteral("section"), + QCoreApplication::translate("CableTypeList", "Cable section")}, + {QStringLiteral("color"), + QCoreApplication::translate("CableTypeList", "Sheath color")} + }; + + const auto it = own.constFind(column); + if (it != own.constEnd()) { + return it.value(); + } + + const QString translated = QETInformation::translatedInfoKey(column); + return translated.isEmpty() ? column : translated; +} + +/** + @brief CableTypeList::translatedHeader + The label line of a cable type file : the columns the way the user + reads them, in the current language. + @param columns + @return +*/ +QStringList CableTypeList::translatedHeader(const QStringList &columns) +{ + QStringList header; + header.reserve(columns.size()); + for (const QString &column : columns) { + header.append(translatedColumn(column)); + } + return header; +} + +/** + @brief CableTypeList::designation + @param record + @return the type name of that cable, the way it shows in the + selection list and on the drawing +*/ +QString CableTypeList::designation(const CableTypeRecord &record) +{ + return record.value(QStringLiteral("designation")); +} + +/** + @brief CableTypeList::coreCount + Tell how many cores a cable type holds. + @param record + @return the number of cores, or 0 when the file doesn't say. 0 means + "unknown", which the selection window treats as usable : a file which + says nothing is no reason to refuse a type. +*/ +int CableTypeList::coreCount(const CableTypeRecord &record) +{ + bool ok = false; + const int cores = record.value(QStringLiteral("cores")).trimmed().toInt(&ok); + return (ok && cores > 0) ? cores : 0; +} + +/** + @brief CableTypeList::coreColors + The colours of the cores, in the order they are written in the file, + which is the order they are given to the cores of a drawn cable. + @param record + @return + + The cell holds the colours separated by |, ; or ,. Writing always + uses | because it can't appear in a colour name and never needs + quoting, reading accepts the three so a hand written file works too. +*/ +QStringList CableTypeList::coreColors(const CableTypeRecord &record) +{ + QStringList colors; + const QString cell = record.value(QStringLiteral("core_colors")); + for (const QString &raw : cell.split(QLatin1Char('|'), Qt::SkipEmptyParts)) + { + for (const QString &part : raw.split(QLatin1Char(';'), Qt::SkipEmptyParts)) + { + for (const QString &color : part.split(QLatin1Char(','), Qt::SkipEmptyParts)) + { + const QString trimmed = color.trimmed(); + if (!trimmed.isEmpty()) { + colors.append(trimmed); + } + } + } + } + return colors; +} + +/** + @brief CableTypeList::headerAliasMap + @return every accepted header cell of a cable type file, lowercased, + mapped to the canonical column it means. + + Beside the key itself, a header may use the translated name of the + column ("Nombre d'âmes" for cores), or the name and translation of an + element information, so a catalogue sharing columns with the material + file is understood in both. +*/ +QMap CableTypeList::headerAliasMap() +{ + QMap map; + + for (const QString &key : canonicalColumns()) + { + map.insert(key.toLower(), key); + const QString translated = translatedColumn(key); + if (!translated.isEmpty()) { + map.insert(translated.toLower(), key); + } + //The element information spells the same column another way + //("Article number" for designation); both are accepted, so + //that a file written with either wording reads the same. + const QString info_key = QETInformation::translatedInfoKey(key); + if (!info_key.isEmpty() && info_key != translated) { + map.insert(info_key.toLower(), key); + } + } + + //Every element information is an acceptable column name, so that + //columns copied over from the material file keep their meaning. + const QStringList keys = QETInformation::elementInfoKeys(); + for (const QString &key : keys) + { + if (map.contains(key.toLower())) { + continue; + } + map.insert(key.toLower(), key); + const QString translated = QETInformation::translatedInfoKey(key); + if (!translated.isEmpty()) { + map.insert(translated.toLower(), key); + } + } + + //The wording older files were written with : these French names + //were the ones the label line carried before the interface text + //became English, and a catalogue written with them must keep + //reading, so each of them still means its column here. + static const QMap older_wording = { + {QStringLiteral("désignation"), QStringLiteral("designation")}, + {QStringLiteral("nombre d'âmes"), QStringLiteral("cores")}, + {QStringLiteral("couleurs des âmes"), QStringLiteral("core_colors")}, + {QStringLiteral("section du câble"), QStringLiteral("section")}, + {QStringLiteral("couleur du fourreau"), QStringLiteral("color")} + }; + for (auto it = older_wording.constBegin(); it != older_wording.constEnd(); ++it) + { + if (!map.contains(it.key())) { + map.insert(it.key(), it.value()); + } + } + + return map; +} + +/** + @brief CableTypeList::canonicalColumn + Turn one header cell into a canonical column name. + @param header_cell + @param already_used the names chosen for the previous cells, to keep + the header free of duplicates + @param alias_map lowercased alias -> canonical name + @return +*/ +QString CableTypeList::canonicalColumn(const QString &header_cell, + const QStringList &already_used, + const QMap &alias_map) +{ + const QString raw = header_cell.trimmed(); + + if (raw.isEmpty()) { + //A nameless column keeps its data and its place, it just can't be + //matched to anything. + QString name = QStringLiteral("column_%1").arg(already_used.size() + 1); + int i = already_used.size() + 1; + while (already_used.contains(name)) { + name = QStringLiteral("column_%1").arg(++i); + } + return name; + } + + const auto it = alias_map.constFind(raw.toLower()); + if (it != alias_map.constEnd() && !already_used.contains(it.value())) { + return it.value(); + } + + //Either an unknown name, or a second cell meaning something which is + //already used : the cell is kept as it is written, its data is never + //thrown away. + QString name = raw; + int i = 2; + while (already_used.contains(name)) { + name = raw + QStringLiteral("_%1").arg(i++); + } + return name; +} + +/** + @brief CableTypeList::isMachineHeaderLine + Tell whether the second line of a cable type file is the machine + header QElectroTech writes under the label line : the canonical + column names, kept so that a file written in one language is still + understood in another one. + @param label_line the first line of the file, the one the user reads + @param candidate the line which may be the machine header + @param alias_map lowercased alias -> canonical name + @return true when candidate must not be read as a record +*/ +bool CableTypeList::isMachineHeaderLine(const QStringList &label_line, + const QStringList &candidate, + const QMap &alias_map) +{ + if (candidate.isEmpty() || candidate.size() != label_line.size()) { + return false; + } + + //At least one cell the program really knows : a line made only + //of key shaped words nobody here gave a meaning to is far more + //likely to be a cable than a header. + bool has_known_column = false; + for (int i = 0; i < candidate.size(); ++i) + { + const QString cell = candidate.at(i).trimmed(); + if (cell.isEmpty()) { + continue; + } + if (alias_map.contains(cell.toLower())) + { + has_known_column = true; + continue; + } + if (cell == label_line.at(i).trimmed()) { + continue; + } + //An unknown key is still a key : the column comes back as + //it was written, its data is never thrown away. + if (isMachineKey(cell)) { + continue; + } + return false; + } + + return has_known_column; +} + +/** + @brief CableTypeList::load + Read a cable type file. + @param path + @param data receives the columns and the records + @param error receives a message when the file can't be read + @return true on success +*/ +bool CableTypeList::load(const QString &path, CableTypeListData *data, QString *error) +{ + if (error) { + error->clear(); + } + if (!data) { + if (error) { + *error = QCoreApplication::translate("CableTypeList", "No receiver for the cable types file."); + } + return false; + } + + data->columns.clear(); + data->records.clear(); + data->separator = QLatin1Char(';'); + + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + { + if (error) { + *error = file.errorString(); + } + return false; + } + const QByteArray raw = file.readAll(); + file.close(); + + const QString content = decode(raw); + if (content.trimmed().isEmpty()) { + return true; + } + + const int eol = content.indexOf(QLatin1Char('\n')); + data->separator = MaterialList::detectSeparator(eol < 0 ? content : content.left(eol)); + + QList rows = MaterialList::parseCsv(content, data->separator); + if (rows.isEmpty()) { + return true; + } + + const QStringList header = rows.takeFirst(); + const QMap alias_map = headerAliasMap(); + + //The label line is followed by the machine header QElectroTech + //writes itself : the canonical names, so that the file is read + //the same way whatever the language it was written in. + QStringList machine_line; + if (!rows.isEmpty() && isMachineHeaderLine(header, rows.first(), alias_map)) { + machine_line = rows.takeFirst(); + } + + QStringList used; + for (int i = 0; i < header.size(); ++i) + { + QString cell = header.at(i); + if (i < machine_line.size() && !machine_line.at(i).isEmpty()) { + cell = machine_line.at(i); + } + const QString column = canonicalColumn(cell, used, alias_map); + used.append(column); + data->columns.append(column); + } + + for (const QStringList &row : std::as_const(rows)) + { + CableTypeRecord record; + bool empty = true; + for (int i = 0; i < data->columns.size(); ++i) + { + const QString value = i < row.size() ? row.at(i) : QString(); + if (!value.isEmpty()) { + empty = false; + } + record.setValue(data->columns.at(i), value); + } + //Cells written past the header are not thrown away : they are + //written back the way they were read. + for (int i = data->columns.size(); i < row.size(); ++i) + { + if (!row.at(i).isEmpty()) { + empty = false; + } + record.extra.append(row.at(i)); + } + if (!empty) { + data->records.append(record); + } + } + + return true; +} + +/** + @brief CableTypeList::writeFile + Write a whole cable type file atomically : a crash or a full disk + never leaves a half written catalogue behind. + @param path + @param data + @param error receives a message when the file can't be written + @return true on success +*/ +bool CableTypeList::writeFile(const QString &path, const CableTypeListData &data, QString *error) +{ + if (error) { + error->clear(); + } + + QList rows; + rows.reserve(data.records.size()); + for (const CableTypeRecord &record : data.records) + { + QStringList row; + row.reserve(data.columns.size()); + for (const QString &column : data.columns) { + row.append(record.value(column)); + } + row.append(record.extra); + rows.append(row); + } + + QList header_lines; + header_lines.append(translatedHeader(data.columns)); + header_lines.append(data.columns); + const QByteArray content = MaterialList::serializeCsv(header_lines, rows, data.separator); + + //The folder may not be there yet: a fresh profile has no + //documents folder, and a path typed into the settings may name a + //folder which was never created. QSaveFile cannot write into a + //folder which does not exist, so without this the whole write + //fails and the message says nothing about the real reason. + const QString folder = QFileInfo(path).absolutePath(); + if (!folder.isEmpty() && !QDir(folder).exists() && !QDir().mkpath(folder)) + { + if (error) { + *error = QCoreApplication::translate( + "CableTypeList", + "Cannot create the folder %1 for the cable types file.").arg(folder); + } + return false; + } + + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly) || file.write(content) != content.size()) + { + if (error) { + *error = file.errorString(); + } + file.cancelWriting(); + return false; + } + if (!file.commit()) + { + if (error) { + *error = file.errorString(); + } + return false; + } + return true; +} + +/** + @brief CableTypeList::appendRecord + Add one cable type at the end of a cable type file. + The file is read again first: changes the user just saved from his + spreadsheet are part of what gets rewritten, never dropped. + @param path + @param record + @param error receives a message when the file can't be written + @return true on success +*/ +bool CableTypeList::appendRecord(const QString &path, const CableTypeRecord &record, QString *error) +{ + if (error) { + error->clear(); + } + + CableTypeListData data; + if (hasContent(path)) + { + if (!load(path, &data, error)) { + return false; + } + if (data.columns.isEmpty()) + { + if (error) { + *error = QCoreApplication::translate("CableTypeList", "The file contains no header: missing columns."); + } + return false; + } + } + else + { + data.columns = defaultColumns(); + data.separator = QLatin1Char(';'); + } + + data.records.append(record); + return writeFile(path, data, error); +} + +/** + @brief CableTypeList::updateRecord + Rewrite one line of a cable type file, the way a spreadsheet saves a + row he just edited: the line is found as it stands at this moment, + replaced by what the user left, and the whole file is written again + -- so the changes he saved from his spreadsheet in the meantime are + part of it rather than being thrown away. + @param path the file to write + @param before the line as it stands in the file right now + @param after the line as the user left it + @param error receives a message when the file can't be written, or + when the line he started from is no longer in it + @return true on success +*/ +bool CableTypeList::updateRecord(const QString &path, + const CableTypeRecord &before, + const CableTypeRecord &after, + QString *error) +{ + if (error) { + error->clear(); + } + + CableTypeListData data; + if (!load(path, &data, error)) { + return false; + } + if (data.columns.isEmpty()) + { + if (error) { + *error = QCoreApplication::translate("CableTypeList", "The file contains no header: missing columns."); + } + return false; + } + + int row = -1; + for (int i = 0; i < data.records.size(); ++i) + { + if (data.records.at(i) == before) { + row = i; + break; + } + } + if (row < 0) + { + //The line he started from is gone: rewriting whatever stands + //there now would overwrite somebody else's work. + if (error) { + *error = QCoreApplication::translate("CableTypeList", + "This entry no longer exists in the file: it may have been modified in the meantime."); + } + return false; + } + + data.records[row] = after; + return writeFile(path, data, error); +} + +/** + @brief CableTypeList::createFile + Create an empty cable type file holding only the header lines. + @param path + @param error receives a message when the file can't be written + @return true on success +*/ +bool CableTypeList::createFile(const QString &path, QString *error) +{ + if (error) { + error->clear(); + } + + if (hasContent(path)) + { + if (error) { + *error = QCoreApplication::translate("CableTypeList", "The file already exists and is not empty."); + } + return false; + } + + CableTypeListData data; + data.columns = defaultColumns(); + data.separator = QLatin1Char(';'); + return writeFile(path, data, error); +} + +/** + @brief CableTypeList::isEmptyFile + @param path + @return true when no file, or an empty one, exists at path +*/ +bool CableTypeList::isEmptyFile(const QString &path) +{ + return !hasContent(path); +} diff --git a/sources/cablelist/cabletypelist.h b/sources/cablelist/cabletypelist.h new file mode 100644 index 000000000..a2e5bfcfa --- /dev/null +++ b/sources/cablelist/cabletypelist.h @@ -0,0 +1,134 @@ +/* + 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 CABLETYPELIST_H +#define CABLETYPELIST_H + +#include +#include +#include +#include +#include +#include +#include + +/** + @brief One line of the cable type file. + + Same shape as MaterialRecord : the keys are column names, either one + QElectroTech knows (designation, manufacturer...) or one the user + invented, which is never interpreted but never thrown away either. +*/ +struct CableTypeRecord +{ + QMap values; + //Cells written after the last column of the header, kept as they + //are, exactly like MaterialRecord::extra. + QStringList extra; + + QString value(const QString &column) const {return values.value(column);} + void setValue(const QString &column, const QString &value) {values.insert(column, value);} + bool operator==(const CableTypeRecord &other) const {return values == other.values;} +}; + +/** + @brief The whole content of a cable type file. + + The separator and the column names are read from the file rather than + imposed, so a catalogue written in a spreadsheet keeps working. +*/ +struct CableTypeListData +{ + QStringList columns; + QList records; + QChar separator = QLatin1Char(';'); + + bool isEmpty() const {return columns.isEmpty() && records.isEmpty();} +}; + +/** + @brief The CableTypeList class reads and writes the cable type file (a + CSV file listing the cable kinds a project may use : their name, + how many cores they have and the colour of each of them). + + It is the cable counterpart of MaterialList and follows it step by + step : same settings handling, same two header lines (the labels the + user reads, then the canonical names QElectroTech matches against), + same lenient reading of a hand written file. + + This is a pure data class : it knows nothing about widgets, so it + stays usable from the tests and from the command line. +*/ +class CableTypeList +{ + public: + //Settings + static QString settingsKey(); + static QString configuredPath(); + static void setConfiguredPath(const QString &path); + static QString defaultPath(); + static QString defaultFileName(); + + //Geometry of the selection window, remembered from one opening + //to the next, the same way MaterialList remembers its own. + static QByteArray savedHeaderState(); + static void saveHeaderState(const QByteArray &state); + static QSize savedDialogSize(); + static void saveDialogSize(const QSize &size); + + //Columns + static QStringList defaultColumns(); + static QString translatedColumn(const QString &column); + static QStringList translatedHeader(const QStringList &columns); + + //Cable semantics + static QString designation(const CableTypeRecord &record); + static int coreCount(const CableTypeRecord &record); + static QStringList coreColors(const CableTypeRecord &record); + + //File io + static bool load(const QString &path, CableTypeListData *data, QString *error = nullptr); + static bool writeFile(const QString &path, const CableTypeListData &data, QString *error = nullptr); + static bool appendRecord(const QString &path, const CableTypeRecord &record, QString *error = nullptr); + /** + @brief Rewrite one line of a cable type file, the way a + spreadsheet saves a row he just edited. + @param path the file to write + @param before the line as it stands in the file right now + @param after the line as the user left it + @param error receives a message when the file can't be + written, or when that line is no longer in it + @return true on success + */ + static bool updateRecord(const QString &path, + const CableTypeRecord &before, + const CableTypeRecord &after, + QString *error = nullptr); + static bool createFile(const QString &path, QString *error = nullptr); + static bool isEmptyFile(const QString &path); + + private: + static QMap headerAliasMap(); + static QString canonicalColumn(const QString &header_cell, + const QStringList &already_used, + const QMap &alias_map); + static bool isMachineHeaderLine(const QStringList &label_line, + const QStringList &candidate, + const QMap &alias_map); +}; + +#endif // CABLETYPELIST_H diff --git a/sources/conductorproperties.cpp b/sources/conductorproperties.cpp index 6cf2f40fd..f65a2a340 100644 --- a/sources/conductorproperties.cpp +++ b/sources/conductorproperties.cpp @@ -284,6 +284,15 @@ void ConductorProperties::toXml(QDomElement &e) const e.setAttribute("text_color", text_color.name()); e.setAttribute("formula", m_formula); e.setAttribute("cable", m_cable); + if (!m_cable_uuid.isNull()) + { + //Only written when there is a cable: a conductor which is no + //part of one keeps whatever cable text it had and no reference + //at all, so a hand written value is never mistaken for a + //generated one. + e.setAttribute("cable_uuid", m_cable_uuid.toString()); + e.setAttribute("cable_slot", QString::number(m_cable_slot)); + } e.setAttribute("bus", m_bus); e.setAttribute("function", m_function); e.setAttribute("tension_protocol", m_tension_protocol); @@ -343,6 +352,8 @@ void ConductorProperties::fromXml(QDomElement &e) text_color = (xml_text_color.isValid()? xml_text_color : QColor(Qt::black)); m_formula = e.attribute("formula"); m_cable = e.attribute("cable"); + m_cable_uuid = QUuid(e.attribute("cable_uuid")); + m_cable_slot = e.attribute("cable_slot", QString::number(-1)).toInt(); m_bus = e.attribute("bus"); m_function = e.attribute("function"); m_tension_protocol = e.attribute("tension_protocol"); @@ -844,6 +855,8 @@ bool ConductorProperties::operator==(const ConductorProperties &other) const other.text_color == text_color &&\ other.m_formula == m_formula &&\ other.m_cable == m_cable &&\ + other.m_cable_uuid == m_cable_uuid &&\ + other.m_cable_slot == m_cable_slot &&\ other.m_bus == m_bus &&\ other.m_function == m_function &&\ other.m_tension_protocol == m_tension_protocol &&\ diff --git a/sources/conductorproperties.h b/sources/conductorproperties.h index d29bbdbaa..6bb0fd004 100644 --- a/sources/conductorproperties.h +++ b/sources/conductorproperties.h @@ -21,6 +21,7 @@ #include "qet.h" #include #include +#include class QPainter; @@ -97,6 +98,14 @@ class ConductorProperties m_bus, m_cable; + /// The cable of the project this conductor is a core of, null + /// when it belongs to no cable. The text shown in m_cable is + /// generated from the cable and never typed in: this reference + /// plus the slot below is all it takes to work it out again. + QUuid m_cable_uuid; + /// Which core of that cable this conductor shows, -1 when none + int m_cable_slot = -1; + int text_size, m_dash_size = 1; diff --git a/sources/diagram.cpp b/sources/diagram.cpp index ab0c504f7..a04d4b433 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -20,9 +20,12 @@ #include "ElementsCollection/elementcollectionhandler.h" #include "TerminalStrip/GraphicsItem/terminalstripitem.h" +#include "cable/cablecopy.h" +#include "cable/cablepart.h" #include "xml/terminalstripitemxml.h" #include "QPropertyUndoCommand/qpropertyundocommand.h" #include "diagramcontent.h" +#include "diagramevent/diagrameventaddcable.h" #include "diagramevent/diagrameventinterface.h" #include "diagramposition.h" #include "factory/elementfactory.h" @@ -900,6 +903,23 @@ bool Diagram::eventInterfaceIsRunning() const return m_event_interface && m_event_interface->isRunning(); } +/** + @brief Diagram::cableToolIsRunning + @return true while the cable drawing tool is the running tool. + + That tool is the one which takes the right mouse button away from + everything else: while it runs, a right click cancels the line being + drawn and must not open the quick command ring nor any context menu + with it. Every other tool lets go of the button again and is + surrounded by the ring the way it always was. +*/ +bool Diagram::cableToolIsRunning() const +{ + return m_event_interface + && m_event_interface->isRunning() + && qobject_cast(m_event_interface); +} + /** @brief Diagram::conductorsAutonumName @return the name of autonum to use. @@ -1420,9 +1440,10 @@ void Diagram::folioSequentialsToXml(QHash *cables_added) { QDomElement root = document.documentElement(); - return(fromXml(root, position, consider_informations, content_ptr)); + return(fromXml(root, position, consider_informations, content_ptr, cables_added)); } /** @@ -1662,7 +1683,8 @@ QPair pickEnds(const QDomElement &f, bool Diagram::fromXml(QDomElement &document, QPointF position, bool consider_informations, - DiagramContent *content_ptr) + DiagramContent *content_ptr, + QList *cables_added) { const QDomElement& root = document; // The first element must be a diagram @@ -1928,6 +1950,17 @@ bool Diagram::fromXml(QDomElement &document, //Load terminal strip item QVector added_strips { TerminalStripItemXml::fromXml(this, root) }; + //Load the cable lines a copy fragment carries -- built here + //rather than in the caller so that they are placed with the rest + //of the selection, but only when the caller asked to be handed + //them: a folio loaded from a project file holds no cable of its + //own, the cables of a project are read at project level. + QList added_cables; + if (cables_added) { + added_cables = CableCopy::read(this, root); + *cables_added = added_cables; + } + //Translate items if a new position was given in parameter if (position != QPointF()) { @@ -1938,6 +1971,7 @@ bool Diagram::fromXml(QDomElement &document, for (auto image : std::as_const(added_images )) added_items << image; for (auto table : std::as_const(added_tables )) added_items << table; for (const auto &strip : std::as_const(added_strips)) added_items << strip; + for (const auto &cable : std::as_const(added_cables)) added_items << cable; //Get the top left corner of the rectangle that contain all added items QRectF items_rect; diff --git a/sources/diagram.h b/sources/diagram.h index e0587a96a..c19cac53b 100644 --- a/sources/diagram.h +++ b/sources/diagram.h @@ -33,6 +33,7 @@ #include class Conductor; +class CablePart; class CustomElement; class DiagramContent; class DiagramPosition; @@ -203,14 +204,21 @@ class Diagram : public QGraphicsScene QPointF = QPointF(), bool = true, DiagramContent * = nullptr); + //The last argument is only for a copy fragment: when it is + //given, the cable lines that fragment carries are built as + //well and handed back through it -- they are not part of a + //DiagramContent, so the caller has to know about them to + //put them on the undo stack (see CableCopy) bool fromXml(QDomDocument &, QPointF = QPointF(), bool = true, - DiagramContent * = nullptr); + DiagramContent * = nullptr, + QList * = nullptr); bool fromXml(QDomElement &, QPointF = QPointF(), bool = true, - DiagramContent * = nullptr); + DiagramContent * = nullptr, + QList * = nullptr); void folioSequentialsToXml(QHash*, QDomElement *, @@ -231,6 +239,7 @@ class Diagram : public QGraphicsScene virtual void addItem (QGraphicsItem *item); virtual void removeItem (QGraphicsItem *item); bool eventInterfaceIsRunning() const; + bool cableToolIsRunning() const; // methods related to graphics options ExportProperties applyProperties(const ExportProperties &); diff --git a/sources/diagramevent/diagrameventaddcable.cpp b/sources/diagramevent/diagrameventaddcable.cpp new file mode 100644 index 000000000..51d9aaacd --- /dev/null +++ b/sources/diagramevent/diagrameventaddcable.cpp @@ -0,0 +1,463 @@ +/* + 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 "diagrameventaddcable.h" + +#include "../cable/addcablecommand.h" +#include "../cable/cablecreatedialog.h" +#include "../cable/cablepart.h" +#include "../diagram.h" +#include "../qetapp.h" +#include "../qetdiagrameditor.h" +#include "../qetproject.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + + ///A click closer than this to the start point is not an end point + const qreal min_end_length = 2.0; + +/** + @brief How far apart two grid points are, the same way the snapping + itself works it out -- the direction the line may be locked to has to + be judged against the same step the end point will land on. +*/ +qreal gridStep() +{ + QSettings settings; + const int x = settings.value(QStringLiteral("diagrameditor/Xgrid"), + Diagram::xGrid).toInt(); + return qMax(1, x); +} + +} // namespace + +/** + @brief DiagramEventAddCable::DiagramEventAddCable + @param diagram the folio the cable is drawn on +*/ +DiagramEventAddCable::DiagramEventAddCable(Diagram *diagram) : + DiagramEventInterface(diagram) +{ + m_running = true; + //Done here as well as in init(): Diagram::setEventInterface() + //only calls init() when it replaces another tool, so a fresh + //tool would otherwise never be set up at all. + init(); + //The message itself waits for the next turn of the event loop: + //setEventInterface() destroys the tool which was running before + //this constructor returns, and its destructor clears the status + //bar, which would wipe out anything shown before it is done. + QTimer::singleShot(0, this, [this]() { updateHint(); }); +} + +/** + @brief DiagramEventAddCable::~DiagramEventAddCable +*/ +DiagramEventAddCable::~DiagramEventAddCable() +{ + delete m_preview; + delete m_help_horiz; + delete m_help_verti; + + if (m_diagram) + { + for (QGraphicsView *view : m_diagram->views()) { + view->setContextMenuPolicy(Qt::DefaultContextMenu); + } + } + clearHint(); +} + +/** + @brief DiagramEventAddCable::init + While the tool runs, the right button belongs to it alone: it cancels + the line being drawn or ends the tool. The quick command ring and the + context menu of the folio would both pop up in the middle of the + drawing and take the tool away from under the user, so they are kept + out of the way until the tool is gone. +*/ +void DiagramEventAddCable::init() +{ + m_running = true; + if (m_diagram) + { + for (QGraphicsView *view : m_diagram->views()) { + view->setContextMenuPolicy(Qt::NoContextMenu); + } + } + updateHint(); +} + +/** + @brief DiagramEventAddCable::reset + Put the tool back to where it was before the first click: no line + kept, no preview, nothing remembered. The cross stays, since it + belongs to the tool rather than to one line. +*/ +void DiagramEventAddCable::reset() +{ + if (m_preview && m_diagram) { + m_diagram->removeItem(m_preview); + } + delete m_preview; + m_preview = nullptr; + m_has_anchor = false; + m_anchor = QPointF(); + m_axis = -1; + m_crossings.clear(); + updateHint(); +} + +/** + @brief DiagramEventAddCable::clearHint +*/ +void DiagramEventAddCable::clearHint() const +{ + if (!m_diagram || m_diagram->views().isEmpty()) return; + if (auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst())) { + editor->statusBar()->clearMessage(); + } +} + +/** + @brief DiagramEventAddCable::updateHint + Say what the next click does, and -- once the line has been pulled + out -- how many conductors it runs across, so the user knows before + the dialog opens what the cable is going to be given. +*/ +void DiagramEventAddCable::updateHint() const +{ + if (!m_diagram || m_diagram->views().isEmpty()) return; + auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst()); + if (!editor) return; + + if (!m_has_anchor) + { + editor->statusBar()->showMessage(tr( + "Left click: place the cable start; " + "drag or second click: finish; " + "right click: cancel; Esc: quit")); + return; + } + + editor->statusBar()->showMessage(tr( + "Left click or release: finish the line " + "(%n core(s) recognized; always horizontal or vertical); " + "right click: cancel the line", + "how many conductors the line being drawn already crosses", + m_crossings.size())); +} + +/** + @brief DiagramEventAddCable::mousePressEvent + @param event +*/ +void DiagramEventAddCable::mousePressEvent(QGraphicsSceneMouseEvent *event) +{ + if (Q_UNLIKELY(!m_diagram || m_diagram->isReadOnly())) return; + + if (event->button() == Qt::RightButton) + { + //Accepted first: emitting finish() makes the diagram destroy + //this very tool, so nothing may touch its members afterwards. + event->setAccepted(true); + + //First right click takes the line being drawn back, second one + //leaves the tool: the user always knows how far he has to press + //to get out, and never lands in a menu he did not ask for. + if (m_has_anchor) { + reset(); + } else { + m_running = false; + emit finish(); + } + return; + } + if (event->button() != Qt::LeftButton) return; + + const QPointF pos = Diagram::snapToGrid(event->scenePos()); + + if (!m_has_anchor) + { + m_has_anchor = true; + m_anchor = pos; + m_axis = -1; + updatePreview(pos); + event->setAccepted(true); + return; + } + + //A second click ends the line wherever it lands: it is snapped + //back onto whichever direction the line already runs along, so + //the user does not have to aim at the line itself. + const QPointF end = finishPoint(pos); + if (QLineF(m_anchor, end).length() >= min_end_length) { + finishLine(end); + } + event->setAccepted(true); +} + +/** + @brief DiagramEventAddCable::mouseMoveEvent + @param event +*/ +void DiagramEventAddCable::mouseMoveEvent(QGraphicsSceneMouseEvent *event) +{ + showGuides(event->scenePos()); + if (!m_has_anchor) return; + + updatePreview(event->scenePos()); + event->setAccepted(true); +} + +/** + @brief DiagramEventAddCable::mouseReleaseEvent + Letting go of a drag which was pulled out far enough lays the line + down straight away, which is how a line is drawn everywhere else in + QElectroTech -- a click without a drag, on the other hand, only puts + the start point down and waits for the next click. + @param event +*/ +void DiagramEventAddCable::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) +{ + if (event->button() != Qt::LeftButton || !m_has_anchor) return; + + const QPointF end = finishPoint(Diagram::snapToGrid(event->scenePos())); + if (QLineF(m_anchor, end).length() >= min_end_length) { + finishLine(end); + } + event->setAccepted(true); +} + +/** + @brief DiagramEventAddCable::keyPressEvent + @param event +*/ +void DiagramEventAddCable::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Escape) + { + if (m_has_anchor) + { + reset(); + event->setAccepted(true); + return; + } + } + DiagramEventInterface::keyPressEvent(event); +} + +/** + @brief DiagramEventAddCable::finishPoint + The point the line really ends at: snapped to the grid, and pulled + onto the direction the line already runs along, so a second click + which does not sit exactly on the axis still ends the line instead + of starting a new one. + @param scene_pos where the user clicked + @return the end of the line, in scene coordinates +*/ +QPointF DiagramEventAddCable::finishPoint(const QPointF &scene_pos) const +{ + if (m_axis == 0) { + return Diagram::snapToGrid(QPointF(scene_pos.x(), m_anchor.y())); + } + if (m_axis == 1) { + return Diagram::snapToGrid(QPointF(m_anchor.x(), scene_pos.y())); + } + + //The line was never pulled far enough to have a direction: take + //the one which is clearly longer, so it never ends up diagonal. + const QPointF delta = scene_pos - m_anchor; + if (qAbs(delta.x()) >= qAbs(delta.y())) { + return Diagram::snapToGrid(QPointF(scene_pos.x(), m_anchor.y())); + } + return Diagram::snapToGrid(QPointF(m_anchor.x(), scene_pos.y())); +} + +/** + @brief DiagramEventAddCable::updatePreview + Show the line as it will be laid down. + + The direction is decided again on every move rather than once and + for all: the user pulls, and the line follows whichever way he pulls + harder. A dead band of half a grid step keeps it from flickering + when he moves along the diagonal. + @param scene_pos where the mouse stands +*/ +void DiagramEventAddCable::updatePreview(const QPointF &scene_pos) +{ + if (!m_has_anchor || !m_diagram) return; + + QPointF end = scene_pos; + { + const QPointF delta = end - m_anchor; + const qreal dx = qAbs(delta.x()); + const qreal dy = qAbs(delta.y()); + const qreal dead = gridStep() * 0.5; + + int wanted = -1; + if (dx > dy + dead) { + wanted = 0; + } else if (dy > dx + dead) { + wanted = 1; + } else if (m_axis < 0 && (dx > dead || dy > dead)) { + wanted = dx >= dy ? 0 : 1; + } + if (wanted >= 0) { + m_axis = wanted; + } + } + + if (m_axis == 0) { + end.setY(m_anchor.y()); + } else if (m_axis == 1) { + end.setX(m_anchor.x()); + } + end = Diagram::snapToGrid(end); + + if (!m_preview) + { + m_preview = new QGraphicsLineItem; + QPen pen(QColor(0, 120, 215)); + pen.setWidthF(2.0); + pen.setStyle(Qt::DashLine); + m_preview->setPen(pen); + m_preview->setZValue(1000.0); + m_diagram->addItem(m_preview); + } + m_preview->setLine(QLineF(m_anchor, end)); + + //Keep the count of crossed conductors up to date, so the status + //bar already says what the dialog is about to say. + m_crossings = CableManager::crossedConductors(m_diagram, QLineF(m_anchor, end)); + updateHint(); +} + +/** + @brief DiagramEventAddCable::showGuides + The thin cross QElectroTech draws while any shape is drawn, which + says where the mouse stands in the folio and, for a line, along + which of the two directions it may run. + @param scene_pos where the mouse stands +*/ +void DiagramEventAddCable::showGuides(const QPointF &scene_pos) +{ + if (!m_diagram) return; + + if (!m_help_horiz || !m_help_verti) + { + QPen pen; + pen.setWidthF(0.4); + pen.setCosmetic(true); + pen.setColor(Diagram::background_color == Qt::darkGray ? Qt::lightGray : Qt::darkGray); + + const QRectF rect = m_diagram->border_and_titleblock.insideBorderRect(); + + if (!m_help_horiz) + { + m_help_horiz = new QGraphicsLineItem(rect.topLeft().x(), 0, rect.topRight().x(), 0); + m_help_horiz->setPen(pen); + m_help_horiz->setZValue(999.0); + m_diagram->addItem(m_help_horiz); + } + if (!m_help_verti) + { + m_help_verti = new QGraphicsLineItem(0, rect.topLeft().y(), 0, rect.bottomLeft().y()); + m_help_verti->setPen(pen); + m_help_verti->setZValue(999.0); + m_diagram->addItem(m_help_verti); + } + } + + const QPointF point = Diagram::snapToGrid(scene_pos); + m_help_horiz->setY(point.y()); + m_help_verti->setX(point.x()); +} + +/** + @brief DiagramEventAddCable::finishLine + Put the line down and hand it over to the dialog which turns it into + a cable -- a new one from the cable type file, or one the project + already holds. + @param end where the line stops +*/ +void DiagramEventAddCable::finishLine(const QPointF &end) +{ + if (!m_diagram || !m_diagram->project()) { + reset(); + return; + } + + QPointF to = end; + if (m_axis == 0) { + to.setY(m_anchor.y()); + } else if (m_axis == 1) { + to.setX(m_anchor.x()); + } + to = Diagram::snapToGrid(to); + + const QLineF line(m_anchor, to); + if (line.length() < min_end_length) { + reset(); + return; + } + + m_crossings = CableManager::crossedConductors(m_diagram, line); + + CablePartData part; + part.uuid = QUuid::createUuid(); + part.diagram = m_diagram->uuid(); + part.p1 = line.p1(); + part.p2 = line.p2(); + + //Remember which cable the line was given to: a brand new one was + //brought into being by this very dialog and undo has to take it + //away again, an existing one only gains a section. + const QVector before = m_diagram->project()->cables(); + + { + CableCreateDialog dialog(m_diagram, m_crossings, part, m_diagram->views().isEmpty() + ? nullptr : m_diagram->views().constFirst()); + if (dialog.exec() != QDialog::Accepted || !dialog.cable()) + { + reset(); + return; + } + + const bool own_cable = !before.contains(dialog.cable()); + + auto *item = new CablePart(part); + item->setCable(dialog.cable()); + m_diagram->addItem(item); + + m_diagram->project()->undoStack()->push( + new AddCableCommand(dialog.cable(), item, m_diagram, own_cable, + dialog.takenCores())); + } + + reset(); +} diff --git a/sources/diagramevent/diagrameventaddcable.h b/sources/diagramevent/diagrameventaddcable.h new file mode 100644 index 000000000..40b6cf0b1 --- /dev/null +++ b/sources/diagramevent/diagrameventaddcable.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 DIAGRAMEVENTADDCABLE_H +#define DIAGRAMEVENTADDCABLE_H + +#include "../cable/cablemanager.h" +#include "diagrameventinterface.h" + +class QLineF; +class QGraphicsLineItem; + +/** + @brief The DiagramEventAddCable class draws the trunk line of a cable. + + It behaves like every other drawing tool of QElectroTech: the first + click puts the start point down, the mouse then shows the line as it + will be, and either a second click or the release of a drag lays it + -- whichever the user does, he does not have to aim at anything. + + The line stays horizontal or vertical, whichever way the user pulls + harder, and it keeps following him: the direction is worked out again + on every mouse move (with a dead band so it does not flicker), never + frozen by the first stray pixel of the gesture -- a cable trunk runs + straight along a row of drawings, but along the row the user meant. +*/ +class DiagramEventAddCable : public DiagramEventInterface +{ + Q_OBJECT + + public: + explicit DiagramEventAddCable(Diagram *diagram); + ~DiagramEventAddCable() override; + + void mousePressEvent(QGraphicsSceneMouseEvent *event) override; + void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; + void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void init() override; + + private: + void reset(); + void updatePreview(const QPointF &scene_pos); + QPointF finishPoint(const QPointF &scene_pos) const; + void finishLine(const QPointF &end); + void showGuides(const QPointF &scene_pos); + void clearHint() const; + void updateHint() const; + + private: + QGraphicsLineItem *m_preview = nullptr; + ///The cross QElectroTech shows while any shape is drawn + QGraphicsLineItem *m_help_horiz = nullptr; + QGraphicsLineItem *m_help_verti = nullptr; + ///True once the first click has put the line's start down + bool m_has_anchor = false; + QPointF m_anchor; + ///Which way the line is locked: + ///-1 undecided, 0 horizontal, 1 vertical + int m_axis = -1; + QList m_crossings; +}; + +#endif // DIAGRAMEVENTADDCABLE_H diff --git a/sources/diagramevent/diagrameventaddpaste.cpp b/sources/diagramevent/diagrameventaddpaste.cpp index 354bd8132..c8a4e02ad 100644 --- a/sources/diagramevent/diagrameventaddpaste.cpp +++ b/sources/diagramevent/diagrameventaddpaste.cpp @@ -18,6 +18,10 @@ #include "diagrameventaddpaste.h" #include "../autoNum/ui/pastenumberingimport.h" +#include "../cable/addcablecommand.h" +#include "../cable/cablecopy.h" +#include "../cable/cablepart.h" +#include "../cable/editcablecommand.h" #include "../diagram.h" #include "../foliogrid.h" #include "../diagramcommands.h" @@ -30,12 +34,15 @@ #include #include +#include #include #include #include #include #include +#include + /** @brief DiagramEventAddPaste::DiagramEventAddPaste @param diagram : diagram to paste into @@ -74,8 +81,12 @@ db->setUpdateBlocked(true); } - //Load items at their original XML coordinates. - m_diagram->fromXml(document_xml, QPointF(), false, &m_content); + //Load items at their original XML coordinates. A cable line + //comes back through its own list rather than through the + //content: the cable owns it, so it is not one of the folio's + //items -- and it travels as a whole, colour labels and all, + //whether or not the place it is going to holds a wire. + m_diagram->fromXml(document_xml, QPointF(), false, &m_content, &m_cables); m_copied_schemes = PasteNumberingImport::copiedBy(document_xml); if (db) { @@ -83,9 +94,15 @@ db->setUpdateBlocked(false); db->updateDB(); } - if (!m_content.count()) return; + if (!m_content.count() && m_cables.isEmpty()) return; - const QList movable = m_content.items(MovableItems); + QList movable = m_content.items(MovableItems); + for (CablePart *part : std::as_const(m_cables)) { + if (part) { + movable << part; + m_cable_items.insert(part); + } + } if (movable.isEmpty()) return; //Compute the top-left of all items' actual on-screen bounding @@ -309,11 +326,24 @@ void DiagramEventAddPaste::moveTo(const QPointF &scene_pos) const QPointF delta = snapGrid(scene_pos) - m_initial_cursor; + //A cable line is handed a step rather than a place to stand: it + //folds a translation into its own geometry and into the colour + //labels it carries and keeps its own pos() at (0, 0) + //(CablePart::itemChange), so the position stored above -- always + //(0, 0) for it -- would be an addition again on every mouse move + //and the line would run away from the group it was pasted with. + //The difference to the last step keeps the translation the same + //one the rest of the paste gets, expressed their way: a place. + const QPointF cable_step = delta - m_cable_delta; for (auto it = m_relative_pos.constBegin() ; it != m_relative_pos.constEnd() ; ++it) { - if (it.key()) { + if (!it.key()) continue; + if (m_cable_items.contains(it.key())) { + it.key()->setPos(cable_step); + } else { it.key()->setPos(it.value() + delta); } } + m_cable_delta = delta; //Update conductor paths so they follow the moved terminals. const QList conductors = m_content.conductors(DiagramContent::AnyConductor); @@ -377,6 +407,11 @@ void DiagramEventAddPaste::keyPressEvent(QKeyEvent *event) PasteDiagramCommand's first redo() does not add the items to the scene -- it assumes they are already there, which is what Diagram::fromXml did when this started. So pushing it here adopts them rather than duplicating them. + + The cable lines are wired just before that, which is the first moment + the place they will occupy is final: each colour label takes the wire + it happens to stand on, and a line the user refuses to let take wires + away from another cable goes away again on its own. */ void DiagramEventAddPaste::commit() { @@ -384,10 +419,35 @@ void DiagramEventAddPaste::commit() m_finished = true; m_running = false; + const QList wired = CableCopy::wire( + m_diagram, m_cables, + m_content.conductors(DiagramContent::AnyConductor)); + m_cables.clear(); + + //The cable lines the copy brings are not part of the paste + //itself: they are handed to the paste so that one Ctrl+Z takes + //the whole of it back in one go. + QList cable_commands; + for (const CableCopy::Wired &line : std::as_const(wired)) + { + if (!line.part) continue; + + auto *added = new AddCableCommand(line.part->cable(), line.part.data(), + m_diagram, true, line.taken); + added->setText(QCoreApplication::translate("CableCopy", "Paste a cable")); + cable_commands << added; + } + //Asks whether to import the numberings the copy brings, if the - //project has not got them + //project has not got them; the paste itself and the cable lines + //travel inside the same step of the undo stack. PasteNumberingImport::push(m_diagram->views().isEmpty() ? nullptr : m_diagram->views().first(), - m_diagram, m_content, m_copied_schemes); + m_diagram, m_content, m_copied_schemes, cable_commands); + + //The undo stack has just run its first redo, which may have + //renewed the identity of a pasted conductor: the cores are + //pointed at the wires again so their cable field survives it. + CableCopy::repoint(wired); emit finish(); } @@ -419,6 +479,15 @@ void DiagramEventAddPaste::removeItems() delete item; } + //The cable lines never reached the undo stack either, so they + //are taken back the way they were built: off the folio, out of + //the project and deleted, leaving no trace of having been + //there -- not even a project asking to be saved for nothing. + for (CablePart *part : std::as_const(m_cables)) { + CableCopy::discard(m_diagram, part); + } + m_cables.clear(); + m_content.clear(); m_relative_pos.clear(); } diff --git a/sources/diagramevent/diagrameventaddpaste.h b/sources/diagramevent/diagrameventaddpaste.h index 12aafdd26..5e6012290 100644 --- a/sources/diagramevent/diagrameventaddpaste.h +++ b/sources/diagramevent/diagrameventaddpaste.h @@ -24,7 +24,9 @@ #include #include +#include +class CablePart; class QStatusBar; /** @@ -91,6 +93,25 @@ class DiagramEventAddPaste : public DiagramEventInterface void removeItems(); DiagramContent m_content; + ///The cable lines the same fragment carried, if any. They are + ///not part of a DiagramContent -- the cable owns them -- so + ///they travel beside it: moved with the rest of the paste, + ///wired where it lands, and taken away whole if it never lands. + QList m_cables; + ///The cable lines among them, as plain items. Their setPos() is + ///a translation rather than a move: CablePart works in scene + ///coordinates and keeps itself at the origin, folding whatever + ///it is handed into its own geometry and into the colour labels + ///it carries (CablePart::itemChange). Everything else in + ///m_relative_pos is given a place to stand; these are given a + ///step to take. + QSet m_cable_items; + ///The step already handed to those cable lines. They take the + ///difference to the new one, because for them a setPos() is an + ///addition: handing them the baseline-relative position on every + ///mouse move would add it again each time and let the line run + ///away from the rest of the paste. + QPointF m_cable_delta; /// The numberings the pasted copy carries QList m_copied_schemes; ///Each movable item's position relative to the group's top left, diff --git a/sources/diagramtoolbarsettings.cpp b/sources/diagramtoolbarsettings.cpp index 9fed1b76a..95964b227 100644 --- a/sources/diagramtoolbarsettings.cpp +++ b/sources/diagramtoolbarsettings.cpp @@ -193,6 +193,11 @@ QStringList DiagramToolbarSettings::defaultIds(const QString &name) QStringLiteral("diagrameditor.conductor_reset"), QStringLiteral("diagrameditor.auto_conductor"), QStringLiteral("diagrameditor.auto_break_conductor"), + //The cable tool is drawn across the conductors those two + //actions work on, so it sits with them rather than with + //the shapes of the "Ajouter" toolbar. + sep, + QStringLiteral("diagrameditor.add_cable"), QStringLiteral("widget:conductor_color")}; } if (name == QLatin1String("adding")) { diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index e758b91de..548606aa0 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -23,6 +23,10 @@ #include "qetproject.h" #include "QPropertyUndoCommand/qpropertyundocommand.h" #include "diagramcommands.h" +#include "cable/addcablecommand.h" +#include "cable/cablecopy.h" +#include "cable/cablepart.h" +#include "cable/editcablecommand.h" #include "diagramevent/diagrameventaddelement.h" #include "diagramevent/diagrameventaddmacro.h" #include "dvevent/dveventinterface.h" @@ -67,6 +71,29 @@ static bool hasCabinetInfo(const DiagramContext &infos); +/** + @brief batchWith + Keep two undo steps together so one Ctrl+Z takes the whole thing + back in one go. + + The batch also keeps the name the undo history shows for what the + user actually did: BatchCommand's own name says "Supprimer", which + is what it was first written for and would be misleading for a paste + or a duplicate. + @param first the step already assembled, may be null + @param second the step to add; ownership goes to the result + @return both steps as one command, or the only one of them +*/ +static QUndoCommand *batchWith(QUndoCommand *first, QUndoCommand *second) +{ + if (!first) return second; + if (!second) return first; + + auto *batched = new BatchCommand(first, second); + if (!first->text().isEmpty()) batched->setText(first->text()); + return batched; +} + /** Constructeur @param diagram Schema a afficher ; si diagram vaut 0, un nouveau Diagram est utilise @@ -638,8 +665,29 @@ void DiagramView::cut() { copy(); DiagramContent cut_content(m_diagram); + //A cable line is not held by DiagramContent: it leaves with a + //command of its own, which takes the cable itself away too when + //no line of it is left on the folio. + QList cable_cut; + for (QGraphicsItem *item : m_diagram->selectedItems()) { + if (auto *part = qgraphicsitem_cast(item)) { + cable_cut << part; + } + } + m_diagram -> clearSelection(); - m_diagram -> undoStack().push(new CutDiagramCommand(m_diagram, cut_content)); + + if (cable_cut.isEmpty()) { + m_diagram -> undoStack().push(new CutDiagramCommand(m_diagram, cut_content)); + return; + } + + QUndoCommand *command = nullptr; + if (cut_content.count()) { + command = new CutDiagramCommand(m_diagram, cut_content); + } + command = batchWith(command, new RemoveCableCommand(m_diagram, cable_cut)); + m_diagram -> undoStack().push(command); } /** @@ -649,7 +697,12 @@ void DiagramView::cut() void DiagramView::copy() { QClipboard *clipboard = QApplication::clipboard(); - QString content_clipboard = m_diagram -> toXml(false, true).toString(4); + QDomDocument document = m_diagram -> toXml(false, true); + //A drawn cable line is not one of the folio's items -- the cable + //owns it -- so it travels in a block of its own, beside the XML + //of the folio rather than in it. + CableCopy::write(m_diagram, document); + QString content_clipboard = document.toString(4); if (clipboard -> supportsSelection()) clipboard -> setText(content_clipboard, QClipboard::Selection); clipboard -> setText(content_clipboard); } @@ -670,16 +723,52 @@ void DiagramView::paste(const QPointF &pos, QClipboard::Mode clipboard_mode) { if (!document_xml.setContent(text_clipboard)) return; DiagramContent content_pasted; - m_diagram->fromXml(document_xml, pos, false, &content_pasted); + //A cable line is not part of a DiagramContent: it comes back + //through its own list, carrying the colour labels it was copied + //with, and each of them is given the wire it happens to stand + //on at the place it has just been put -- none at all, when that + //place holds no wire, in which case the labels go on standing + //on the line to be wired when it is pulled into place. + QList cable_pasted; + m_diagram->fromXml(document_xml, pos, false, &content_pasted, &cable_pasted); + const QList wired = CableCopy::wire( + m_diagram, cable_pasted, + content_pasted.conductors(DiagramContent::AnyConductor)); //If something was really added to diagram, we create an undo object. - if (content_pasted.count()) + if (content_pasted.count() || !wired.isEmpty()) { m_diagram -> clearSelection(); + + //One single step for the whole paste, cable lines included: + //undoing has to take back everything which came out of the + //clipboard in one go, and redoing has to put it all back. + QList cable_commands; + for (const CableCopy::Wired &line : std::as_const(wired)) { + if (!line.part) continue; + auto *added = new AddCableCommand(line.part->cable(), line.part.data(), + m_diagram, true, line.taken); + added->setText(QCoreApplication::translate("CableCopy", "Paste a cable")); + cable_commands << added; + } + //The copies end up selected, like everything else a paste + //just added, so the next Ctrl+V or Ctrl+D carries on from them + for (const CableCopy::Wired &line : std::as_const(wired)) { + if (line.part) line.part->setSelected(true); + } + //Asks whether to import the numberings the copy brings, if the - //project has not got them + //project has not got them; the paste itself and the cable lines + //travel inside the same step of the undo stack. PasteNumberingImport::push(this, m_diagram, content_pasted, - PasteNumberingImport::copiedBy(document_xml)); + PasteNumberingImport::copiedBy(document_xml), + cable_commands); + //The stack has just renewed the identity of a pasted + //conductor: the cores are pointed at the wires again and + //the cable fields written out once more, so the entries + //the copy was given as it was put down are on the wires it + //has actually landed on. + CableCopy::repoint(wired); adjustSceneRect(); } } @@ -749,21 +838,51 @@ void DiagramView::duplicate(const QPoint &stepOffset) // Mirrors copy(), but does not touch the system clipboard: Ctrl+D // should not clobber whatever the user last copied with Ctrl+C. QDomDocument document = m_diagram->toXml(false, true); + CableCopy::write(m_diagram, document); DiagramContent pasted; + QList cable_pasted; // No position argument -- see the function comment above for why // the offset is not passed here. - m_diagram->fromXml(document, QPointF(), false, &pasted); - if (!pasted.count()) return; + m_diagram->fromXml(document, QPointF(), false, &pasted, &cable_pasted); + if (!pasted.count() && cable_pasted.isEmpty()) return; const int movable = DiagramContent::Elements | DiagramContent::TextFields | DiagramContent::Images | DiagramContent::Shapes | DiagramContent::Tables | DiagramContent::TerminalStrip; for (QGraphicsItem *item : pasted.items(movable)) item->setPos(item->pos() + offset); + //The new line is stamped beside the original like everything + //else -- and only after that, because where each colour label + //ends up decides which wire it is going to take + for (CablePart *part : std::as_const(cable_pasted)) + if (part) part->setPos(part->pos() + offset); + + const QList wired = CableCopy::wire( + m_diagram, cable_pasted, + pasted.conductors(DiagramContent::AnyConductor)); m_diagram->clearSelection(); - m_diagram->undoStack().push(new PasteDiagramCommand(m_diagram, pasted)); + + QUndoCommand *command = nullptr; + if (pasted.count()) { + command = new PasteDiagramCommand(m_diagram, pasted); + } + for (const CableCopy::Wired &line : std::as_const(wired)) { + if (!line.part) continue; + auto *added = new AddCableCommand(line.part->cable(), line.part.data(), + m_diagram, true, line.taken); + added->setText(QCoreApplication::translate("CableCopy", "Duplicate a cable")); + command = batchWith(command, added); + } + for (const CableCopy::Wired &line : std::as_const(wired)) { + if (line.part) line.part->setSelected(true); + } + if (command) m_diagram->undoStack().push(command); + //Same as a paste: the stack has renewed the identity of the + //pasted conductors, so the cores are pointed at the wires again + //and the cable fields written out once more. + CableCopy::repoint(wired); adjustSceneRect(); } @@ -793,22 +912,59 @@ void DiagramView::mousePressEvent(QMouseEvent *e) m_press_pos = e->position().toPoint(); } + //A line which waits for the click putting one of its cores down + //owns the right button as well: that click says "no" to the core, + //so it neither starts the quick command ring nor reaches the + //sheet. It is the context menu of this very click which ends the + //wait (see contextMenuEvent()), which is why nothing is ended + //here: the menu is then cancelled instead of opened, and no menu + //can open out of a click meant to say "no" -- neither QET's own + //nor the platform's, which arrives with the press on X11 and with + //the release on Windows. + if (e->button() == Qt::RightButton && CablePart::placingPart()) { + e->accept(); + return; + } + //Right button: a click opens the context menu on release, a drag is //a gesture (DiagramGestureOverlay). Left alone while a text is edited. m_swallow_native_menu = false; m_gesture_over_tool = false; + + //A right click on the running cable tool belongs to the tool + //alone: it cancels the line being drawn, and ending the tool puts + //the context menu policy of the views back before this very click + //is over. The platform then still sends its own context menu for + //the same click -- on X11 with the press, on Windows with the + //release -- and the menu would open on the click which was meant + //to cancel. So it is swallowed here, the way a gesture swallows + //it further down; the next press which no tool handles puts this + //flag back to false. No other tool is touched: they hand the + //button back and are surrounded by the ring, as they always were. + if (e->button() == Qt::RightButton && m_diagram + && m_diagram->cableToolIsRunning()) { + m_swallow_native_menu = true; + } if (e->button() == Qt::RightButton && DiagramGestureOverlay::isEnabled() - && !m_diagram->focusItem()) + && !m_diagram->focusItem() + && !m_diagram->cableToolIsRunning()) { + //The right button draws the quick command ring, or opens the + //context menu on release. The cable tool alone does not let go + //of it: while it runs no gesture is tracked at all, because + //tracking one would both pop the ring up in the middle of the + //drawing and take the tool away from the click meant to + //cancel it (see mouseMoveEvent, which ends the tool as soon + //as a gesture is recognised). m_gesture_tracking = true; m_gesture_origin = e->position().toPoint(); m_context_toolbar->hide(); - //A tool is running, often one a gesture just started. A right - //click still goes to it -- it cancels or finishes the tool -- so - //the press carries on to the scene. A drag ends the tool and - //shows the ring (see mouseMoveEvent). + //Another tool is running, often one a gesture just started. A + //right click still goes to it -- it cancels or finishes the + //tool -- so the press carries on to the scene. A drag ends the + //tool and shows the ring (see mouseMoveEvent). if (m_diagram->eventInterfaceIsRunning()) { m_gesture_over_tool = true; } @@ -1277,6 +1433,15 @@ void DiagramView::keyPressEvent(QKeyEvent *e) if (m_diagram && m_diagram->eventInterfaceIsRunning()) { QGraphicsView::keyPressEvent(e); // let the active tool see it return; + } + //A line waiting for the click putting a core down has the + //first call on Escape: it lets that core go back to being + //a free one and lets the mouse go, while everything else + //-- the selection included -- stays as it is, so that + //Escape keeps doing one thing at a time. + if (CablePart::cancelPlacing()) { + e->accept(); + return; } if (m_diagram && !m_diagram->selectedItems().isEmpty()) { m_diagram->clearSelection(); @@ -1995,12 +2160,67 @@ QList DiagramView::contextMenuActions() const return list; } +/** + @brief DiagramView::cableCoreMenu + The menu of one single core, opened where he right-clicked: taking + that core off its line, which is the exact opposite of the menu the + free cores offer in the panel, of putting one down on a line. + + Only the colour labels count -- a right-click anywhere else on the + line is still about the line itself, and opens the folio's own menu + instead. Nothing is changed until he picks the entry, so opening the + menu and clicking it away leaves everything standing as it was. + @param menu_pos where he right-clicked, in view coordinates + @param global_pos the same place, in global coordinates + @return true when that menu was the one to open +*/ +bool DiagramView::cableCoreMenu(const QPoint &menu_pos, const QPoint &global_pos) +{ + if (!m_diagram || m_diagram->isReadOnly()) return false; + + const QPointF scene_pos = mapToScene(menu_pos); + auto *part = qgraphicsitem_cast( + m_diagram->itemAt(scene_pos, transform())); + if (!part) return false; + + const int core = part->coreAt(scene_pos); + if (core < 0) return false; + + //The line he takes a core off is the one on the sheet, so it is + //also the one the panel beside it edits from now on. + if (!part->isSelected()) { + m_diagram->clearSelection(); + part->setSelected(true); + } + + QMenu menu(this); + QAction *take = menu.addAction(tr("Remove this core from the line")); + take->setIcon(QET::Icons::Cable); + + if (menu.exec(global_pos) == take) { + part->takeCoreOffLine(core); + } + return true; +} + /** @brief DiagramView::contextMenuEvent @param e */ void DiagramView::contextMenuEvent(QContextMenuEvent *e) { + //A line which waits for the click putting one of its cores down + //takes this very event: the right button ends that wait and no + //menu opens out of a click meant to say "no". Being asked here + //first is what makes it work for every way a menu can come up -- + //the gesture ring's own menu, the platform's (with the press on + //X11, with the release on Windows) and the one raised from the + //keyboard -- so the menu never has to be closed after the fact. + if (CablePart::cancelPlacing()) { + e->accept(); + return; + } + QPoint menu_pos = e->pos(); QPoint menu_global_pos = e->globalPos(); @@ -2049,6 +2269,16 @@ void DiagramView::contextMenuEvent(QContextMenuEvent *e) } else { + //A colour label standing on a line asks about that one core + //rather than about the whole sheet: the very opposite of the + //menu the free cores offer, of putting one down on a line. + //It comes first, so that a core drawn over something else + //still belongs to its own line. + if (cableCoreMenu(menu_pos, menu_global_pos)) { + e->accept(); + return; + } + QGraphicsView::contextMenuEvent(e); if(e->isAccepted()) return; diff --git a/sources/diagramview.h b/sources/diagramview.h index 96e5f3820..4a3601ed7 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -143,6 +143,20 @@ class DiagramView : public PaletteGraphicsView private: void handleElementDrop(QDropEvent *); void updateFolioReportMenu(); + /** + The menu of one single core: the right button coming down + on a colour label which stands on a line asks about that + core rather than about the whole folio -- taking it off the + line, which is the exact opposite of the menu the free + cores offer, of putting one down on a line. + + Nothing opens when the point carries no colour label: the + folio's own menu is then still the right one. + @param menu_pos where he right-clicked, in view coordinates + @param global_pos the same place, in global coordinates + @return true when that menu was the one to open + */ + bool cableCoreMenu(const QPoint &menu_pos, const QPoint &global_pos); void handleTitleBlockDrop(QDropEvent *); void handleTextDrop(QDropEvent *); void handleImageFilesDrop(QDropEvent *); diff --git a/sources/elementsmover.cpp b/sources/elementsmover.cpp index c49ee9579..947f1e015 100644 --- a/sources/elementsmover.cpp +++ b/sources/elementsmover.cpp @@ -19,6 +19,8 @@ #include "qetproject.h" #include "autobreakconductor.h" #include "conductorautonumerotation.h" +#include "cable/cablepart.h" +#include "cable/editcablecommand.h" #include "diagram.h" #include "itemgroups.h" #include "qetgraphicsitem/conductor.h" @@ -116,6 +118,54 @@ int ElementsMover::beginMovement(Diagram *diagram, QGraphicsItem *driver_item) if (!m_moved_content.count()) return(-1); + //Steps left over by a gesture which never reached its end are + //never worth keeping: whatever starts here starts from nothing. + qDeleteAll(m_extra_commands); + m_extra_commands.clear(); + + //The cable lines he marked together with the elements come along + //too: a selection of elements and lines is one thing to move. + //When the finger is on a line itself, that line carries its own + //kind -- every line of the selection is begun by the one being + //dragged (see CablePart) -- so there is nothing to collect here. + m_moved_cables.clear(); + if (!qgraphicsitem_cast(m_movement_driver)) + { + for (QGraphicsItem *item : diagram->selectedItems()) + { + auto *cable = qgraphicsitem_cast(item); + if (!cable || !cable->cable()) continue; + + cable->beginLineGesture(); + m_moved_cables.append(cable); + } + } + + //Where every one of them stands at this moment: calling the + //movement off puts them all back here, recorded rather than + //worked out from the movement, since every step of that movement + //was snapped to the grid on its way and need not land on the + //same place coming back. + m_start_positions.clear(); + typedef DiagramContent dc; + for (auto &qgi : m_moved_content.items(dc::Elements + | dc::TextFields + | dc::Images + | dc::Shapes + | dc::ElementTextFields + | dc::TextGroup)) + { + m_start_positions.insert(qgi, qgi->pos()); + } + for (auto *conductor : m_moved_content.m_conductors_to_move) + { + if (conductor && conductor->textItem() + && conductor->textItem()->wasMovedByUser()) { + m_start_positions.insert(conductor->textItem(), + conductor->textItem()->pos()); + } + } + /* At this point, we've got all info to manage movement. * There is now a move in progress */ m_movement_running = true; @@ -177,9 +227,25 @@ void ElementsMover::continueMovement(const QPointF &movement) conductor->updatePath(); } + //The cable lines he marked together with the elements come along + //by the very same step: one gesture moves the whole selection + //instead of the lines standing still while the elements go, or + //the other way round. They are drawn where the gesture brings + //them, exactly as if the finger had been on the line itself, and + //settled once at the end (see endMovement). + for (CablePart *cable : std::as_const(m_moved_cables)) + { + if (cable) cable->continueLineGesture(movement); + } + if (m_status_bar && m_movement_driver) { - const auto point_{m_movement_driver->scenePos()}; + //A line does not stand at a point of its own -- it is drawn + //straight on the sheet -- so its own end is what is worth + //showing rather than its origin, which never moves. + auto *cable = qgraphicsitem_cast(m_movement_driver); + const auto point_{cable ? cable->firstPoint() + : m_movement_driver->scenePos()}; m_status_bar->showMessage(QString("x %1 : y %2").arg(QString::number(point_.x()), QString::number(point_.y()))); } } @@ -195,6 +261,30 @@ void ElementsMover::endMovement() // A movement must be inited if (!m_movement_running) return; + //The lines he marked together with the elements settle first: + //they may have to be asked something before they can rest, and + //their answer decides whether the whole gesture is written at + //all -- one of them calling it off calls off everything, the + //elements included, and there is nothing to undo because nothing + //at all has been written. + QList cable_steps; + for (CablePart *cable : std::as_const(m_moved_cables)) + { + if (!cable) continue; + if (!cable->settleLineGesture(cable_steps)) + { + qDeleteAll(cable_steps); + cable_steps.clear(); + cancelMovement(); + return; + } + } + for (CablePart *cable : std::as_const(m_moved_cables)) + { + if (cable) cable->doneLineGesture(); + } + m_moved_cables.clear(); + //empty command to be used has parent of commands below QUndoCommand *undo_object{new QUndoCommand()}; @@ -270,18 +360,118 @@ void ElementsMover::endMovement() } } - //Add undo_object if have child - if (undo_object->childCount()) - m_diagram->undoStack().push(undo_object); + //Add undo_object if have child -- or, when the lines of this + //selection brought their own undo over (see addExtraCommands), + //one single step for the whole gesture: taking it back has to + //take all of it back, or half the selection would stay where + //the gesture left it. + QList steps = m_extra_commands; + m_extra_commands.clear(); + steps.append(cable_steps); + + if (steps.isEmpty()) + { + if (undo_object->childCount()) + m_diagram->undoStack().push(undo_object); + else + delete undo_object; + } else - delete undo_object; + { + QString text = undo_object->text(); + if (text.isEmpty()) + text = steps.first()->text(); + + if (undo_object->childCount()) + steps.prepend(undo_object); + else + delete undo_object; + + if (steps.size() == 1) + m_diagram->undoStack().push(steps.takeFirst()); + else + m_diagram->undoStack().push(new BatchCommand(steps, text)); + } // There is no movement in progress now m_movement_running = false; m_moved_content.clear(); + m_moved_cables.clear(); + m_start_positions.clear(); //Keep saying why a held group did not move if (m_status_bar && !m_driver_held) { m_status_bar->clearMessage(); } } + +/** + @brief ElementsMover::addExtraCommands + Take over undo steps of items this mover does not move itself -- + the cable lines the user dragged himself -- so that the end of the + movement pushes them together with its own: one gesture, one step + in the history, whatever the selection is made of. + + They are thrown away by cancelMovement() if the gesture is called + off, and pushed by endMovement() otherwise. + @param steps the commands, given away with them +*/ +void ElementsMover::addExtraCommands(const QList &steps) +{ + for (QUndoCommand *step : steps) + { + if (step) m_extra_commands.append(step); + } +} + +/** + @brief ElementsMover::cancelMovement + Call the whole movement off without writing anything: every element + goes back to the place the gesture found it, every line of the + selection puts itself back where it stood and the undo steps which + were waiting for this movement are thrown away. + + One gesture is written as a whole or not at all, which is what a + "Annuler" answer to a question asked while the lines settle means. +*/ +void ElementsMover::cancelMovement() +{ + if (!m_movement_running) return; + + //Back to the places the gesture found them in: the recorded + //ones rather than the movement taken back, since every step of + //that movement was snapped to the grid on its way and coming + //back by the same amount need not land on the same place. + for (auto it = m_start_positions.constBegin(); + it != m_start_positions.constEnd(); ++it) + { + if (it.key()) it.key()->setPos(it.value()); + } + m_start_positions.clear(); + + for (auto *conductor : m_moved_content.m_conductors_to_move) + { + if (conductor) conductor->updatePath(); + } + + //Every line of the selection puts itself back where it stood + for (CablePart *cable : std::as_const(m_moved_cables)) + { + if (cable) cable->abortLineGesture(); + } + m_moved_cables.clear(); + + //Steps handed over by a line he dragged himself go with + //everything else: nothing has been written, so there is nothing + //to take back. + qDeleteAll(m_extra_commands); + m_extra_commands.clear(); + + m_current_movement -= m_current_movement; + m_movement_running = false; + m_moved_content.clear(); + + if (m_status_bar && !m_driver_held) { + m_status_bar->clearMessage(); + } +} diff --git a/sources/elementsmover.h b/sources/elementsmover.h index 2074a88eb..5a210d905 100644 --- a/sources/elementsmover.h +++ b/sources/elementsmover.h @@ -18,13 +18,16 @@ #ifndef ELEMENTS_MOVER_H #define ELEMENTS_MOVER_H +#include #include #include #include "diagramcontent.h" +class CablePart; class ConductorTextItem; class Diagram; class QStatusBar; +class QUndoCommand; /** This class manages the interactive movement of different items (elements, @@ -53,6 +56,27 @@ class ElementsMover { void continueMovement(const QPointF &); void endMovement(); bool holds(const QGraphicsItem *item) const; + /** + Steps of items this mover does not move itself -- the + cable lines the user dragged himself, which know how to + carry their own colour labels along -- handed over so + that the end of this movement pushes them together with + its own: one gesture, one undo step, whatever the + selection is made of. + @param steps the commands, given away with them + */ + void addExtraCommands(const QList &steps); + /** + Call the whole movement off without writing anything: + every element goes back to the place the gesture found it + and every line of the selection puts itself back too (see + CablePart), so nothing at all has to be undone. + + Answering "Annuler" to a question asked while the lines + settle is what calls it: one gesture is written as a + whole or not at all. + */ + void cancelMovement(); // attributes private: @@ -63,6 +87,17 @@ class ElementsMover { bool m_driver_held{false}; DiagramContent m_moved_content; QPointer m_status_bar; + ///The cable lines marked together with the elements, moved + ///by this mover from one gesture to the next (see + ///CablePart::beginLineGesture) -- empty whenever the finger + ///is on a line itself, since a line then carries its own + QList m_moved_cables; + ///Steps handed over by a line the user dragged himself, to + ///be pushed together with the rest of the movement + QList m_extra_commands; + ///Where every item of the movement stood when it began, so + ///that calling it off puts them all back exactly there + QHash m_start_positions; }; #endif diff --git a/sources/factory/propertieseditorfactory.cpp b/sources/factory/propertieseditorfactory.cpp index ba8b6acb3..cc7ae1dc9 100644 --- a/sources/factory/propertieseditorfactory.cpp +++ b/sources/factory/propertieseditorfactory.cpp @@ -18,6 +18,10 @@ #include "propertieseditorfactory.h" #include "../PropertiesEditor/propertieseditorwidget.h" +#include "../cable/cablepart.h" +#include "../cable/cablepropertieseditorwidget.h" +#include "../cablelist/cablelistmodel.h" +#include "../cablelist/cablelistmodelpropertieswidget.h" #include "../qetgraphicsitem/ViewItem/projectdbmodel.h" #include "../qetgraphicsitem/ViewItem/qetgraphicstableitem.h" #include "../qetgraphicsitem/ViewItem/ui/graphicstablepropertieseditor.h" @@ -55,7 +59,7 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor( PropertiesEditorWidget *editor, QWidget *parent) { - if (auto m = static_cast(model)) + if (auto m = dynamic_cast(model)) { if (editor && editor->metaObject()->className() @@ -66,6 +70,17 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor( } return new ProjectDBModelPropertiesWidget(m, parent); } + if (auto m = dynamic_cast(model)) + { + if (editor && + editor->metaObject()->className() + == CableListModelPropertiesWidget::staticMetaObject.className()) + { + static_cast(editor)->setModel(m); + return editor; + } + return new CableListModelPropertiesWidget(m, parent); + } return nullptr; } @@ -247,6 +262,27 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor( } return new GraphicsTablePropertiesEditor(table, parent); } + case CablePart::Type: //1012 + { + if (count_ > 1) { + return nullptr; + } + auto part = static_cast(item); + //The fields belong to the cable, never to one of its + //lines: whichever line is picked, it is the cable which + //is edited. + Cable *cable = part->cable(); + if (!cable) { + return nullptr; + } + + if (class_name == CablePropertiesEditorWidget::staticMetaObject.className()) + { + static_cast(editor)->setCable(cable, part); + return editor; + } + return new CablePropertiesEditorWidget(cable, part, parent); + } default: return nullptr; } diff --git a/sources/factory/qetgraphicstablefactory.cpp b/sources/factory/qetgraphicstablefactory.cpp index 44b7bc716..03f44e717 100644 --- a/sources/factory/qetgraphicstablefactory.cpp +++ b/sources/factory/qetgraphicstablefactory.cpp @@ -17,6 +17,8 @@ */ #include "qetgraphicstablefactory.h" +#include "../cablelist/cablelistmodel.h" +#include "../cablelist/cablequerywidget.h" #include "../dataBase/ui/elementquerywidget.h" #include "../dataBase/ui/summaryquerywidget.h" #include "../diagram.h" @@ -71,6 +73,26 @@ void QetGraphicsTableFactory::createAndAddSummary(Diagram *diagram) } } +/** + @brief QetGraphicsTableFactory::createAndAddCableList + Open a dialog for ask user the config of the table, + create a cable list table and add it to diagram + @param diagram +*/ +void QetGraphicsTableFactory::createAndAddCableList(Diagram *diagram) +{ + QScopedPointer d( + new AddTableDialog( + new CableQueryWidget(), + diagram->views().first())); + d->setWindowTitle(QObject::tr("Add a cable list")); + d->setTableName(QObject::tr("Cable list")); + + if (d->exec()) { + create(diagram, d.data()); + } +} + void QetGraphicsTableFactory::create(Diagram *diagram, AddTableDialog *dialog) { auto table_ = newTable(diagram, dialog); @@ -124,20 +146,33 @@ QetGraphicsTableItem *QetGraphicsTableFactory::newTable(Diagram *diagram, AddTab if (!previous_table) { - QString identifier_; - QString query_; + QAbstractTableModel *model = nullptr; if (auto query_widget = dynamic_cast(dialog->contentWidget())) { - identifier_ = query_widget->modelIdentifier(); - query_ = query_widget->queryStr(); + auto *db_model = new ProjectDBModel(diagram->project(), diagram->project()); + db_model->setIdentifier(query_widget->modelIdentifier()); + db_model->setQuery(query_widget->queryStr()); + model = db_model; } else if (auto query_widget = dynamic_cast(dialog->contentWidget())) { - identifier_ = query_widget->modelIdentifier(); - query_ = query_widget->queryStr(); + auto *db_model = new ProjectDBModel(diagram->project(), diagram->project()); + db_model->setIdentifier(query_widget->modelIdentifier()); + db_model->setQuery(query_widget->queryStr()); + model = db_model; + } else if (auto query_widget = dynamic_cast(dialog->contentWidget())) { + auto *cable_model = new CableListModel(diagram->project(), diagram->project()); + cable_model->setIdentifier(CableQueryWidget::modelIdentifier()); + //No entries chosen is no mistake to correct: an empty + //set of columns makes an empty table, the same grey + //frame a nomenclature table makes without entries + cable_model->setFields(query_widget->selectedKeys()); + model = cable_model; + } else { + //A content widget this factory does not know leaves an + //empty database model behind, which is what it has + //always done + model = new ProjectDBModel(diagram->project(), diagram->project()); } - auto model = new ProjectDBModel(diagram->project(), diagram->project()); - model->setIdentifier(identifier_); - model->setQuery(query_); model->setData(model->index(0,0), int(dialog->tableAlignment()), Qt::TextAlignmentRole); model->setData(model->index(0,0), dialog->tableFont(), Qt::FontRole); model->setData(model->index(0,0), QETUtils::marginsToString(dialog->headerMargins()), Qt::UserRole+1); diff --git a/sources/factory/qetgraphicstablefactory.h b/sources/factory/qetgraphicstablefactory.h index 0a93b0b5b..5c3118310 100644 --- a/sources/factory/qetgraphicstablefactory.h +++ b/sources/factory/qetgraphicstablefactory.h @@ -32,6 +32,7 @@ class QetGraphicsTableFactory static void createAndAddNomenclature(Diagram *diagram); static void createAndAddSummary(Diagram *diagram); + static void createAndAddCableList(Diagram *diagram); // Public so a caller that has already built and configured an // AddTableDialog itself (never shown or exec'd -- the two // methods above always exec() one, which the scripting API diff --git a/sources/pdf_links.cpp b/sources/pdf_links.cpp index af892836a..4fc9fc357 100644 --- a/sources/pdf_links.cpp +++ b/sources/pdf_links.cpp @@ -17,6 +17,7 @@ */ #include "pdf_links.h" +#include "cable/cablepart.h" #include "diagram.h" #include "qetgraphicsitem/crossrefitem.h" #include "qetgraphicsitem/dynamicelementtextitem.h" @@ -53,10 +54,13 @@ void injectCrossRefLinks(QPdfEngine *engine, Diagram *diagram, const QRectF &pageBounds = geom.pageBounds; // Compute, in PDF points on its OWN page, the rectangle to frame for a - // target element (used as a /FitR destination so the link zooms onto it). - auto destRectPdf = [&](Element *tgt) -> QRectF { - Diagram *dg = tgt ? tgt->diagram() : nullptr; - if (!dg) return QRectF(); + // target (used as a /FitR destination so the link zooms onto it). + // The target is given as a rectangle in the scene of @p dg, since what + // is pointed at is not always an element: a cable section is a plain + // drawn line somewhere on another folio. + auto frameRectPdf = [&](Diagram *dg, const QRectF &targetScene) -> QRectF { + if (!dg || targetScene.width() <= 0.0 || targetScene.height() <= 0.0) + return QRectF(); const QRectF srcT = geom.sourceRectOf(dg); if (srcT.width() <= 0.0 || srcT.height() <= 0.0) return QRectF(); const qreal sT = qMin(target.width() / srcT.width(), @@ -66,20 +70,20 @@ void injectCrossRefLinks(QPdfEngine *engine, Diagram *diagram, fitT.scale(sT, sT); fitT.translate(-srcT.x(), -srcT.y()); - QRectF elemScene = tgt->mapRectToScene(tgt->boundingRect()); - // Frame the element with a little context, and enforce a minimum + // Frame the target with a little context, and enforce a minimum // framed size so tiny contacts don't zoom in extremely. + QRectF framed = targetScene; const qreal pad = 25.0; - elemScene.adjust(-pad, -pad, pad, pad); + framed.adjust(-pad, -pad, pad, pad); const qreal minSide = 160.0; - if (elemScene.width() < minSide) - elemScene.adjust(-(minSide - elemScene.width()) / 2.0, 0, - (minSide - elemScene.width()) / 2.0, 0); - if (elemScene.height() < minSide) - elemScene.adjust(0, -(minSide - elemScene.height()) / 2.0, - 0, (minSide - elemScene.height()) / 2.0); + if (framed.width() < minSide) + framed.adjust(-(minSide - framed.width()) / 2.0, 0, + (minSide - framed.width()) / 2.0, 0); + if (framed.height() < minSide) + framed.adjust(0, -(minSide - framed.height()) / 2.0, + 0, (minSide - framed.height()) / 2.0); - const QRectF devT = fitT.mapRect(elemScene); + const QRectF devT = fitT.mapRect(framed); const QPointF a = geom.devToPdf(devT.topLeft()); const QPointF b = geom.devToPdf(devT.bottomRight()); return QRectF(QPointF(qMin(a.x(), b.x()), qMin(a.y(), b.y())), @@ -94,15 +98,18 @@ void injectCrossRefLinks(QPdfEngine *engine, Diagram *diagram, struct Link { QRectF rect; QUrl url; }; QList links; - auto injectLink = [&](const QRectF &sceneRect, Element *targetElmt) { - if (!targetElmt || !targetElmt->diagram()) return; - const int targetPage = pageMap.value(targetElmt->diagram(), -1); + // One clickable link: @p sceneRect on THIS folio leads to + // @p targetDiagram, framed around @p targetScene over there. + auto injectLinkTo = [&](const QRectF &sceneRect, Diagram *targetDiagram, + const QRectF &targetScene) { + if (!targetDiagram) return; + const int targetPage = pageMap.value(targetDiagram, -1); if (targetPage < 1) return; const QRectF devRect = fit.mapRect(sceneRect); if (!devRect.isValid() || !pageBounds.intersects(devRect)) return; QString frag = QString("page=%1").arg(targetPage); - const QRectF d = destRectPdf(targetElmt); // /FitR L_B_R_T + const QRectF d = frameRectPdf(targetDiagram, targetScene); // /FitR L_B_R_T if (d.isValid()) frag += QString("&fitr=%1_%2_%3_%4") .arg(qRound(d.left())).arg(qRound(d.top())) @@ -113,6 +120,12 @@ void injectCrossRefLinks(QPdfEngine *engine, Diagram *diagram, links.append({devRect, url}); }; + auto injectLink = [&](const QRectF &sceneRect, Element *targetElmt) { + if (!targetElmt || !targetElmt->diagram()) return; + injectLinkTo(sceneRect, targetElmt->diagram(), + targetElmt->mapRectToScene(targetElmt->boundingRect())); + }; + for (auto *item : diagram->items()) { // --- CrossRefItem links --- @@ -128,6 +141,15 @@ void injectCrossRefLinks(QPdfEngine *engine, Diagram *diagram, continue; } + // --- Cable cross-reference links (the lines under a cable which + // name the folio it goes on to) --- + if (auto *cable = dynamic_cast(item)) { + for (const CablePart::PdfRef &ref : cable->pdfRefs()) { + injectLinkTo(ref.here, ref.diagram, ref.there); + } + continue; + } + // --- Folio report links (DynamicElementTextItem) --- if (auto *deti = dynamic_cast(item)) { Element *parent = deti->parentElement(); diff --git a/sources/properties/xrefproperties.cpp b/sources/properties/xrefproperties.cpp index e6ed7cd53..4fbfbc4ea 100644 --- a/sources/properties/xrefproperties.cpp +++ b/sources/properties/xrefproperties.cpp @@ -67,6 +67,7 @@ XRefProperties::XRefProperties() m_prefix_keys << "power" << "delay" << "switch"; m_master_label = "%f-%l%c"; m_slave_label = "(%f-%l%c)"; + m_font = QString(); m_offset = 0; m_slave_offset = 0; m_xref_pos = Qt::AlignBottom; @@ -97,6 +98,7 @@ void XRefProperties::toSettings(QSettings &settings, settings.setValue(prefix % "master_label", master_label); QString slave_label = m_slave_label; settings.setValue(prefix % "slave_label", slave_label); + settings.setValue(prefix % "font", m_font); QMetaEnum var = QMetaEnum::fromType(); @@ -128,6 +130,7 @@ void XRefProperties::fromSettings(const QSettings &settings, m_slave_offset = settings.value(prefix % "slave_offset", "0").toInt(); m_master_label = settings.value(prefix % "master_label", "%f-%l%c").toString(); m_slave_label = settings.value(prefix % "slave_label", "(%f-%l%c)").toString(); + m_font = settings.value(prefix % "font").toString(); m_xref_pos = xrefPosFromKey(settings.value(prefix % "xrefpos").toString()); @@ -169,6 +172,14 @@ QDomElement XRefProperties::toXml(QDomDocument &xml_document) const xml_element.setAttribute("master_label", master_label); QString slave_label = m_slave_label; xml_element.setAttribute("slave_label", slave_label); + //Only the cable cross-reference has a font of its own, and only + //it ever sets one. Writing the attribute unconditionally put + //font="" on every cross-reference type of every project the + //program saved; a file which does not hold the attribute reads + //back as no font, which is what all of them are. + if (!m_font.isEmpty()) { + xml_element.setAttribute("font", m_font); + } foreach (QString key, m_prefix.keys()) { xml_element.setAttribute(key % "prefix", m_prefix.value(key)); } @@ -197,6 +208,7 @@ bool XRefProperties::fromXml(const QDomElement &xml_element) { m_slave_offset = xml_element.attribute("slave_offset", "0").toInt(); m_master_label = xml_element.attribute("master_label", "%f-%l%c"); m_slave_label = xml_element.attribute("slave_label","(%f-%l%c)"); + m_font = xml_element.attribute("font"); foreach (QString key, m_prefix_keys) { m_prefix.insert(key, xml_element.attribute(key % "prefix")); } @@ -206,15 +218,17 @@ bool XRefProperties::fromXml(const QDomElement &xml_element) { /** @brief XRefProperties::defaultProperties @return the default properties stored in the setting file - For the xref, there are 2 properties. - For coil, stored with the string "coil" in the returned QHash. - For protection, stored with the string "protection" in the returned QHash. + One entry per kind of element which shows a cross reference, stored + under its own key: "coil", "protection", "commutator", "plc" -- and + "cable", which is not an element at all: it is the reference a cable + writes in the label of each of its sections when the cable runs on + several folios. */ QHash XRefProperties::defaultProperties() { QHash hash; QStringList keys; - keys << "coil" << "protection" << "commutator" << "plc"; + keys << "coil" << "protection" << "commutator" << "plc" << "cable"; QSettings settings; @@ -223,6 +237,13 @@ QHash XRefProperties::defaultProperties() XRefProperties properties; QString str("diagrameditor/defaultxref"); properties.fromSettings(settings, str += key); + + //A cable takes the very same default text as a relay master + //and the other types, "%f-%l%c": what it names is then the + //other section of the cable -- the folio it stands on and + //the place it stands at there, which is what an element + //points at as well. No default of its own, so that setting + //the five of them apart needs no thought at all. hash.insert(key, properties); } @@ -241,7 +262,8 @@ bool XRefProperties::operator ==(const XRefProperties &xrp) const{ && m_offset == xrp.m_offset && m_slave_offset== xrp.m_slave_offset && m_xref_pos == xrp.m_xref_pos - && m_slave_label == xrp.m_slave_label); + && m_slave_label == xrp.m_slave_label + && m_font == xrp.m_font); } bool XRefProperties::operator !=(const XRefProperties &xrp) const diff --git a/sources/properties/xrefproperties.h b/sources/properties/xrefproperties.h index 08dfeca02..50fa569f8 100644 --- a/sources/properties/xrefproperties.h +++ b/sources/properties/xrefproperties.h @@ -80,6 +80,14 @@ class XRefProperties : public PropertiesInterface void setMasterLabel (const QString master) {m_master_label = master;} QString masterLabel () const {return m_master_label;} + /** + The font the reference of a cable is written with, as the + settings describe it -- empty when he has not set one, which + leaves the reference with the font of the cable texts. + */ + void setFont (const QString &font) {m_font = font;} + QString font () const {return m_font;} + void setSlaveLabel(const QString slave) {m_slave_label = slave;} QString slaveLabel () const {return m_slave_label;} @@ -103,6 +111,7 @@ class XRefProperties : public PropertiesInterface QStringList m_prefix_keys; QString m_master_label; QString m_slave_label; + QString m_font; int m_offset; int m_slave_offset; QString m_key; diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index 9fddcead5..39a6009b2 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -1536,6 +1536,97 @@ QFont QETApp::indiTextsItemFont(qreal size) return(font_); } +/** + @brief QETApp::cableTextsFont + @return the font the texts of a cable (designation, type, + installation, location, length) are written with when the cable + itself has been given no font of its own. + + That is what the preferences offer on the "Textes" page, and -- when + nothing has been chosen there yet -- the font the drawing writes its + own texts with, a shade smaller: the way every cable has always been + drawn, so that opening the preferences changes nothing until he + picks something. +*/ +QFont QETApp::cableTextsFont() +{ + QSettings settings; + if (settings.contains("cable-management/cable-text-font")) + { + QFont font; + if (QETUtils::fontFromString( + font, settings.value("cable-management/cable-text-font").toString())) { + return font; + } + } + + QFont font = QApplication::font(); + font.setPointSizeF(font.pointSizeF() * 0.9); + return font; +} + +/** + @brief QETApp::cableCoreFont + @return the font the colour labels of the cores are written with + when the cable itself has been given no font of its own. + + That is what the preferences offer on the "Textes" page; until he + picks something there, the colours follow the texts of the cable, + so one setting alone already resizes the whole label of a cable. +*/ +QFont QETApp::cableCoreFont() +{ + QSettings settings; + if (settings.contains("cable-management/cable-core-font")) + { + QFont font; + if (QETUtils::fontFromString( + font, settings.value("cable-management/cable-core-font").toString())) { + return font; + } + } + return cableTextsFont(); +} + +/** + @brief QETApp::cableTextAlignment + @return how the texts of a cable line up at the left end of their + line when the cable itself asks for nothing: what the preferences + offer on the "Textes" page, and -- until he has picked something + there -- the way every text has always stood, with its right end at + the place where the line begins. +*/ +Qt::Alignment QETApp::cableTextAlignment() +{ + const QSettings settings; + const QString name = settings.value("cable-management/cable-align").toString(); + if (name == QLatin1String("left")) { + return Qt::AlignLeft; + } + if (name == QLatin1String("center")) { + return Qt::AlignHCenter; + } + return Qt::AlignRight; +} + +/** + @brief QETApp::setCableTextAlignment + Keep what the preferences chose as the way every cable lines up its + texts, for the cables which say nothing about it themselves. + @param alignment Qt::AlignRight, Qt::AlignHCenter or Qt::AlignLeft +*/ +void QETApp::setCableTextAlignment(Qt::Alignment alignment) +{ + QString name = QLatin1String("right"); + if (alignment == Qt::AlignLeft) { + name = QLatin1String("left"); + } else if (alignment == Qt::AlignHCenter) { + name = QLatin1String("center"); + } + QSettings settings; + settings.setValue("cable-management/cable-align", name); +} + /** @brief QETApp::diagramEditors @return schema editors diff --git a/sources/qetapp.h b/sources/qetapp.h index 234db5fa5..7810547c8 100644 --- a/sources/qetapp.h +++ b/sources/qetapp.h @@ -175,6 +175,24 @@ class QETApp : public QObject static QFont diagramTextsItemFont(qreal = -1.0); static QFont dynamicTextsItemFont(qreal = -1.0); static QFont indiTextsItemFont (qreal = -1.0); + /** + @brief The font the texts of a cable (designation, type, + installation, location, length) are written with when the + cable itself asks for none. + */ + static QFont cableTextsFont(); + /** + @brief The font the colour labels of the cores of a cable + are written with when the cable itself asks for none. + */ + static QFont cableCoreFont(); + /** + @brief How the texts of a cable line up at the left end of + their line when the cable asks for nothing itself: right, + centred or left of where the line begins. + */ + static Qt::Alignment cableTextAlignment(); + static void setCableTextAlignment(Qt::Alignment alignment); static QETDiagramEditor *diagramEditorForFile(const QString &); static QETDiagramEditor *diagramEditorAncestorOf (const QWidget *child); static QList diagramEditors(); diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 02538c4dd..a25842303 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -46,8 +46,11 @@ #endif #include "diagramevent/diagrameventaddshape.h" #include "diagramevent/diagrameventaddpath.h" +#include "diagramevent/diagrameventaddcable.h" #include "diagramevent/diagrameventfillet.h" #include "diagramevent/diagrameventaddtext.h" +#include "cable/cablepart.h" +#include "cable/editcablecommand.h" #include "diagramevent/diagrameventaddpaste.h" #include "diagramview.h" #include "elementspanelwidget.h" @@ -55,6 +58,7 @@ #include "factory/qetgraphicstablefactory.h" #include "print/projectprintwindow.h" #include "project/projectpropertieshandler.h" +#include "ui/projectpropertiesdialog.h" #include "projectview.h" #include "qetproject.h" #include "qetgraphicsitem/ViewItem/qetgraphicstableitem.h" @@ -65,6 +69,7 @@ #include "recentfiles.h" #include "textgrid.h" #include "shortcutmanager.h" +#include "cablelist/cableexportdialog.h" #include "ui/bomexportdialog.h" #include "ui/conductorcolortoolbutton.h" #include "ui/diagrambgcolorbutton.h" @@ -90,6 +95,7 @@ #include "wiringlistexport.h" #include "ui/wiringlistdialog.h" #include "ui/terminalnumberingdialog.h" +#include "ui/cablenumberingdialog.h" #include "toolbarsettings.h" #include #include @@ -744,6 +750,27 @@ void QETDiagramEditor::setUpActions() ShortcutManager::instance().registerAction(m_project_terminalBloc, "diagrameditor.terminal_strip_plugin", tr("Diagram editors"), QKeySequence()); connect(m_project_terminalBloc, &QAction::triggered, this, &QETDiagramEditor::generateTerminalBlock); + //Add a cable list + m_add_cable_list = new QAction(QET::Icons::TableOfContent, tr("Add a cable list"), this); + connect(m_add_cable_list, &QAction::triggered, this, [this]() { + if (this->currentDiagramView()) { + QetGraphicsTableFactory::createAndAddCableList(this->currentDiagramView()->diagram()); + } + }); + + //Export the whole cable list to CSV, every column of the list + m_export_cable_list = new QAction(QET::Icons::DocumentSpreadsheet, tr("Export the cable list to CSV format"), this); + connect(m_export_cable_list, &QAction::triggered, [this]() { + QETProject *project = this->currentProject(); + if (!project) { + return; + } + //The same dialog the material list is exported with, only + //with the columns of the cable list + CableExportDialog dialog(project, this); + dialog.exec(); + }); + //Export conductor num to csv m_project_export_conductor_num = new QAction(QET::Icons::DocumentSpreadsheet, tr("Export the list of names of wires"), this); ShortcutManager::instance().registerAction(m_project_export_conductor_num, "diagrameditor.export_conductor_names", tr("Diagram editors"), QKeySequence()); @@ -784,6 +811,14 @@ void QETDiagramEditor::setUpActions() ShortcutManager::instance().registerAction(m_terminal_numbering, "diagrameditor.terminal_numbering", tr("Diagram editors"), QKeySequence()); connect(m_terminal_numbering, &QAction::triggered, this, &QETDiagramEditor::slot_terminalNumbering); + // Cable numbering: the rule the project numbers its cables with, and + // the numbering of every cable of that project at once + m_cable_numbering = new QAction(QET::Icons::AutoNum, tr("Cable numbering"), this); + m_cable_numbering->setStatusTip( + tr("Shows the cable numbering rule of the project and " + "renumbers its cables with it")); + connect(m_cable_numbering, &QAction::triggered, this, &QETDiagramEditor::slot_cableNumbering); + // Reload element drawings from their current definition (bugtracker #802) m_reload_element_drawings = new QAction(QET::Icons::ViewRefresh, tr("Reload element drawings"), this); ShortcutManager::instance().registerAction(m_reload_element_drawings, "diagrameditor.reload_element_drawings", tr("Diagram editors"), QKeySequence()); @@ -1243,6 +1278,9 @@ void QETDiagramEditor::setUpActions() QAction *add_fillet = m_add_item_actions_group.addAction(QET::Icons::DrawFillet, tr("Add a fillet")); QAction *add_terminal_strip = m_add_item_actions_group.addAction(QET::Icons::TerminalStrip, tr("Add a terminal plan")); QAction *add_generic_device = m_add_item_actions_group.addAction(QET::Icons::GenericDevice, tr("Add a generic device…")); + //Not an element of the drawing but a line drawn across the + //conductors: what it crosses becomes the cores of a cable. + m_add_cable = m_add_item_actions_group.addAction(QET::Icons::Cable, tr("Draw a cable")); add_text ->setStatusTip(tr("Adds a text field to the current sheet")); add_image ->setStatusTip(tr("Add an image to the current sheet")); @@ -1258,6 +1296,7 @@ void QETDiagramEditor::setUpActions() add_fillet ->setStatusTip(tr("Rounds the corner between two lines of the current sheet")); add_terminal_strip->setStatusTip(tr("Adds a terminal plan to the current sheet")); add_generic_device->setStatusTip(tr("Makes a box symbol with terminals on any side and places it on the current sheet")); + m_add_cable ->setStatusTip(tr("Draws the main line of a cable: every conductor crossed becomes one of its cores")); add_text ->setData(QStringLiteral("text")); add_image ->setData(QStringLiteral("image")); @@ -1273,6 +1312,7 @@ void QETDiagramEditor::setUpActions() add_fillet ->setData(QStringLiteral("fillet")); add_terminal_strip->setData(QStringLiteral("terminal_strip")); add_generic_device->setData(QStringLiteral("generic_device")); + m_add_cable ->setData(QStringLiteral("cable")); add_text->setCheckable(true); add_line->setCheckable(true); @@ -1281,6 +1321,7 @@ void QETDiagramEditor::setUpActions() add_arc->setCheckable(true); add_polyline->setCheckable(true); add_path->setCheckable(true); + m_add_cable->setCheckable(true); add_fillet->setCheckable(true); connect(&m_add_item_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::addItemGroupTriggered); @@ -1442,6 +1483,10 @@ void QETDiagramEditor::setUpMenu() QMenu* menu_fichier = new QMenu(tr("&File"), this); QMenu* menu_edition = new QMenu(tr("&Edit"), this); QMenu* menu_project = new QMenu(tr("&Project"), this); + //Numbering of everything the project numbers, kept together + //and sitting right behind the project it belongs to + QMenu* menu_numbering = new QMenu(tr("Numbering"), this); + QMenu* menu_lists = new QMenu(tr("Lists"), this); QMenu* menu_affichage = new QMenu(tr("Displ&ay"), this); // QMenu *menu_outils = new QMenu(tr("O&utils"), this); windows_menu = new QMenu(tr("Wi&ndows"), this); @@ -1449,6 +1494,8 @@ void QETDiagramEditor::setUpMenu() insertMenu(settings_menu_, menu_fichier); insertMenu(settings_menu_, menu_edition); insertMenu(settings_menu_, menu_project); + insertMenu(settings_menu_, menu_numbering); + insertMenu(settings_menu_, menu_lists); insertMenu(settings_menu_, menu_affichage); insertMenu(help_menu_, windows_menu); @@ -1556,15 +1603,11 @@ void QETDiagramEditor::setUpMenu() menu_project -> addAction(m_remove_diagram_from_project); menu_project -> addAction(m_clean_project); menu_project -> addSeparator(); - menu_project -> addAction(m_add_summary); - menu_project -> addAction(m_add_nomenclature); menu_project -> addAction(m_csv_export); + menu_project -> addAction(m_export_cable_list); menu_project -> addAction(m_project_export_conductor_num); - menu_project -> addAction(m_terminal_strip_dialog); - menu_project -> addAction(m_project_terminalBloc); menu_project -> addAction(m_project_export_wiring_list); menu_project -> addAction(m_project_wiring_list_view); - menu_project -> addAction(m_terminal_numbering); menu_project -> addAction(m_reload_element_drawings); #ifdef QET_HAS_SCRIPTING m_scripts_menu = menu_project -> addMenu(tr("Scripts")); @@ -1578,6 +1621,27 @@ void QETDiagramEditor::setUpMenu() menu_project -> addAction(m_export_project_db); #endif + // menu Numbering + //Everything the project numbers sits here together: the + //terminals have come out of the project menu, the cables are + //the new one + menu_numbering -> addAction(m_terminal_numbering); + menu_numbering -> addAction(m_cable_numbering); + + // menu Listes + //Everything which drops a table or a list onto the sheets has + //come out of the project menu and sits here together: the + //summary, the material list, the terminal strip tools and the + //cable list. The CSV exports stay with the other exports in the + //project menu, the cable list's among them. + menu_lists -> addAction(m_add_summary); + menu_lists -> addAction(m_add_nomenclature); + menu_lists -> addSeparator(); + menu_lists -> addAction(m_terminal_strip_dialog); + menu_lists -> addAction(m_project_terminalBloc); + menu_lists -> addSeparator(); + menu_lists -> addAction(m_add_cable_list); + main_tool_bar -> toggleViewAction() -> setStatusTip(tr("Display or hide the main toolbar")); view_tool_bar -> toggleViewAction() -> setStatusTip(tr("Display or hide the Display toolbar")); diagram_tool_bar -> toggleViewAction() -> setStatusTip(tr("Display or hide the Diagram toolbar")); @@ -2242,6 +2306,90 @@ void QETDiagramEditor::selectGroupTriggered(QAction *action) diagram->selectAllTextFields(); } +/** + @brief QETDiagramEditor::cableNumberingGate + Decide whether the cable tool may start, asking about the numbering + rule when the project numbers its cables by no rule at all. + + A project which does have one is asked nothing: the tool starts and + that rule gives the numbers. A project which has none is asked once + whether one should be defined now: + + - Oui opens the window where the rule is written, straight onto the + cable page of its automatic numbering; whatever he does in there, + the tool only starts if a rule really exists by the time that + window is closed. + - Non says no, and says it for good: it is remembered in the program + settings so the question does not come up on the next start + either. Drawing goes on all the same, with W as the cable's name: + no number is worked out where none has been defined. + - Annuler leaves everything as it was, the tool does not start, and + the question comes again the next time he picks the tool up. + + @param diagram the folio the cable would be drawn on + @return true when the cable tool may start +*/ +bool QETDiagramEditor::cableNumberingGate(Diagram *diagram) +{ + if (!diagram || !diagram->project()) return true; + QETProject *project = diagram->project(); + if (project->hasCableAutoNum()) return true; + + //A read-only project is left alone without a question: there is + //nowhere to define a rule in it, so asking whether he wants to + //would be a way to nowhere. Its cables are drawn called W, the + //same as any project without a rule. + if (project->isReadOnly()) return true; + + //Whether he has once answered no in THIS project. The answer is + //kept with the project rather than in the program settings, so + //one project being answered with no does not stop every other + //project from being asked. + if (!project->cableAskNumbering()) { + return true; + } + + QMessageBox box(QMessageBox::Question, + tr("Automatic numbering", "window title"), + tr("No automatic cable numbering is defined.", + "shown when a project has no cable numbering rule"), + QMessageBox::NoButton, + this); + box.setInformativeText(tr( + "Do you want to define one now?\n\n" + "You can also number the cables automatically later: the rule " + "is set at any time in the project properties, on the " + "\"Auto Numbering\" page.", + "ask whether a cable numbering rule should be defined now")); + QPushButton *define_button = box.addButton(tr("Yes", "yes"), QMessageBox::YesRole); + QPushButton *later_button = box.addButton(tr("No", "no"), QMessageBox::NoRole); + box.addButton(tr("Cancel", "cancel"), QMessageBox::RejectRole); + box.setDefaultButton(later_button); + box.exec(); + + if (box.clickedButton() == later_button) { + //With this project only: he has answered once here and + //should not be asked again every time he picks the tool + //up in this project -- another project is free to ask. + project->setCableAskNumbering(false); + project->setModified(true); + return true; + } + if (box.clickedButton() != define_button) { + return false; + } + + //Oui: the window itself, opened where the rule is written. It + //has to really be there by the time that window is closed -- + //a Cancel in it must not leave the tool running all the same. + ProjectPropertiesDialog dialog(diagram->project(), this); + dialog.setCurrentPage(ProjectPropertiesDialog::Autonum); + dialog.changeToCable(); + dialog.exec(); + + return diagram->project()->hasCableAutoNum(); +} + /** @brief QETDiagramEditor::addItemGroupTriggered This slot is called when an item must be added to the current diagram, @@ -2294,6 +2442,22 @@ void QETDiagramEditor::addItemGroupTriggered(QAction *action) { diagram_event = new DiagramEventAddPath (d); } + else if (value == QLatin1String("cable")) + { + //The rule the number comes from -- or the word about there + //being no rule yet -- comes before anything is drawn. + if (!cableNumberingGate(d)) + { + //Nothing is drawn, so the button must not go on looking + //like a tool which is running, and whichever tool was + //active before this click must not keep going with its + //own button unchecked either. + action->setChecked(false); + d->clearEventInterface(); + return; + } + diagram_event = new DiagramEventAddCable(d); + } else if (value == "fillet") diagram_event = new DiagramEventFillet (d); else if (value == "image") @@ -2388,8 +2552,30 @@ void QETDiagramEditor::selectionGroupTriggered(QAction *action) "Unbridge and/or remove the levels from the affected terminals so that they can be " "deleted")); } else { + //A cable line is not held by DiagramContent: it is + //removed by its own command, which also lets the cable + //it belongs to go when no line of it is left anywhere. + QList cable_parts; + for (QGraphicsItem *item : diagram->selectedItems()) { + if (auto *part = qgraphicsitem_cast(item)) { + cable_parts << part; + } + } + const bool other_items = dc.hasDeletableItems(); + diagram->clearSelection(); - diagram->undoStack().push(new DeleteQGraphicsItemCommand(diagram, dc)); + + if (cable_parts.isEmpty()) { + diagram->undoStack().push(new DeleteQGraphicsItemCommand(diagram, dc)); + } else if (!other_items) { + diagram->undoStack().push(new RemoveCableCommand(diagram, cable_parts)); + } else { + //One single step, so a selection holding both a + //cable and ordinary drawings comes back whole. + diagram->undoStack().push(new BatchCommand( + new DeleteQGraphicsItemCommand(diagram, dc), + new RemoveCableCommand(diagram, cable_parts))); + } dv->adjustSceneRect(); } } @@ -2593,6 +2779,9 @@ void QETDiagramEditor::slot_updateActions() m_project_export_wiring_list -> setEnabled(opened_project); m_project_wiring_list_view -> setEnabled(opened_project); m_terminal_numbering -> setEnabled(editable_project); + m_cable_numbering -> setEnabled(editable_project); + m_add_cable_list -> setEnabled(editable_project); + m_export_cable_list -> setEnabled(editable_project); m_reload_element_drawings -> setEnabled(opened_project); #ifdef QET_HAS_SCRIPTING m_run_script -> setEnabled(opened_project); @@ -2686,8 +2875,20 @@ void QETDiagramEditor::slot_updateComplexActions() m_mirror_vertical->setEnabled(!ro && selected_elements_count); //Actions that need items (elements, conductors, texts...) selected, to be enabled - bool copiable_items = dc.hasCopiableItems(); - bool deletable_items = dc.hasDeletableItems(); + //A cable line is drawn on the folio but held by the cable, not + //by DiagramContent: it counts on its own for deleting and editing. + int selected_cables = 0; + for (QGraphicsItem *item : diagram_->selectedItems()) { + if (item && item->type() == CablePart::Type) { + selected_cables++; + } + } + + //A drawn cable line is copyable, cuttable and duplicatable on + //its own, although it belongs to its cable rather than to the + //folio and DiagramContent never sees it. + bool copiable_items = dc.hasCopiableItems() || selected_cables > 0; + bool deletable_items = dc.hasDeletableItems() || selected_cables > 0; m_cut -> setEnabled(!ro && copiable_items); m_copy -> setEnabled(copiable_items); m_duplicate -> setEnabled(!ro && copiable_items); @@ -2743,7 +2944,8 @@ void QETDiagramEditor::slot_updateComplexActions() - selected_dynamic_elmt_text) + selected_image + selected_shape - + selected_conductors_count; + + selected_conductors_count + + selected_cables; if (selected_editable == 1) { @@ -2769,6 +2971,13 @@ void QETDiagramEditor::slot_updateComplexActions() "edit image")); m_edit_selection -> setIcon(QET::Icons::resize_image); } + //edit cable + else if (selected_cables) + { + m_edit_selection -> setText(tr("Edit the cable", + "edit cable")); + m_edit_selection -> setIcon(QET::Icons::Cable); + } //edit conductor else if (selected_conductors_count) { @@ -4011,6 +4220,21 @@ void QETDiagramEditor::slot_terminalNumbering() { } } +/** + * @brief QETDiagramEditor::slot_cableNumbering + * Opens the window holding the numbering rule of the project's cables + * and the button which numbers every one of them again. The window + * writes the project and pushes its own undo step itself, so nothing + * is done with its result here beyond letting it work. + */ +void QETDiagramEditor::slot_cableNumbering() { + QETProject *project = currentProject(); + if (!project) return; + + CableNumberingDialog dialog(project, this); + dialog.exec(); +} + /** @brief QETDiagramEditor::slot_reloadElementDrawings Redraw every placed element of the current project from its current diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 9e9e0a01d..2ff471b3f 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -102,6 +102,18 @@ class QETDiagramEditor : public QETMainWindow void setUpMenu (); bool addProject(QETProject *, bool = true); + /** + Checked while the cable tool is about to start, when the + project has no numbering rule for its cables. He is then + asked whether one should be defined now (the window which + opens goes straight to the cable page of the automatic + numbering) or not -- and having answered no, he is not + asked again, whatever he draws from then on. A project + which does number its cables is never asked at all, and + one which lost the rule again is. + @return true when the cable tool may start. + */ + bool cableNumberingGate(Diagram *diagram); DiagramView *currentDiagramView() const; Element *currentElement() const; ProjectView *findProject(DiagramView *) const; @@ -158,6 +170,7 @@ class QETDiagramEditor : public QETMainWindow void editProjectProperties(ProjectView *); void editProjectProperties(QETProject *); void slot_terminalNumbering(); + void slot_cableNumbering(); void slot_reloadElementDrawings(); #ifdef QET_HAS_SCRIPTING void slot_runScript(); @@ -241,6 +254,7 @@ class QETDiagramEditor : public QETMainWindow *m_configure_duplicate, ///< Reopen the duplicate offset/direction dialog (#991) *m_auto_conductor, ///< Enable/Disable the use of auto conductor *m_auto_break_conductor, ///< Enable/Disable the use of auto break conductor + *m_add_cable = nullptr, ///< Draw the trunk line of a cable *m_draw_grid, ///< Switch the background grid display or not *m_draw_guides = nullptr, ///< Switch the custom guides display or not *m_cell_rulers = nullptr, ///< Keep the folio column/row headers in sight or not @@ -258,6 +272,9 @@ class QETDiagramEditor : public QETMainWindow *m_project_export_wiring_list, ///< Action to export the wiring list *m_project_wiring_list_view, ///< Action to show the wiring list read from the project database *m_terminal_numbering, ///< Action to launch terminal numbering + *m_cable_numbering, ///< Action to open the cable numbering window (rule + numbering of every cable) + *m_add_cable_list, ///< Add a cable list graphics item + *m_export_cable_list, ///< Export the cable list of the project to CSV *m_reload_element_drawings, ///< Action to redraw every placed element from its current definition #ifdef QET_HAS_SCRIPTING *m_run_script, ///< Action to run a JavaScript macro against the current project diff --git a/sources/qetgraphicsitem/ViewItem/qetgraphicstableitem.cpp b/sources/qetgraphicsitem/ViewItem/qetgraphicstableitem.cpp index df090c36d..7a4f50155 100644 --- a/sources/qetgraphicsitem/ViewItem/qetgraphicstableitem.cpp +++ b/sources/qetgraphicsitem/ViewItem/qetgraphicstableitem.cpp @@ -19,6 +19,7 @@ #include "../../shownkinds.h" #include "../../qetproject.h" #include "../../QPropertyUndoCommand/qpropertyundocommand.h" +#include "../../cablelist/cablelistmodel.h" #include "../../createdxf.h" #include "../../diagram.h" #include "../../elementprovider.h" @@ -450,7 +451,13 @@ void QetGraphicsTableItem::setPreviousTable(QetGraphicsTableItem *table) } else if (old_previous_table->model()) //Copie the model of old previous table { - setModel(new ProjectDBModel(*static_cast(old_previous_table->model()))); + auto *old_model = old_previous_table ? old_previous_table->model() : nullptr; + if (auto *db_model = dynamic_cast(old_model)) { + setModel(new ProjectDBModel(*db_model)); + } + else if (auto *cable_model = dynamic_cast(old_model)) { + setModel(new CableListModel(*cable_model)); + } } if (old_previous_table && @@ -619,8 +626,12 @@ QDomElement QetGraphicsTableItem::toXml(QDomDocument &dom_document) const { //Add model auto dom_model = dom_document.createElement("model"); - auto project_db_model = static_cast(m_model.data()); - dom_model.appendChild(project_db_model->toXml(dom_document)); + if (auto project_db_model = dynamic_cast(m_model.data())) { + dom_model.appendChild(project_db_model->toXml(dom_document)); + } + else if (auto cable_model = dynamic_cast(m_model.data())) { + dom_model.appendChild(cable_model->toXml(dom_document)); + } dom_table.appendChild(dom_model); } @@ -672,16 +683,30 @@ void QetGraphicsTableItem::fromXml(const QDomElement &dom_element) } else if (this->diagram()) //The table haven't got a previous table, so there should be a model save to xml { - //Get table - auto model_ = new ProjectDBModel( - this->diagram()->project(), - this->diagram()->project()); - model_->fromXml( - dom_element - .firstChildElement("model") - .firstChildElement( - ProjectDBModel::xmlTagName())); - this->setModel(model_); + //Get table: which model wrote itself in there decides which + //class reads it back -- a cable list is not a database query + auto model_element = dom_element.firstChildElement("model"); + auto project_model_element = model_element.firstChildElement( + ProjectDBModel::xmlTagName()); + auto cable_model_element = model_element.firstChildElement( + CableListModel::xmlTagName()); + + if (!cable_model_element.isNull()) + { + auto model_ = new CableListModel( + this->diagram()->project(), + this->diagram()->project()); + model_->fromXml(cable_model_element); + this->setModel(model_); + } + else + { + auto model_ = new ProjectDBModel( + this->diagram()->project(), + this->diagram()->project()); + model_->fromXml(project_model_element); + this->setModel(model_); + } } //Restore the header from xml diff --git a/sources/qetgraphicsitem/conductor.cpp b/sources/qetgraphicsitem/conductor.cpp index 66fe92dbb..dc2715139 100644 --- a/sources/qetgraphicsitem/conductor.cpp +++ b/sources/qetgraphicsitem/conductor.cpp @@ -1845,6 +1845,32 @@ ConductorProperties Conductor::properties() const return(m_properties); } +/** + @brief Conductor::setCableReference + Record the cable core this conductor shows, together with the label + that assignment generates. Called only by CableManager, which works + the values out from the cable itself: the user never types them. + @param label the generated cable field, e.g. "15W1:br", empty to + detach the conductor from its cable + @param cable_uuid the cable this conductor is a core of + @param slot which core of that cable it shows, -1 when none +*/ +void Conductor::setCableReference(const QString &label, + const QUuid &cable_uuid, + int slot) +{ + if (m_properties.m_cable == label + && m_properties.m_cable_uuid == cable_uuid + && m_properties.m_cable_slot == slot) { + return; + } + + m_properties.m_cable = label; + m_properties.m_cable_uuid = cable_uuid; + m_properties.m_cable_slot = slot; + emit propertiesChange(); +} + /** @return true si le conducteur est mis en evidence */ diff --git a/sources/qetgraphicsitem/conductor.h b/sources/qetgraphicsitem/conductor.h index 34edfe436..f75931064 100644 --- a/sources/qetgraphicsitem/conductor.h +++ b/sources/qetgraphicsitem/conductor.h @@ -125,6 +125,15 @@ class Conductor : public QGraphicsObject bool only_text = false); void setProperties(const ConductorProperties &property); ConductorProperties properties() const; + /// Records which cable core this conductor shows and writes the + /// label that assignment generates (15W1:br) into its cable + /// field. Deliberately not routed through + /// setPropertyToPotential(): a core of a cable belongs to this + /// conductor alone and must never spill over a pass-through + /// terminal onto the neighbouring half conductor. + void setCableReference(const QString &label, + const QUuid &cable_uuid, + int slot); void setProfile(const ConductorProfile &, Qt::Corner); ConductorProfile profile(Qt::Corner) const; diff --git a/sources/qeticons.cpp b/sources/qeticons.cpp index 8436b77a9..e64fdcae6 100644 --- a/sources/qeticons.cpp +++ b/sources/qeticons.cpp @@ -37,6 +37,7 @@ namespace QET { QIcon ArrowRightDouble; QIcon Autoconnect; QIcon BringForward; + QIcon Cable; QIcon Cancel; QIcon Company; QIcon Conductor; @@ -416,6 +417,7 @@ void QET::Icons::initIcons() ArrowRightDouble = QIcon::fromTheme("arrow-right-double"); Autoconnect = QIcon::fromTheme("autoconnect"); BringForward = QIcon::fromTheme("bring_forward"); + Cable = QIcon::fromTheme("cable"); Cancel = QIcon::fromTheme("item-cancel"); Company = QIcon::fromTheme("go-company"); Conductor = QIcon::fromTheme("conductor"); diff --git a/sources/qeticons.h b/sources/qeticons.h index aec05c184..35aedfcc5 100644 --- a/sources/qeticons.h +++ b/sources/qeticons.h @@ -44,6 +44,7 @@ namespace QET { extern QIcon ArrowRightDouble; extern QIcon Autoconnect; extern QIcon BringForward; + extern QIcon Cable; extern QIcon Cancel; extern QIcon Company; extern QIcon Conductor; diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index 829ffd3b6..37ca5ed9a 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -23,6 +23,9 @@ #include "autoNum/numerotationcontext.h" #include "autoNum/numerotationcontextcommands.h" #include "autoNum/renumberelementscommand.h" +#include "cable/cable.h" +#include "cable/cablemanager.h" +#include "cable/cablepart.h" #include "autoNum/elementautonumschemecommand.h" #include "diagram.h" #include "qetgraphicsitem/element.h" @@ -117,6 +120,17 @@ m_project_properties_handler{this} for (auto it = folioData.first.constBegin(); it != folioData.first.constEnd(); ++it) { addFolioAutoNum(it.key(), it.value()); } + + //The cable numbering rule a project starts with, so a project + //made from these defaults numbers its cables the way the + //global settings say without having to be opened first. + auto cableData = NumerotationContext::loadFromSettings(settings, QStringLiteral("autonum/cable")); + for (auto it = cableData.first.constBegin(); it != cableData.first.constEnd(); ++it) { + addCableAutoNum(it.key(), it.value()); + } + if (!cableData.second.isEmpty()) { + setCurrentCableAutoNum(cableData.second); + } } } @@ -826,6 +840,16 @@ QHash QETProject::elementAutoNum() const return m_element_autonum; } +/** + @brief QETProject::cableAutoNum + @return the cable numerotation stored in the project -- one entry at + most, since cables are numbered by a single rule +*/ +QHash QETProject::cableAutoNum() const +{ + return m_cable_autonum; +} + /** @brief QETProject::elementAutoNumFormula @param key : autonum title @@ -866,6 +890,109 @@ void QETProject::setCurrrentElementAutonum(QString autoNum) { m_current_element_autonum = std::move(autoNum); } +/** + @brief QETProject::cableAutoNumFormula + @param key : autonum title + @return Formula of the cable numerotation stored with key +*/ +QString QETProject::cableAutoNumFormula(const QString& key) const +{ + if (m_cable_autonum.contains(key)) { + return autonum::numerotationContextToFormula(m_cable_autonum.value(key)); + } + + return QString(); +} + +/** + @brief QETProject::hasCableAutoNum + @return true when a numbering rule for cables is set: a context + stored under the name which is in use. A project which has never been + given one answers false, and that is what makes the cable tool ask + whether one should be defined (see QETDiagramEditor). +*/ +bool QETProject::hasCableAutoNum() const +{ + if (m_current_cable_autonum.isEmpty()) return false; + return !cableAutoNum(m_current_cable_autonum).isEmpty(); +} + +/** + @brief QETProject::cableCurrentAutoNum + @return title of the cable numerotation in use (one rule only) +*/ +QString QETProject::cableCurrentAutoNum() const +{ + return m_current_cable_autonum; +} + +/** + @brief QETProject::setCurrentCableAutoNum + @param autoNum : the rule which from now on numbers the cables +*/ +void QETProject::setCurrentCableAutoNum(QString autoNum) +{ + m_current_cable_autonum = std::move(autoNum); +} + +/** + @brief QETProject::cableAutoNumRuleName + @return the name the one and only cable numbering rule is kept under +*/ +QString QETProject::cableAutoNumRuleName() +{ + return QStringLiteral("Cable"); +} + +/** + @brief QETProject::cableXAxisFirst + @return true when the cables are numbered along the X axis (the + leftmost first), false when they are numbered along the Y axis (the + topmost first) +*/ +bool QETProject::cableXAxisFirst() const +{ + return m_cable_axis_x_first; +} + +/** + @brief QETProject::setCableXAxisFirst + Which axis the whole project numbers its cables along. Stored with + the rule rather than on its own, since it only means anything while + a rule is being applied to every cable at once. + @param x_axis_first +*/ +void QETProject::setCableXAxisFirst(bool x_axis_first) +{ + m_cable_axis_x_first = x_axis_first; +} + +/** + @brief QETProject::cableAskNumbering + @return true when the cable tool may still ask this project whether + a numbering rule should be defined. The answer to that question is + kept with the project rather than in the program settings, so that + one project being answered with no does not stop every other + project from asking. +*/ +bool QETProject::cableAskNumbering() const +{ + return m_cable_ask_numbering; +} + +/** + @brief QETProject::setCableAskNumbering + Take the answer he gave to the numbering question down with the + project. False means he has said no once here and is not to be + asked again in this project; taking the rule away puts it back to + true so the question comes again. + @param ask +*/ +void QETProject::setCableAskNumbering(bool ask) +{ + m_cable_ask_numbering = ask; +} + /** @brief QETProject::renumberElementsBySchemeTitle Renumber existing elements by element autonumbering scheme title. @@ -1187,6 +1314,32 @@ void QETProject::addFolioAutoNum(const QString& key, const NumerotationContext& emit autoNumContextUpdated(); } +/** + @brief QETProject::addCableAutoNum + Store the cable numbering rule. There is room for exactly one: saving + it under a name drops every other entry, so a project can never end up + with a list of cable rules to choose from. + @param key + @param context +*/ +void QETProject::addCableAutoNum(const QString& key, const NumerotationContext& context) +{ + if (!key.isEmpty()) { + const QStringList others = m_cable_autonum.keys(); + for (const QString &other : others) { + if (other != key) m_cable_autonum.remove(other); + } + } + m_cable_autonum.insert(key, context); + //The rule in use must always be one which exists: after a save + //under another name the old entry is gone, so follow the new one. + if (!key.isEmpty() && (m_current_cable_autonum.isEmpty() + || !m_cable_autonum.contains(m_current_cable_autonum))) { + m_current_cable_autonum = key; + } + emit autoNumContextUpdated(); +} + /** @brief QETProject::removeConductorAutoNum Remove Conductor Numerotation Context stored with key @@ -1217,6 +1370,21 @@ void QETProject::removeFolioAutoNum(const QString& key) { m_folio_autonum.remove(key); } +/** + @brief QETProject::removeCableAutoNum + Forget the cable numbering rule stored with key + @param key +*/ +void QETProject::removeCableAutoNum(const QString& key) +{ + m_cable_autonum.remove(key); + if (m_current_cable_autonum == key) { + m_current_cable_autonum = m_cable_autonum.isEmpty() + ? QString() : m_cable_autonum.keys().first(); + } + emit autoNumContextUpdated(); +} + /** @brief QETProject::conductorAutoNum Return conductor numerotation context stored with key. @@ -1252,6 +1420,18 @@ NumerotationContext QETProject::folioAutoNum (const QString &key) const else return NumerotationContext(); } +/** + @brief QETProject::cableAutoNum + Return the cable numerotation context stored with key. + If key is not found, return an empty numerotation context + @param key +*/ +NumerotationContext QETProject::cableAutoNum(const QString &key) const +{ + if (m_cable_autonum.contains(key)) return m_cable_autonum.value(key); + else return NumerotationContext(); +} + /** @brief QETProject::freezeExistentConductorLabel Freeze Existent Conductors in the selected folios @@ -1596,6 +1776,18 @@ QDomDocument QETProject::toXml() project_root.appendChild(xml_strip); } + //Write the cables to xml. Their sections are written here too, + //beside the cable they belong to, so a cable and everything drawn + //for it are read back together and can never come apart. + if (m_cables.count()) + { + auto xml_cables = xml_doc.createElement(QStringLiteral("cables")); + for (auto &cable : m_cables) { + xml_cables.appendChild(cable->toXml(xml_doc)); + } + project_root.appendChild(xml_cables); + } + // Write the elements collection. project_root.appendChild(m_elements_collection->root().cloneNode(true)); @@ -2109,10 +2301,23 @@ void QETProject::readProjectXml(QDomDocument &xml_project) readTerminalStripXml(xml_project); const qint64 strips_ms = phase_timer.restart(); + //Load the cables, after the diagrams their sections are drawn on + readCableXml(xml_project); + const qint64 cables_ms = phase_timer.restart(); + //Now that all are loaded we refresh content of the project. refresh(); const qint64 refresh_ms = phase_timer.restart(); + //refresh() above resolved the folio report links for the first + //time (refreshContents() -> initLink). The cable label pass at + //the end of readCableXml() ran before that, while no element + //knew its partners yet, so a core running through a report could + //not be carried over there -- it was even cleared off the far + //wire again. Run the pass once more now that the links exist: + //it recomputes everything from the project as it now stands. + CableManager::refreshLabels(this); + m_data_base.blockSignals(false); m_data_base.setUpdateBlocked(false); m_data_base.updateDB(xml_project); @@ -2120,11 +2325,12 @@ void QETProject::readProjectXml(QDomDocument &xml_project) qInfo().nospace() << "Project content built in " - << (elements_ms + diagrams_ms + strips_ms + << (elements_ms + diagrams_ms + strips_ms + cables_ms + refresh_ms + database_ms) / 1000.0 << " seconds (elements collection " << elements_ms / 1000.0 << ", diagrams " << diagrams_ms / 1000.0 << ", terminal strips " << strips_ms / 1000.0 + << ", cables " << cables_ms / 1000.0 << ", refresh " << refresh_ms / 1000.0 << ", database " << database_ms / 1000.0 << ")"; @@ -2318,7 +2524,7 @@ void QETProject::readDefaultPropertiesXml(QDomDocument &xml_project) m_default_xref_properties = XRefProperties:: defaultProperties(); //Read values indicate in project - QDomElement border_elmt, titleblock_elmt, conductors_elmt, report_elmt, xref_elmt, conds_autonums, folio_autonums, element_autonums, guides_elmt; + QDomElement border_elmt, titleblock_elmt, conductors_elmt, report_elmt, xref_elmt, conds_autonums, folio_autonums, element_autonums, cable_autonums, guides_elmt; for (QDomNode child = newdiagrams_elmt.firstChild() ; !child.isNull() ; child = child.nextSibling()) { @@ -2341,6 +2547,8 @@ void QETProject::readDefaultPropertiesXml(QDomDocument &xml_project) folio_autonums = child_elmt; else if (child_elmt.tagName()== QLatin1String("element_autonums")) element_autonums = child_elmt; + else if (child_elmt.tagName()== QLatin1String("cable_autonums")) + cable_autonums = child_elmt; else if (child_elmt.tagName() == QLatin1String("guides")) guides_elmt = child_elmt; } @@ -2409,6 +2617,37 @@ void QETProject::readDefaultPropertiesXml(QDomDocument &xml_project) m_current_element_autonum = current_title; } } + if (!cable_autonums.isNull()) + { + m_current_cable_autonum = cable_autonums.attribute(QStringLiteral("current_autonum")); + //Which axis the cables are numbered along. A file written + //before this existed has no say in it and stays with the + //axis the terminals have always started from. + m_cable_axis_x_first = cable_autonums.attribute( + QStringLiteral("axis_priority"), QStringLiteral("x")) + != QLatin1String("y"); + //Whether the numbering question was already answered with + //no in this project. A file which does not hold the answer + //yet asks again rather than staying silent: it is his own + //answer per project which is kept, not a default saying no. + m_cable_ask_numbering = cable_autonums.attribute( + QStringLiteral("ask_numbering_rule"), QStringLiteral("true")) + != QLatin1String("false"); + for (auto elmt : QET::findInDomElement(cable_autonums, QStringLiteral("cable_autonum"))) + { + NumerotationContext nc; + nc.fromXml(elmt); + if (elmt.attribute(QStringLiteral("title")).isEmpty()) continue; + m_cable_autonum.insert(elmt.attribute(QStringLiteral("title")), nc); + } + //A file written by hand, or written before this rule existed, + //may name a rule which is not in the list: the numbering must + //never be left pointing at something which is not there. + if (!m_cable_autonum.contains(m_current_cable_autonum)) { + m_current_cable_autonum = m_cable_autonum.isEmpty() + ? QString() : m_cable_autonum.keys().first(); + } + } // Read guides from XML (if missing, e.g. in old projects, list stays empty) m_default_guides.clear(); @@ -2443,6 +2682,55 @@ void QETProject::readTerminalStripXml(const QDomDocument &xml_project) } } +/** + * @brief QETProject::readCableXml + * Read the cables of the project, and draw a section for each of them on + * the folio it belongs to. + * + * The diagrams have already been read at this point, which is what makes + * it possible to put the sections back where they were: a folio is + * identified by its uuid, so reordering or renumbering pages never moves + * a cable onto the wrong one. + * @param xml_project + */ +void QETProject::readCableXml(const QDomDocument &xml_project) +{ + auto xml_elmt = xml_project.documentElement(); + auto xml_cables = xml_elmt.firstChildElement(QStringLiteral("cables")); + if (xml_cables.isNull()) return; + + for (auto xml_cable = xml_cables.firstChildElement(QStringLiteral("cable")); + !xml_cable.isNull(); + xml_cable = xml_cable.nextSiblingElement(QStringLiteral("cable"))) + { + auto cable = new Cable(this); + cable->fromXml(xml_cable); + addCable(cable); + + for (const CablePartData &part_data : cable->parts()) + { + Diagram *diagram = diagramByUuid(part_data.diagram); + if (!diagram) + { + //The folio this section was drawn on has been removed: + //the section goes with it, and so do the cores which + //were only ever shown on it. + cable->removePart(part_data.uuid); + continue; + } + + auto part = new CablePart(part_data); + part->setCable(cable); + diagram->addItem(part); + } + } + + //The file holds the generated cable field of every conductor as + //well as the cables themselves; working it out again here makes + //sure the two agree even if a file was edited by hand. + CableManager::refreshLabels(this); +} + /** Export project properties under the \a xml_element XML element. */ @@ -2607,6 +2895,52 @@ void QETProject::writeDefaultPropertiesXml(QDomElement &xml_element) } xml_element.appendChild(element_autonums); + //Export Cable Autonum -- one rule, but stored exactly like the others + //so it travels with the project and survives a re-save untouched. + // + //Only when it holds something, though. An element which is not there + //reads back as exactly the defaults a project which never touched + //cable numbering already has, so writing it every time put + // into every file the program saved -- into the + //example projects and into projects without a single cable in them. + // + //That is not the same as writing it only for projects with cables, + //which would cost data: a rule named before the first cable is + //drawn, an axis chosen before it, and the numbering question + //answered with no are answers only this element can carry. + QDomElement cable_autonums = xml_document.createElement("cable_autonums"); + cable_autonums.setAttribute("current_autonum", m_current_cable_autonum); + cable_autonums.setAttribute("axis_priority", + m_cable_axis_x_first ? "x" : "y"); + //Only the no is written down: a project which has not answered + //the numbering question yet asks again, so silence in the file + //means asking rather than holding back. + if (!m_cable_ask_numbering) { + cable_autonums.setAttribute("ask_numbering_rule", "false"); + } + int written_cable_rules = 0; + QStringList cable_autonum_keys = cableAutoNum().keys(); + cable_autonum_keys.sort(); + for (const QString &key : std::as_const(cable_autonum_keys)) { + QDomElement cable_autonum = cableAutoNum(key).toXml(xml_document, "cable_autonum"); + if (key != "" && cableAutoNumFormula(key) != "") { + cable_autonum.setAttribute("title", key); + cable_autonum.setAttribute("formula", cableAutoNumFormula(key)); + cable_autonums.appendChild(cable_autonum); + ++written_cable_rules; + } + } + //Nothing to keep when the project never named a rule, never + //chose an axis other than the default and has not answered the + //numbering question with no -- then this element would say + //nothing the reader does not assume anyway. + if (!m_current_cable_autonum.isEmpty() + || !m_cable_ask_numbering + || !m_cable_axis_x_first + || written_cable_rules > 0) { + xml_element.appendChild(cable_autonums); + } + // Export default guides QDomElement guides_elmt = xml_document.createElement("guides"); for (const auto &g : m_default_guides) { @@ -2792,6 +3126,86 @@ bool QETProject::removeTerminalStrip(TerminalStrip *strip) { return m_terminal_strip_vector.removeOne(strip); } +/** + * @brief QETProject::cables + * @return every cable of this project + */ +QVector QETProject::cables() const { + return m_cables; +} + +/** + * @brief QETProject::newCable + * Create a cable owned by this project and watch it: whenever it changes, + * the cable field of every conductor it feeds is worked out again. + * @return the new cable + */ +Cable *QETProject::newCable() +{ + auto cable = new Cable(this); + addCable(cable); + return cable; +} + +/** + * @brief QETProject::addCable + * @param cable + * @return true when the cable is now held by this project + */ +bool QETProject::addCable(Cable *cable) +{ + if (!cable || cable->parent() != this) + return false; + + if (m_cables.contains(cable)) + return true; + + m_cables.append(cable); + connect(cable, &Cable::changed, this, [this]() { + CableManager::refreshLabels(this); + setModified(true); + }); + emit cableAdded(cable); + return true; +} + +/** + * @brief QETProject::removeCable + * Cable is removed from the list but not deleted, so an undo command can + * put it back. + * @param cable + * @return true when the cable was in the list + */ +bool QETProject::removeCable(Cable *cable) { + if (!m_cables.removeOne(cable)) + return false; + + disconnect(cable, nullptr, this, nullptr); + + //A cable which is no longer one of ours stops writing its labels + //into the conductors it used to feed. + CableManager::refreshLabels(this); + setModified(true); + emit cableRemoved(cable); + return true; +} + +/** + * @brief QETProject::diagramByUuid + * @param uuid + * @return the folio with that identity, or nullptr. A cable section says + * which folio it is drawn on by identity, since folios are reordered and + * renumbered freely. + */ +Diagram *QETProject::diagramByUuid(const QUuid &uuid) +{ + if (uuid.isNull()) return nullptr; + for (Diagram *diagram : m_diagrams_list) { + if (diagram && diagram->uuid() == uuid) return diagram; + } + return nullptr; +} + /** Cette methode sert a reperer un projet vide, c-a-d un projet identique a ce que l'on obtient en faisant Fichier > Nouveau. diff --git a/sources/qetproject.h b/sources/qetproject.h index 212d08169..56b1fc517 100644 --- a/sources/qetproject.h +++ b/sources/qetproject.h @@ -56,6 +56,7 @@ class QUndoStack; class XmlElementCollection; class QTimer; class TerminalStrip; +class Cable; #include @@ -182,6 +183,49 @@ class QETProject : public QObject QString elementCurrentAutoNum() const; void setCurrrentElementAutonum(QString autoNum); + /// The cable numbering rule. Only one of them is ever kept: + /// a cable is numbered by one rule and that is all there is, + /// so the hash holds a single entry (see the cable tab of + /// the automatic numbering pages). + QHash cableAutoNum() const; + void addCableAutoNum (const QString& key, const NumerotationContext& context); + void removeCableAutoNum (const QString& key); + NumerotationContext cableAutoNum(const QString &key) const; + + QString cableAutoNumFormula(const QString& key) const; + bool hasCableAutoNum() const; + QString cableCurrentAutoNum() const; + void setCurrentCableAutoNum(QString autoNum); + /** + Which axis the cables of the project are laid out along + when the whole project is numbered again: true (the + default) for the X axis, so the leftmost cable of a folio + comes first, false for the Y axis, so the topmost does. + It belongs to the numbering and is written into the + project file next to the rule itself. + */ + bool cableXAxisFirst() const; + void setCableXAxisFirst(bool x_axis_first); + /** + Whether the cable tool asks this project whether a + numbering rule should be defined, when he is picked up + and the project has none. Saying no in that question is + remembered with the project rather than in the program + settings, so one project saying no does not silence + every other one -- each project asks again until it + has answered for itself, and taking the rule away + brings the question back (see the cable tab of the + project properties and the numbering window). + */ + bool cableAskNumbering() const; + void setCableAskNumbering(bool ask); + /** + The name the single cable numbering rule is kept under. + Fixed and never translated: it is a key which goes into + the settings and into the project file, so it must read + the same whatever language the program runs in. + */ + static QString cableAutoNumRuleName(); //Identity of the element numbering schemes. The title is the //name shown to the user and the lookup key of the API; the uuid //is what an element's ELMT_FORMULA_ID refers to, so a scheme can @@ -262,6 +306,12 @@ class QETProject : public QObject bool addTerminalStrip(TerminalStrip *strip); bool removeTerminalStrip(TerminalStrip *strip); + QVector cables() const; + Cable *newCable(); + bool addCable(Cable *cable); + bool removeCable(Cable *cable); + Diagram *diagramByUuid(const QUuid &uuid); + public slots: Diagram *addNewDiagram(int pos = -1); void removeDiagram(Diagram *); @@ -295,6 +345,12 @@ class QETProject : public QObject void folioAutoNumRemoved(); void defaultTitleBlockPropertiesChanged(); void conductorAutoNumChanged(); + /// A cable joined or left this project. Views over the + /// cables -- the cable list table -- listen to both to work + /// their rows out again; during a load the cables arrive one + /// signal at a time, long after the folios were read. + void cableAdded(Cable *cable); + void cableRemoved(Cable *cable); private slots: void updateDiagramsFolioData(); @@ -329,6 +385,7 @@ class QETProject : public QObject void readProjectPropertiesXml(QDomDocument &xml_project); void readDefaultPropertiesXml(QDomDocument &xml_project); void readTerminalStripXml(const QDomDocument &xml_project); + void readCableXml(const QDomDocument &xml_project); void readUsageXml(QDomDocument &xml_project); void readWireHopsXml(QDomDocument &xml_project); void readSymbolTextsXml(QDomDocument &xml_project); @@ -398,6 +455,16 @@ class QETProject : public QObject /// Title -> uuid of each element numbering scheme QHash m_element_autonum_id; QString m_current_element_autonum; + /// Cable auto numbering: exactly one rule, kept the same way + /// the others are so it stores and travels with the project + /// alike, but never a list to choose from. + QHash m_cable_autonum; + QString m_current_cable_autonum; + ///< which axis the cables are numbered along (see cableXAxisFirst) + bool m_cable_axis_x_first = true; + ///< whether the cable tool still asks about a numbering rule + ///< for this project (see cableAskNumbering) + bool m_cable_ask_numbering = true; /// True when the loaded file had element numbering schemes /// saved without an id (written before ids existed) bool m_legacy_element_autonums = false; @@ -423,6 +490,7 @@ class QETProject : public QObject QSet m_saved_item_uuids; //symbol and wire uuids the file carries, see derivedItemUuid() projectDataBase m_data_base; QVector m_terminal_strip_vector; + QVector m_cables; ProjectPropertiesHandler m_project_properties_handler; }; diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index eec89c0e5..3b315f784 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -24,6 +24,9 @@ #include "../QPropertyUndoCommand/qpropertyundocommand.h" #include "../cli_export.h" #include "../diagram.h" +#include "../bomexport.h" +#include "../cablelist/cablelistrows.h" +#include "../cablelist/cablequerywidget.h" #include "../dataBase/ui/elementquerywidget.h" #include "../dataBase/ui/summaryquerywidget.h" #include "../diagramcontent.h" @@ -336,6 +339,31 @@ bool QetScriptApi::exportBom(const QString &output, bool noSlaves, return runFlag(QStringLiteral("--export-bom"), args); } +/** + @brief QetScriptApi::exportCableList + Write the cable list of this project to @p output as CSV -- the same + bytes the "Exporter la liste des câbles au format CSV" action writes, + every column of CableList, every cable of the project. Written from + the project in memory rather than through runFlag(): the rows are + worked out from the cables themselves, there is no CLI flag and no + reopening of the file to do it through. + @param output path of the file to write + @return true when the file was written +*/ +bool QetScriptApi::exportCableList(const QString &output) +{ + if (!m_project) { + log(QStringLiteral("qet.exportCableList: no project")); + return false; + } + QString error; + if (!BomExport::writeCsv(output, CableList::toCsv(m_project), &error)) { + log(QStringLiteral("qet.exportCableList: %1").arg(error)); + return false; + } + return true; +} + bool QetScriptApi::exportWiring(const QString &output) { return runFlag(QStringLiteral("--export-wiring"), {output}); @@ -2962,12 +2990,16 @@ QStringList QetScriptApi::tables(int folioIndex) const file, so one call creates exactly the one table asked for. A script that wants either behaviour can resize the result or add a folio itself. - kind is "nomenclature" (an ElementQueryWidget, over placed elements) or - "summary" (a SummaryQueryWidget, over folios); query is required, since + kind is "nomenclature" (an ElementQueryWidget, over placed elements), + "summary" (a SummaryQueryWidget, over folios) or "cable_list" (a + CableQueryWidget, over the cables of the project); query is required + for the first two, since both widgets otherwise build their own from a set of checkboxes that default to none checked, and "SELECT with no columns" is not a useful table -- query() against element_nomenclature_view or - project_summary_view is the way to find one that is. + project_summary_view is the way to find one that is. For + "cable_list" the query is comma-joined column keys of CableList, and + empty means the default columns. Not undoable: newTable(), which create() calls, calls Diagram::addItem() directly, with no undo command of its own, in the @@ -2991,7 +3023,7 @@ int QetScriptApi::addTable(int folioIndex, const QString &kind, const QString &n log(QStringLiteral("qet.%1: no folio at index %2").arg(caller).arg(folioIndex)); return -1; } - if (query.isEmpty()) { + if (query.isEmpty() && kind != QLatin1String("cable_list")) { // Both widgets build their query from a set of checkboxes that // default to none checked, so "no query" is not "the sensible // default" here the way it might look -- it is SELECT with no @@ -3000,6 +3032,9 @@ int QetScriptApi::addTable(int folioIndex, const QString &kind, const QString &n // instead, over query() -- the same project database and the same // two views this project already exposes, element_nomenclature_view // for a nomenclature table and project_summary_view for a summary. + // A cable list is not read from that database (its rows come + // straight from the cables of the project), so for that kind an + // empty query is allowed and simply means "the default columns". log(QStringLiteral("qet.%1: a query is required -- try qet.query() against " "element_nomenclature_view or project_summary_view first " "to find one that returns what is wanted").arg(caller)); @@ -3015,8 +3050,27 @@ int QetScriptApi::addTable(int folioIndex, const QString &kind, const QString &n auto *w = new SummaryQueryWidget(); w->setQuery(query); content = w; + } else if (kind == QLatin1String("cable_list")) { + auto *w = new CableQueryWidget(); + if (!query.isEmpty()) { + // Comma-joined column keys; keys the list does not know are + // ignored by setFields(), and an empty list of usable keys + // leaves the default columns rather than a blank table. + const QStringList fields = query.split(QLatin1Char(','), Qt::SkipEmptyParts); + w->setFields(fields); + if (w->selectedKeys().isEmpty()) { + w->setFields(CableList::defaultKeys()); + } + } else { + // No query for this kind means the default columns, as + // promised above. The dialog a user opens leaves an empty + // table when nothing is picked, but a script asking for + // "a cable list" asks for the columns everybody gets. + w->setFields(CableList::defaultKeys()); + } + content = w; } else { - log(QStringLiteral("qet.%1: unknown kind '%2'; expected nomenclature or summary").arg(caller, kind)); + log(QStringLiteral("qet.%1: unknown kind '%2'; expected nomenclature, summary or cable_list").arg(caller, kind)); return -1; } diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index 2e3f327ac..f6b8b0c0e 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -189,7 +189,8 @@ class QetGraphicsTableItem; button applies, not a rule reimplemented here. sortTerminalStrip() reorders the strip's physical positions into the canonical order the editor's own sort button computes. - - @b Tables: a BOM/nomenclature or a summary (table of contents) placed + - @b Tables: a BOM/nomenclature, a summary (table of contents) or a + cable list placed on a folio, through QetGraphicsTableFactory::create() -- the same factory call the "add table" menu action makes, minus the modal AddTableDialog it collects its settings from first. That dialog is @@ -201,7 +202,11 @@ class QetGraphicsTableItem; calling addTable() once should create exactly the one table it asked for, not possibly several spread across folios it never asked to add. A script that wants either behaviour can resize the result itself or - add its own folio. + add its own folio. kind is "nomenclature" (over placed elements), + "summary" (over folios) or "cable_list" (over the cables of the + project): the first two require a real SQL query, the last takes + comma-joined column keys instead and accepts an empty query, which + means its default columns. Neither creating nor deleting a table is undoable: QetGraphicsTableFactory::newTable(), which create() calls, calls @@ -386,6 +391,7 @@ class QetScriptApi : public QObject Q_INVOKABLE bool exportWires(const QString &output); Q_INVOKABLE bool exportBom(const QString &output, bool noSlaves = false, bool noJunctions = false); + Q_INVOKABLE bool exportCableList(const QString &output); Q_INVOKABLE bool exportWiring(const QString &output); Q_INVOKABLE bool exportNets(const QString &output); Q_INVOKABLE bool exportLinks(const QString &output); diff --git a/sources/ui/cablenumberingdialog.cpp b/sources/ui/cablenumberingdialog.cpp new file mode 100644 index 000000000..ec833f062 --- /dev/null +++ b/sources/ui/cablenumberingdialog.cpp @@ -0,0 +1,383 @@ +/* + 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 "cablenumberingdialog.h" + +#include "../autoNum/ui/selectautonumw.h" +#include "../cable/cablerenumber.h" +#include "../cable/editcablecommand.h" +#include "../qetproject.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + @brief CableNumberingDialog::CableNumberingDialog + Build the window around the numbering rule of the project: the rule + itself, the axis the cables are laid out along when the whole + project is numbered again, and the button which does that numbering. + @param project the project whose cables are numbered + @param parent +*/ +CableNumberingDialog::CableNumberingDialog(QETProject *project, QWidget *parent) : + QDialog(parent), + m_project(project) +{ + setWindowTitle(tr("Cable numbering")); + //The rule editor needs room to be read: this window opens with + //the size it needs rather than with whatever its contents + //happen to add up to, so nothing of the rule is cramped from + //the first moment + setMinimumSize(QSize(900, 620)); + + auto *layout = new QVBoxLayout(this); + + auto *note = new QLabel(tr( + "This window shows the automatic numbering rule for the cables " + "of this project and edits it. A rule changed here is the one " + "the project keeps: it is written into the project when you " + "apply it."), this); + note->setWordWrap(true); + layout->addWidget(note); + + m_rule = new SelectAutonumW(3, this); + //One rule only, so the row which picks between several named + //numberings is hidden: what is on show is the project's rule + m_rule->setSingleRuleMode(true); + layout->addWidget(m_rule); + + auto *axis_group = new QGroupBox(tr("Axis priority"), this); + auto *axis_layout = new QHBoxLayout(axis_group); + m_axis_x = new QRadioButton(tr("Priority to the X axis (horizontal)"), axis_group); + m_axis_y = new QRadioButton(tr("Priority to the Y axis (vertical)"), axis_group); + axis_layout->addWidget(m_axis_x); + axis_layout->addWidget(m_axis_y); + layout->addWidget(axis_group); + + auto *axis_note = new QLabel(tr( + "The chosen axis decides the order in which the cables are " + "renumbered: X numbers them from left to right, Y numbers them " + "from top to bottom."), this); + axis_note->setWordWrap(true); + layout->addWidget(axis_note); + + auto *actions = new QHBoxLayout(); + m_renumber_pb = new QPushButton(tr("Renumber all cables"), this); + m_renumber_pb->setToolTip(tr( + "Sets the number of each cable of the project again from the " + "rule, starting at the beginning: a number left missing after " + "a cable is deleted is filled in this way.")); + actions->addWidget(m_renumber_pb); + actions->addStretch(); + + //No confirm button: this window is there to number the cables + //again and to show the rule which does it. What the window + //holds is written by the editor's own Apply button, and by the + //numbering button which writes it before numbering -- so a + //second button only saving and closing would say the same + //thing twice. + m_box = new QDialogButtonBox(this); + m_box->addButton(QDialogButtonBox::Cancel); + m_box->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + actions->addWidget(m_box); + layout->addLayout(actions); + + connect(m_box, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(m_renumber_pb, &QPushButton::clicked, this, &CableNumberingDialog::renumber); + //The editor's own buttons are wired the same way the project + //properties wire them, so this window saves exactly what that + //page would have saved + connect(m_rule, &SelectAutonumW::applyPressed, this, &CableNumberingDialog::applyRule); + connect(m_rule, &SelectAutonumW::removeClicked, this, &CableNumberingDialog::removeRule); + //Filling a rule in lights the numbering button up as soon as + //there is something to number with + connect(m_rule, &SelectAutonumW::contextEdited, this, + &CableNumberingDialog::updateEnabling); + + if (m_project) + { + const QString key = m_project->cableCurrentAutoNum(); + const bool has_rule = !key.isEmpty() + && !m_project->cableAutoNum(key).isEmpty(); + m_rule->setContext(has_rule ? m_project->cableAutoNum(key) + : NumerotationContext()); + //The button which takes the rule away is only worth showing + //when there is one, and only while the project may be + //written to + m_rule->setRuleRemovable(has_rule && !m_project->isReadOnly()); + + m_axis_x->setChecked(m_project->cableXAxisFirst()); + m_axis_y->setChecked(!m_project->cableXAxisFirst()); + + if (m_project->isReadOnly()) + { + m_rule->setEnabled(false); + m_axis_x->setEnabled(false); + m_axis_y->setEnabled(false); + } + } + updateEnabling(); +} + +/** + @brief CableNumberingDialog::applyRule + What the editor's own Apply button does: write the rule into the + project and leave the window open, the way that button behaves on + the numbering pages of the settings. +*/ +void CableNumberingDialog::applyRule() +{ + save(); + updateEnabling(); +} + +/** + @brief CableNumberingDialog::save + Write the rule and the axis into the project. Nothing is written + when nothing was changed, so confirming a window which was only + ever looked at leaves the project exactly as it was. + @return true when the window holds nothing which could not be written +*/ +bool CableNumberingDialog::save() +{ + if (!m_project || m_project->isReadOnly()) return false; + + bool changed = false; + + if (m_rule->isModified()) + { + if (!m_rule->isValid()) + { + QMessageBox::warning(this, tr("Cable numbering"), + tr("The rule is not complete yet: " + "there is still an empty field in it.")); + return false; + } + + const QString title = QETProject::cableAutoNumRuleName(); + m_project->addCableAutoNum(title, m_rule->toNumContext()); + m_project->setCurrentCableAutoNum(title); + //What was edited is written now, so there is nothing of it + //left for a second save to write again + m_rule->setContext(m_rule->toNumContext()); + m_rule->setRuleRemovable(true); + changed = true; + } + + const bool axis_first = m_axis_x->isChecked(); + if (m_project->cableXAxisFirst() != axis_first) changed = true; + m_project->setCableXAxisFirst(axis_first); + + if (changed) m_project->setModified(true); + return true; +} + +/** + @brief CableNumberingDialog::removeRule + Take the cable numbering rule out of the project. The project then + numbers its cables by no rule at all -- they are called W until one + is defined again -- and the cable tool asks once more whether one + should be defined. +*/ +void CableNumberingDialog::removeRule() +{ + if (!m_project || m_project->isReadOnly()) return; + + const QString title = m_project->cableCurrentAutoNum(); + if (title.isEmpty()) return; + + m_project->removeCableAutoNum(title); + m_rule->setContext(NumerotationContext()); + //No rule left, so the button which takes one away goes too. And + //having just taken the rule away means numbering cables by hand + //again: the question of defining one comes back the next time a + //cable is drawn, so it is not held back in this project any more. + m_rule->setRuleRemovable(false); + m_project->setCableAskNumbering(true); + m_project->setModified(true); + updateEnabling(); +} + +/** + @brief CableNumberingDialog::updateEnabling + Renumbering needs something to number with: a rule filled in well + enough to be one, and a project which may be written to. +*/ +void CableNumberingDialog::updateEnabling() +{ + if (!m_project || m_project->isReadOnly() || !m_rule) { + m_renumber_pb->setEnabled(false); + return; + } + + const QString key = m_project->cableCurrentAutoNum(); + const bool saved_rule = !key.isEmpty() + && !m_project->cableAutoNum(key).isEmpty(); + //A rule which is still being filled in counts as soon as it is + //written, and the numbering writes it together with the numbers + //it hands out + const bool drawn_rule = m_rule->isModified() && m_rule->isValid() + && !m_rule->toNumContext().isEmpty(); + + m_renumber_pb->setEnabled((saved_rule || drawn_rule) + && m_rule->isValid() + && !m_rule->toNumContext().isEmpty()); +} + +/** + @brief CableNumberingDialog::askAboutHandWritten + Some cables carry a name he typed in himself rather than one the + rule handed out. Taking such a name away is his to allow: yes and + they are numbered like all the others, no and they keep their name + while the rest is numbered around them, and cancelling leaves every + cable exactly where it stands. + @param count how many such names the project holds + @return 1 to number them too, 0 to leave them alone, -1 for nothing +*/ +int CableNumberingDialog::askAboutHandWritten(int count) +{ + if (count <= 0) return 1; + + QMessageBox box(QMessageBox::Question, tr("Cable numbering"), + tr("%n cable(s) of this project were entered by " + "hand.\n\n" + "Do you want to renumber them too?", "", count), + QMessageBox::NoButton, this); + QPushButton *take = box.addButton( + tr("Yes", "Les noms saisis à la main sont remplacés eux aussi."), + QMessageBox::YesRole); + QPushButton *leave = box.addButton( + tr("No", "Les noms saisis à la main sont laissés tels quels."), + QMessageBox::NoRole); + QPushButton *cancel = box.addButton( + tr("Cancel", "Ne renumérote rien du tout."), + QMessageBox::RejectRole); + //Keeping his own names is the safe answer under his finger + box.setDefaultButton(leave); + box.setEscapeButton(cancel); + box.exec(); + + const QAbstractButton *clicked = box.clickedButton(); + if (clicked == take) return 1; + if (clicked == leave) return 0; + return -1; +} + +/** + @brief CableNumberingDialog::renumber + Number every cable of the project again with the rule on show, + from the beginning of that rule and in the order the cables stand + along the chosen axis. One single undo step takes it all back, and + the window closes once the numbering has been written so that he + can see what became of his cables. +*/ +void CableNumberingDialog::renumber() +{ + if (!m_project || m_project->isReadOnly()) return; + + //A rule which is not complete yet is refused before anything is + //worked out at all. What the window holds is written only together + //with the numbering itself, so every way out coming before that + //point -- a rule which does not fit, him saying no, nothing to + //number -- leaves the project exactly as it stands. + const NumerotationContext rule = m_rule->toNumContext(); + if (!m_rule->isValid() || rule.isEmpty()) + { + QMessageBox::warning(this, tr("Cable numbering"), + tr("The rule is not complete yet.")); + return; + } + const bool axis_first = m_axis_x->isChecked(); + + //Whether he lets his own names be taken away or not, a project + //with nothing to number with says so before he is asked + //anything: the rule has to be one first + CableRenumberPlan plan = CableRenumber::plan(m_project, rule, axis_first, + true); + if (!plan.ok()) + { + QMessageBox::warning(this, tr("Cable numbering"), plan.error); + return; + } + + const int answer = askAboutHandWritten(CableRenumber::byHandCount(m_project)); + if (answer < 0) return; + + //Leaving his own names alone means the numbering runs around + //them, which can hand out a number somebody else keeps: that + //is worked out again here and refused if it does not fit + if (answer == 0) + { + plan = CableRenumber::plan(m_project, rule, axis_first, false); + if (!plan.ok()) + { + QMessageBox::warning(this, tr("Cable numbering"), plan.error); + return; + } + } + if (plan.cables.isEmpty()) + { + QMessageBox::information(this, tr("Cable numbering"), + tr("No cable to renumber in this project.")); + return; + } + + //Every cable already carries the number the rule says: there is + //nothing to write, and an undo step which undoes nothing would + //only be in the way + bool changed = false; + for (int i = 0; i < plan.cables.size(); ++i) + { + if (plan.cables.at(i)->designation() != plan.designations.at(i) + || plan.cables.at(i)->designationByHand()) { + changed = true; + break; + } + } + if (!changed) + { + QMessageBox::information(this, tr("Cable numbering"), + tr("All the cables already carry the number " + "that the rule gives them.")); + return; + } + + //The rule on show is written now, right with the numbering it + //belongs to, and the numbering then leaves the counters of that + //rule where the last number was taken from: both go into the + //project with the names, as one single act which undo takes + //back together. Nothing was written before this point, which is + //why he may still back out of here without the project having + //been touched. + if (!save()) return; + + const QString num_key = QETProject::cableAutoNumRuleName(); + if (QUndoStack *stack = m_project->undoStack()) { + stack->push(new RenumberCablesCommand( + plan.cables, plan.designations, m_project, num_key, + m_project->cableAutoNum(num_key), plan.after)); + } + accept(); +} diff --git a/sources/ui/cablenumberingdialog.h b/sources/ui/cablenumberingdialog.h new file mode 100644 index 000000000..c52a65fb9 --- /dev/null +++ b/sources/ui/cablenumberingdialog.h @@ -0,0 +1,86 @@ +/* + 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 CABLENUMBERINGDIALOG_H +#define CABLENUMBERINGDIALOG_H + +#include + +class QDialogButtonBox; +class QETProject; +class QPushButton; +class QRadioButton; +class SelectAutonumW; + +/** + @brief The window behind the "Cable numbering" entry of the + numbering menu: the one numbering rule the project numbers its + cables with, the axis those cables are laid out along when the whole + project is numbered again, and the button which does that numbering. + + The rule is the very editor the project properties hold on their + cable tab, read from the project when the window opens and written + back into the project by that editor's own Apply button -- and by + the numbering button, which writes what the window holds before it + numbers, so numbering always happens with the rule on show: a rule + which is changed here is the rule the project keeps, not a rule of + its own. There is no confirm button of this window's own: what the + window holds is either applied by hand or left behind when it is + closed. + + Renumbering works every cable of the project out from the beginning + of that rule, so the hole a deleted cable leaves in the numbering + closes -- and it only happens when he asks for it. +*/ +class CableNumberingDialog : public QDialog +{ + Q_OBJECT + + public: + explicit CableNumberingDialog(QETProject *project, QWidget *parent = nullptr); + + private slots: + ///Put the rule and the axis into the project + void applyRule(); + ///Take the cable numbering rule out of the project + void removeRule(); + ///Number every cable of the project with the rule on show + void renumber(); + ///Let the button say whether there is a rule to number with + void updateEnabling(); + + private: + ///Write what the window holds into the project + bool save(); + /** + Ask what to do with the names he typed in himself. + @param count how many such names the project holds + @return 1 to number them too, 0 to leave them alone, + -1 to do nothing at all + */ + int askAboutHandWritten(int count); + + private: + QETProject *m_project = nullptr; + SelectAutonumW *m_rule = nullptr; + QRadioButton *m_axis_x = nullptr; + QRadioButton *m_axis_y = nullptr; + QPushButton *m_renumber_pb = nullptr; + QDialogButtonBox *m_box = nullptr; +}; + +#endif // CABLENUMBERINGDIALOG_H diff --git a/sources/ui/configpage/configpages.cpp b/sources/ui/configpage/configpages.cpp index 6cba2f872..6144349ea 100644 --- a/sources/ui/configpage/configpages.cpp +++ b/sources/ui/configpage/configpages.cpp @@ -108,19 +108,26 @@ NewDiagramPage::NewDiagramPage(QETProject *project, auto saw_conductor = new SelectAutonumW(1); auto saw_element = new SelectAutonumW(0); auto saw_folio = new SelectAutonumW(2); + auto saw_cable = new SelectAutonumW(3); + //A cable is numbered by one rule and that is all there is: + //the row which lists the available numberings is hidden. + saw_cable->setSingleRuleMode(true); initAutoNumTab(m_autonum_conductor, saw_conductor, QStringLiteral("autonum/conductor")); initAutoNumTab(m_autonum_element, saw_element, QStringLiteral("autonum/element")); initAutoNumTab(m_autonum_folio, saw_folio, QStringLiteral("autonum/folio")); + initAutoNumTab(m_autonum_cable, saw_cable, QStringLiteral("autonum/cable")); + m_autonum_cable.fixed_name = QETProject::cableAutoNumRuleName(); QSettings autonum_settings; loadAutoNumTab(m_autonum_conductor, autonum_settings); loadAutoNumTab(m_autonum_element, autonum_settings); loadAutoNumTab(m_autonum_folio, autonum_settings); + loadAutoNumTab(m_autonum_cable, autonum_settings); // Intercept Return key in the combo line edits so it doesn't // activate the dialog's default button (OK). - for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio}) { + for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio, &m_autonum_cable}) { if (QComboBox *combo = tab->widget->contextComboBox()) { if (combo->lineEdit()) { combo->lineEdit()->installEventFilter(this); @@ -169,6 +176,7 @@ NewDiagramPage::NewDiagramPage(QETProject *project, autonum_inner_tab->addTab(m_autonum_conductor.widget, tr("Conductors")); autonum_inner_tab->addTab(m_autonum_element.widget, tr("Elements")); autonum_inner_tab->addTab(m_autonum_folio.widget, tr("Sheets")); + autonum_inner_tab->addTab(m_autonum_cable.widget, tr("Cables")); autonum_layout->addWidget(autonum_inner_tab); tab_widget -> addTab (autonum_widget, tr("Auto Numbering")); } @@ -280,6 +288,20 @@ void NewDiagramPage::applyConf() settings.endArray(); // save global auto-numbering defaults + // + // The cable rule is written here as well as by its own Apply + // button. The cable tool asks a project for that rule the + // moment he tries to draw a cable, and a project built out of + // settings he has just filled in and confirmed must not turn + // up without one -- which is what would happen if only the + // Apply button of that one tab could save it. A rule nobody + // has finished filling in is left alone, and a tab he has not + // touched writes nothing at all. + if (m_autonum_cable.widget + && m_autonum_cable.widget->isModified() + && m_autonum_cable.widget->isValid()) { + saveAutoNumContext(m_autonum_cable); + } persistAutonumSettings(); } } @@ -393,6 +415,12 @@ void NewDiagramPage::loadAutoNumTab(AutoNumTab &tab, QSettings &settings) tab.widget->contextComboBox()->setCurrentText(data.second); tab.widget->setContext(tab.contexts.value(data.second)); } + //A tab which keeps one single rule can take it away again with + //a button of its own -- the row the others delete from is + //hidden here -- but only when there really is one to take away. + if (!tab.fixed_name.isEmpty()) { + tab.widget->setRuleRemovable(tab.contexts.contains(tab.fixed_name)); + } } /** @@ -402,15 +430,25 @@ void NewDiagramPage::loadAutoNumTab(AutoNumTab &tab, QSettings &settings) */ void NewDiagramPage::saveAutoNumContext(AutoNumTab &tab) { - QString name = tab.widget->contextComboBox()->currentText().trimmed(); - if (name.isEmpty() || isPlaceholder(tab.widget->contextComboBox(), name)) { - return; + // A tab which keeps one single rule has no visible combo box to name + // it with (the row is hidden): it saves under its fixed name. + QString name = tab.fixed_name; + if (name.isEmpty()) { + name = tab.widget->contextComboBox()->currentText().trimmed(); + if (name.isEmpty() || isPlaceholder(tab.widget->contextComboBox(), name)) { + return; + } } tab.contexts.insert(name, tab.widget->toNumContext()); if (tab.widget->contextComboBox()->findText(name) == -1) { tab.widget->contextComboBox()->addItem(name); } + tab.widget->contextComboBox()->setCurrentText(name); persistAutonumSettings(); + //There is a rule now, so there is something to take away again. + if (!tab.fixed_name.isEmpty()) { + tab.widget->setRuleRemovable(true); + } } /** @@ -419,17 +457,35 @@ void NewDiagramPage::saveAutoNumContext(AutoNumTab &tab) */ void NewDiagramPage::removeAutoNumContext(AutoNumTab &tab) { - QString name = tab.widget->contextComboBox()->currentText().trimmed(); - if (name.isEmpty() || isPlaceholder(tab.widget->contextComboBox(), name)) { - return; + QString name = tab.fixed_name; + if (name.isEmpty()) { + name = tab.widget->contextComboBox()->currentText().trimmed(); + if (name.isEmpty() || isPlaceholder(tab.widget->contextComboBox(), name)) { + return; + } } int idx = tab.widget->contextComboBox()->findText(name); - if (idx == -1) return; + // A name which is not in the list was never saved, so there is + // nothing to take out -- unless it is a single rule, which is + // always under its fixed name whether the combo knows it or not. + if (idx == -1 && tab.fixed_name.isEmpty()) return; + if (idx != -1) { + tab.widget->contextComboBox()->removeItem(idx); + tab.widget->contextComboBox()->setCurrentText(QString()); + } tab.contexts.remove(name); - tab.widget->contextComboBox()->removeItem(idx); - tab.widget->contextComboBox()->setCurrentText(QString()); tab.widget->setContext(NumerotationContext()); persistAutonumSettings(); + + //A tab which keeps one single rule has no list to delete it + //from, so this is where the button of its own goes away too. + //And having just taken the rule away means numbering cables by + //hand again: every project of its own asks once more whether + //one should be defined -- that answer is kept per project, so + //there is nothing to take back here. + if (!tab.fixed_name.isEmpty()) { + tab.widget->setRuleRemovable(false); + } } /** @@ -439,12 +495,21 @@ void NewDiagramPage::removeAutoNumContext(AutoNumTab &tab) void NewDiagramPage::persistAutonumSettings() { QSettings settings; - for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio}) { + for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio, &m_autonum_cable}) { QString current; - QComboBox *combo = tab->widget->contextComboBox(); - if (!isPlaceholder(combo, combo->currentText().trimmed()) - && tab->contexts.contains(combo->currentText().trimmed())) { - current = combo->currentText().trimmed(); + if (!tab->fixed_name.isEmpty()) { + // The one rule of a single-rule tab is in use as soon as + // it exists: there is nothing to choose between. + if (tab->contexts.contains(tab->fixed_name)) { + current = tab->fixed_name; + } + } + else { + QComboBox *combo = tab->widget->contextComboBox(); + if (!isPlaceholder(combo, combo->currentText().trimmed()) + && tab->contexts.contains(combo->currentText().trimmed())) { + current = combo->currentText().trimmed(); + } } NumerotationContext::saveToSettings(tab->contexts, current, settings, tab->prefix); @@ -462,7 +527,7 @@ bool NewDiagramPage::eventFilter(QObject *obj, QEvent *event) auto *ke = static_cast(event); if ((ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter)) { // Check if this is a line edit inside one of our autonum combos - for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio}) { + for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio, &m_autonum_cable}) { if (QComboBox *combo = tab->widget->contextComboBox()) { if (combo->lineEdit() && combo->lineEdit() == obj) { return true; // eat the event diff --git a/sources/ui/configpage/configpages.h b/sources/ui/configpage/configpages.h index 9ddb2a523..e5676611a 100644 --- a/sources/ui/configpage/configpages.h +++ b/sources/ui/configpage/configpages.h @@ -82,10 +82,16 @@ public slots: SelectAutonumW *widget = nullptr; QHash contexts; QString prefix; + /// When set, this tab keeps one single rule, always saved + /// and read under this name: there is no visible combo box + /// to name a rule with (see SelectAutonumW:: + /// setSingleRuleMode). The cable numbering works that way. + QString fixed_name; }; AutoNumTab m_autonum_conductor; AutoNumTab m_autonum_element; AutoNumTab m_autonum_folio; + AutoNumTab m_autonum_cable; void initAutoNumTab(AutoNumTab &tab, SelectAutonumW *w, const QString &prefix); void loadAutoNumTab(AutoNumTab &tab, QSettings &settings); diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp index b2f7123dd..ccbc24cdd 100644 --- a/sources/ui/configpage/generalconfigurationpage.cpp +++ b/sources/ui/configpage/generalconfigurationpage.cpp @@ -23,6 +23,7 @@ #include "../../qeticons.h" #include "ui_generalconfigurationpage.h" #include "../../materiallist/materiallist.h" +#include "../../cablelist/cabletypelist.h" #include "../../utils/qetsettings.h" #include "../../utils/qetutils.h" #include "../../qetmessagebox.h" @@ -33,6 +34,7 @@ #include "../../ElementsCollection/qetlabelsfile.h" #include "../prefixconfigurationdialog.h" #include "../nokde/kcolorbutton.h" +#include #include #include #include @@ -40,6 +42,35 @@ #include #include +#include "../../diagram.h" +#include "../../qetproject.h" + +namespace { + /** + @brief What one of those font buttons says: the family, the size + and the style, the way it would read on the sheet. + @param font the font that text is written with + @return e.g. "Liberation Sans 8,1 (Regular)" + */ + QString fontButtonLabel(const QFont &font) + { + QString style = font.styleName(); + if (font.bold()) { + style = font.italic() + ? QCoreApplication::translate("GeneralConfigurationPage", + "Bold italic") + : QCoreApplication::translate("GeneralConfigurationPage", "Bold"); + } else if (font.italic()) { + style = QCoreApplication::translate("GeneralConfigurationPage", "Italic"); + } else if (style.isEmpty()) { + style = QCoreApplication::translate("GeneralConfigurationPage", "Normal"); + } + return font.family() + QLatin1Char(' ') + + QString::number(font.pointSizeF(), 'g', 3) + + QLatin1String(" (") + style + QLatin1Char(')'); + } +} + /** @brief GeneralConfigurationPage::GeneralConfigurationPage @param parent @@ -192,6 +223,17 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) : font.styleName() + ")"; ui->m_indi_text_font_pb->setText(fontInfos); } else { ui->m_indi_text_font_pb->setText("Liberation Sans 9 (Regular)"); } + + //Cable texts: what every cable writes along its own line when + //it has not been given a font of its own. + ui->m_cable_text_font_pb->setText(fontButtonLabel(QETApp::cableTextsFont())); + ui->m_cable_core_font_pb->setText(fontButtonLabel(QETApp::cableCoreFont())); + + //Where those texts stop in relation to the beginning of their + //own line, for the cables which say nothing about it themselves + const int align_index = int(QETApp::cableTextAlignment()) == int(Qt::AlignLeft) ? 2 + : (int(QETApp::cableTextAlignment()) == int(Qt::AlignHCenter) ? 1 : 0); + ui->m_cable_align_cb->setCurrentIndex(align_index); ui->m_highlight_integrated_elements->setChecked(settings.value("diagrameditor/highlight-integrated-elements", true).toBool()); ui->m_default_elements_info->setPlainText(settings.value("elementeditor/default-informations", "").toString()); @@ -271,6 +313,17 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) : .arg(MaterialList::defaultPath())); } + //CABLE TYPE FILE + path = CableTypeList::configuredPath(); + ui->m_cable_type_list_path_le->setText(path); + if (path.isEmpty()) + { + ui->m_cable_type_list_path_le->setPlaceholderText( + tr("Not set (default: %1)", + "hint shown in the cable type file field when no file is configured yet") + .arg(CableTypeList::defaultPath())); + } + fillLang(); } @@ -483,6 +536,30 @@ void GeneralConfigurationPage::applyConf() //kept as chosen even when it doesn't exist yet : the user may well //point QElectroTech at a file he intends to write later. MaterialList::setConfiguredPath(ui->m_material_list_path_le->text().trimmed()); + + //CABLE TYPE FILE + //Same rule as the material file : it is a plain file, kept as + //chosen even when it doesn't exist yet. + CableTypeList::setConfiguredPath(ui->m_cable_type_list_path_le->text().trimmed()); + + //The way a cable lines up its texts at the beginning of its + //line, for every cable which says nothing about it itself -- + //written now, so the sheets drawn again just below show it. + const int align_index = ui->m_cable_align_cb->currentIndex(); + QETApp::setCableTextAlignment(align_index == 2 ? Qt::AlignLeft + : (align_index == 1 + ? Qt::AlignHCenter + : Qt::AlignRight)); + + //Every folio is drawn again: the texts of the cables are written + //from the settings, so that what he just picked reaches the + //sheets without his having to touch anything on them first. + for (QETProject *project : QETApp::registeredProjects()) + { + for (Diagram *diagram : project->diagrams()) { + diagram->update(); + } + } } /** @@ -601,6 +678,45 @@ void GeneralConfigurationPage::on_m_dyn_text_font_pb_clicked() } } +/** + @brief GeneralConfigurationPage::on_m_cable_text_font_pb_clicked + Let him pick the font the texts of a cable are written with by + default: designation, type, installation, location and length. + + Every cable which has not been given a font of its own follows it, + from now on: one which has keeps the one it was given, which is why + those are set on the cable itself rather than here. +*/ +void GeneralConfigurationPage::on_m_cable_text_font_pb_clicked() +{ + bool ok = false; + const QFont font = QFontDialog::getFont(&ok, QETApp::cableTextsFont(), this, + tr("Cable text font")); + if (!ok) return; + + QSettings settings; + settings.setValue("cable-management/cable-text-font", QETUtils::fontToString(font)); + ui->m_cable_text_font_pb->setText(fontButtonLabel(QETApp::cableTextsFont())); +} + +/** + @brief GeneralConfigurationPage::on_m_cable_core_font_pb_clicked + Let him pick the font the colours of the cores are written with by + default -- all of them together, the way they are drawn: one row of + colours beside the line, never set apart from each other. +*/ +void GeneralConfigurationPage::on_m_cable_core_font_pb_clicked() +{ + bool ok = false; + const QFont font = QFontDialog::getFont(&ok, QETApp::cableCoreFont(), this, + tr("Core colors font")); + if (!ok) return; + + QSettings settings; + settings.setValue("cable-management/cable-core-font", QETUtils::fontToString(font)); + ui->m_cable_core_font_pb->setText(fontButtonLabel(QETApp::cableCoreFont())); +} + @@ -838,6 +954,70 @@ void GeneralConfigurationPage::on_m_material_list_create_pb_clicked() ui->m_material_list_path_le->setText(path); } +/** + @brief GeneralConfigurationPage::on_m_cable_type_list_browse_pb_clicked + Let the user pick an existing cable type file. +*/ +void GeneralConfigurationPage::on_m_cable_type_list_browse_pb_clicked() +{ + QString start_dir = ui->m_cable_type_list_path_le->text(); + start_dir = start_dir.isEmpty() + ? QETApp::documentDir() + : QFileInfo(start_dir).absolutePath(); + + const QString path = QFileDialog::getOpenFileName( + this, + tr("Select the cable types file"), + start_dir, + tr("CSV files (*.csv)")); + + if (!path.isEmpty()) { + ui->m_cable_type_list_path_le->setText(path); + } +} + +/** + @brief GeneralConfigurationPage::on_m_cable_type_list_create_pb_clicked + Create the cable type file with its header line, so the columns are + known before the user fills them from his spreadsheet. +*/ +void GeneralConfigurationPage::on_m_cable_type_list_create_pb_clicked() +{ + QString path = ui->m_cable_type_list_path_le->text(); + path = path.isEmpty() + ? CableTypeList::defaultPath() + : QFileInfo(path).absolutePath() + QLatin1Char('/') + CableTypeList::defaultFileName(); + + path = QFileDialog::getSaveFileName( + this, + tr("Create the cable types file"), + path, + tr("CSV files (*.csv)")); + if (path.isEmpty()) { + return; + } + if (QFileInfo(path).suffix().isEmpty()) { + path += QStringLiteral(".csv"); + } + + //An existing file is kept as it is : this button creates the + //header, it never overwrites a catalogue. + if (CableTypeList::isEmptyFile(path)) + { + QString error; + if (!CableTypeList::createFile(path, &error)) + { + QET::QetMessageBox::critical(this, + tr("Cannot be created"), + tr("Cannot create the file:\n%1\n%2") + .arg(path, error)); + return; + } + } + + ui->m_cable_type_list_path_le->setText(path); +} + void GeneralConfigurationPage::on_m_indi_text_font_pb_clicked() { bool ok; diff --git a/sources/ui/configpage/generalconfigurationpage.h b/sources/ui/configpage/generalconfigurationpage.h index 4954a6d3c..e613c7b88 100644 --- a/sources/ui/configpage/generalconfigurationpage.h +++ b/sources/ui/configpage/generalconfigurationpage.h @@ -50,7 +50,11 @@ class GeneralConfigurationPage : public ConfigPage void on_m_prefix_pb_clicked(); void on_m_material_list_browse_pb_clicked(); void on_m_material_list_create_pb_clicked(); + void on_m_cable_type_list_browse_pb_clicked(); + void on_m_cable_type_list_create_pb_clicked(); void on_m_indi_text_font_pb_clicked(); + void on_m_cable_text_font_pb_clicked(); + void on_m_cable_core_font_pb_clicked(); void on_MaxPartsElementEditorList_sb_valueChanged(int value); void on_DiagramEditor_Grid_PointSize_min_sb_valueChanged(int value); void on_ElementEditor_Grid_PointSize_min_sb_valueChanged(int value); diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui index 3edac907e..4114b51ee 100644 --- a/sources/ui/configpage/generalconfigurationpage.ui +++ b/sources/ui/configpage/generalconfigurationpage.ui @@ -600,6 +600,47 @@ + + + + Cable type list file + + + + + + + + + Directory of the cable types used to define a cable (number of cores, colors, cross-section) + + + true + + + Not set + + + + + + + Browse... + + + + + + + Create the cable type list file with its header line + + + Create... + + + + + @@ -864,6 +905,113 @@ + + + + Cable texts + + + + + + Designation, type, plant, location, length : + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Font of the texts written along the cable line; a cable which has its own keeps it. + + + + + + + + + + Core colors (all of them) : + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Font of the core colors written along the line; a cable which has its own keeps it. + + + + + + + + + + Text alignment : + + + true + + + + + + + Where the texts of a cable stop relative to the start of its line; a cable which has its own keeps it. + + + + Right aligned + + + + + Centred + + + + + Left aligned + + + + + + + diff --git a/sources/ui/configpage/projectconfigpages.cpp b/sources/ui/configpage/projectconfigpages.cpp index 4e1c62964..6710fbc3e 100644 --- a/sources/ui/configpage/projectconfigpages.cpp +++ b/sources/ui/configpage/projectconfigpages.cpp @@ -471,9 +471,20 @@ QIcon ProjectAutoNumConfigPage::icon() const /** @brief ProjectAutoNumConfigPage::applyProjectConf + Save what this page has to save when the window is confirmed. + The cable rule is written from here as well as by its own Apply + button: the cable tool asks the project for that rule the moment + he tries to draw a cable, so filling it in and confirming this + window only to be asked once more would be a surprise. A rule he + has not finished filling in is left alone, and a page he has not + touched leaves the project exactly as it was. */ void ProjectAutoNumConfigPage::applyProjectConf() -{} +{ + if (m_saw_cable && m_saw_cable->isModified() && m_saw_cable->isValid()) { + saveContextCable(); + } +} namespace { /** @@ -555,6 +566,12 @@ void ProjectAutoNumConfigPage::initWidgets() m_saw_folio = new SelectAutonumW(2); m_saw_folio->setExplicitNaming(); tab_widget->addTab(schemeTab(m_saw_folio, m_folio_users_box, m_folio_users), tr("Sheets")); + + //Cable tab: one rule only, so the row which lists the available + //numberings stays hidden and there is nothing to choose between. + m_saw_cable = new SelectAutonumW(3); + m_saw_cable->setSingleRuleMode(true); + tab_widget->addTab(m_saw_cable, tr("Cables")); //AutoNumbering Tab m_faw = new FolioAutonumberingW(project()); @@ -589,6 +606,7 @@ void ProjectAutoNumConfigPage::readValuesFromProject() m_saw_conductor->contextComboBox()->clear(); m_saw_element->contextComboBox()->clear(); m_saw_folio->contextComboBox()->clear(); + m_saw_cable->contextComboBox()->clear(); //Conductor Tab refreshSchemes(SchemeKind::Conductor, m_project->conductorCurrentAutoNum()); @@ -598,6 +616,25 @@ void ProjectAutoNumConfigPage::readValuesFromProject() //Folio Tab refreshSchemes(SchemeKind::Folio, QString()); + + //Cable tab: the rule goes straight into the editor, since with the + //row of available numberings hidden he can never pick it out of + //the combo box himself. With no rule at all the editor shows an + //empty one to fill in, exactly like a numbering which has just + //been opened for the first time. + const QStringList strlk(m_project->cableAutoNum().keys()); + m_saw_cable->contextComboBox()->addItems(strlk); + const QString cable_rule = m_project->cableCurrentAutoNum(); + const bool cable_rule_ok = !cable_rule.isEmpty() + && !m_project->cableAutoNum(cable_rule).isEmpty(); + if (cable_rule_ok) { + m_saw_cable->setContext(m_project->cableAutoNum(cable_rule)); + } else { + m_saw_cable->setContext(NumerotationContext()); + } + //The button which takes the rule away is only worth showing when + //there is one, and only clickable when the project allows changes. + m_saw_cable->setRuleRemovable(cable_rule_ok && !m_project->isReadOnly()); //Folio AutoNumbering Tab m_faw->setContext(m_project->folioAutoNum().keys()); @@ -612,6 +649,15 @@ void ProjectAutoNumConfigPage::adjustReadOnly() if (m_import_pb && m_project) { m_import_pb->setDisabled(m_project->isReadOnly()); } + //The button which takes the cable numbering rule away does the + //same thing as the import button: nothing while the project + //cannot be written. + if (m_saw_cable && m_project) { + const QString title = m_project->cableCurrentAutoNum(); + m_saw_cable->setRuleRemovable(!m_project->isReadOnly() + && !title.isEmpty() + && !m_project->cableAutoNum(title).isEmpty()); + } } /** @@ -644,6 +690,11 @@ void ProjectAutoNumConfigPage::buildConnections() connect(m_saw_folio, &SelectAutonumW::renameClicked, this, &ProjectAutoNumConfigPage::renameContextFolio); connect(m_saw_folio->contextComboBox(), &QComboBox::textActivated, this, &ProjectAutoNumConfigPage::updateContextFolio); + //Cable tab: one rule, so there is no name to pick in the hidden + //combo box -- applying always writes the single rule. + connect(m_saw_cable, &SelectAutonumW::applyPressed, this, &ProjectAutoNumConfigPage::saveContextCable); + connect(m_saw_cable, &SelectAutonumW::removeClicked, this, &ProjectAutoNumConfigPage::removeContextCable); + // Auto Folio Numbering connect(m_faw, &FolioAutonumberingW::applyPressed, this, &ProjectAutoNumConfigPage::applyAutoNum); @@ -1034,6 +1085,66 @@ void ProjectAutoNumConfigPage::saveContextElement() } } +/** + @brief ProjectAutoNumConfigPage::saveContextCable + Save the cable numbering rule into the project. + + There is one rule only, and it has no name the user could have typed + (the row which holds those names is hidden): it always goes in under + the fixed name, and it is the rule in use from the moment it exists. +*/ +void ProjectAutoNumConfigPage::saveContextCable() +{ + if (!m_project || m_project->isReadOnly()) return; + + const QString title = QETProject::cableAutoNumRuleName(); + m_project->addCableAutoNum(title, m_saw_cable->toNumContext()); + m_project->setCurrentCableAutoNum(title); + + //Keep the hidden combo box in step with what the project holds, + //so the next reading of this page shows the rule just saved. + if (m_saw_cable->contextComboBox()->findText(title) == -1) { + m_saw_cable->contextComboBox()->addItem(title); + } + m_saw_cable->contextComboBox()->setCurrentText(title); + //There is a rule now, so there is something to take away again. + m_saw_cable->setRuleRemovable(true); + //This is applied straight away rather than at the end of the + //window, so the project has to say so itself: otherwise the rule + //would be dropped by a Cancel which comes after it. + m_project->setModified(true); +} + +/** + @brief ProjectAutoNumConfigPage::removeContextCable + Take the cable numbering rule out of the project. The project then + numbers its cables by no rule at all -- they are called W until one + is defined again -- and the cable tool asks once more whether one + should be defined. +*/ +void ProjectAutoNumConfigPage::removeContextCable() +{ + if (!m_project || m_project->isReadOnly()) return; + + const QString title = m_project->cableCurrentAutoNum(); + if (title.isEmpty()) return; + + m_project->removeCableAutoNum(title); + const int idx = m_saw_cable->contextComboBox()->findText(title); + if (idx != -1) { + m_saw_cable->contextComboBox()->removeItem(idx); + } + m_saw_cable->contextComboBox()->setCurrentText(QString()); + m_saw_cable->setContext(NumerotationContext()); + //No rule left, so the button which takes one away goes too. And + //having just taken the rule away means numbering cables by hand + //again: the question of defining one comes back the next time a + //cable is drawn, so it is not held back in this project any more. + m_saw_cable->setRuleRemovable(false); + m_project->setCableAskNumbering(true); + m_project->setModified(true); +} + /** @brief ProjectAutoNumConfigPage::importFromProject Read the automatic numbering rules stored in another .qet project and @@ -1653,3 +1764,14 @@ void ProjectAutoNumConfigPage::changeToTab(int i) m_tab_widget->setCurrentIndex(i); } } + +/** + @brief ProjectAutoNumConfigPage::changeToCableTab + Show the cable tab -- the one the cable tool opens when it finds out + that the project has no numbering rule yet. +*/ +void ProjectAutoNumConfigPage::changeToCableTab() +{ + //Tabs in order : Management, Conductors, Elements, Folios, Cables + changeToTab(4); +} diff --git a/sources/ui/configpage/projectconfigpages.h b/sources/ui/configpage/projectconfigpages.h index 435df2c62..39df7702f 100644 --- a/sources/ui/configpage/projectconfigpages.h +++ b/sources/ui/configpage/projectconfigpages.h @@ -160,6 +160,9 @@ class ProjectAutoNumConfigPage : public ProjectConfigPage { QIcon icon() const override; void applyProjectConf() override; virtual void changeToTab(int); + ///Jump straight to the cable tab (used when the cable tool + ///asks whether a numbering rule should be defined) + void changeToCableTab(); protected: void initWidgets() override; void initLayout() override {} @@ -177,6 +180,8 @@ class ProjectAutoNumConfigPage : public ProjectConfigPage { void updateContextElement(const QString&);//element void saveContextElement(); void removeContextElement(); + void saveContextCable(); //cable: one rule, no name to pick + void removeContextCable(); void newContextElement(); void renameContextElement(); void refreshElementSchemes(const QString &selected); @@ -219,6 +224,7 @@ class ProjectAutoNumConfigPage : public ProjectConfigPage { SelectAutonumW *m_saw_conductor; SelectAutonumW *m_saw_folio; SelectAutonumW *m_saw_element; + SelectAutonumW *m_saw_cable; QTabWidget *m_tab_widget = nullptr; QGroupBox *m_conductor_users_box = nullptr; QTableWidget *m_conductor_users = nullptr; diff --git a/sources/ui/projectpropertiesdialog.cpp b/sources/ui/projectpropertiesdialog.cpp index f27f71464..81ab0223f 100644 --- a/sources/ui/projectpropertiesdialog.cpp +++ b/sources/ui/projectpropertiesdialog.cpp @@ -94,6 +94,20 @@ void ProjectPropertiesDialog::changeToFolio() autoNumPage->changeToTab(3); } +/** + @brief ProjectPropertiesDialog::changeToCable + Change the current displayed tab to the cable numbering tab. + The caller is the cable tool, which opens this window when it finds + that the project numbers its cables by no rule at all. +*/ +void ProjectPropertiesDialog::changeToCable() +{ + ProjectAutoNumConfigPage *autoNumPage = + static_cast ( + m_properties_dialog->pages.at(2)); + autoNumPage->changeToCableTab(); +} + /** @brief ProjectPropertiesDialog::changeToElement Change the current displayed tab to the element numberings tab. diff --git a/sources/ui/projectpropertiesdialog.h b/sources/ui/projectpropertiesdialog.h index 075423907..e54c9f866 100644 --- a/sources/ui/projectpropertiesdialog.h +++ b/sources/ui/projectpropertiesdialog.h @@ -41,6 +41,8 @@ class ProjectPropertiesDialog : public QObject { void exec(); void setCurrentPage(ProjectPropertiesDialog::Page); void changeToFolio(); + ///Show the automatic numbering page with the cable tab on top + void changeToCable(); void changeToElement(); private: diff --git a/sources/ui/xrefpropertieswidget.cpp b/sources/ui/xrefpropertieswidget.cpp index f3f44d5fa..0ee65331e 100644 --- a/sources/ui/xrefpropertieswidget.cpp +++ b/sources/ui/xrefpropertieswidget.cpp @@ -17,12 +17,16 @@ */ #include +#include #include #include +#include #include "xrefpropertieswidget.h" #include "ui_xrefpropertieswidget.h" #include "qdebug.h" +#include "../qetapp.h" +#include "../utils/qetutils.h" /** @brief XRefPropertiesWidget::XRefPropertiesWidget @@ -37,10 +41,12 @@ XRefPropertiesWidget::XRefPropertiesWidget(QHash prope { ui->setupUi(this); buildUi(); + fillMissingTypes(); connect(ui->m_display_has_cross_rb, &QRadioButton::toggled, ui->m_cross_properties_gb, &QWidget::setEnabled); connect(ui->m_display_has_contacts_rb, &QRadioButton::toggled, ui->m_show_all_slaves_cb, &QWidget::setEnabled); connect(ui->m_type_cb, qOverload(&QComboBox::currentIndexChanged), this, &XRefPropertiesWidget::typeChanged); connect(ui->m_snap_to_cb, qOverload(&QComboBox::currentIndexChanged), this, &XRefPropertiesWidget::enableOffsetSB); + connect(ui->m_font_pb, &QPushButton::clicked, this, &XRefPropertiesWidget::chooseXRefFont); updateDisplay(); } @@ -54,6 +60,7 @@ XRefPropertiesWidget::~XRefPropertiesWidget() disconnect(ui->m_display_has_contacts_rb, &QRadioButton::toggled, ui->m_show_all_slaves_cb, &QWidget::setEnabled); disconnect(ui->m_type_cb, qOverload(&QComboBox::currentIndexChanged), this, &XRefPropertiesWidget::typeChanged); disconnect(ui->m_snap_to_cb, qOverload(&QComboBox::currentIndexChanged), this, &XRefPropertiesWidget::enableOffsetSB); + disconnect(ui->m_font_pb, &QPushButton::clicked, this, &XRefPropertiesWidget::chooseXRefFont); delete ui; } @@ -65,10 +72,33 @@ XRefPropertiesWidget::~XRefPropertiesWidget() void XRefPropertiesWidget::setProperties(const QHash &properties) { m_properties = properties; + fillMissingTypes(); updateDisplay(); m_previous_type_index = ui->m_type_cb->currentIndex(); } +/** + @brief XRefPropertiesWidget::fillMissingTypes + Make sure every type the combo box offers has properties of its own. + + A project built before a type existed does not hold that type, and + neither does a settings file written before it. Without an entry the + form would make one up out of thin air, and for a cable that would be + the very default of the coil ("%f-%l%c"), which reads as a stray dash + behind the folio number and two empty numbers after it. +*/ +void XRefPropertiesWidget::fillMissingTypes() +{ + const QHash defaults = XRefProperties::defaultProperties(); + for (int i = 0; i < ui->m_type_cb->count(); ++i) + { + const QString type = ui->m_type_cb->itemData(i).toString(); + if (!m_properties.contains(type)) { + m_properties.insert(type, defaults.value(type)); + } + } +} + /** @brief XRefPropertiesWidget::properties @return the properties edited by this widget @@ -88,6 +118,9 @@ void XRefPropertiesWidget::setReadOnly(bool ro) { ui->m_type_cb->setDisabled(ro); ui->m_display_gb->setDisabled(ro); ui->m_cross_properties_gb->setDisabled(ro); + //The texts stand on their own since they are needed for a cable + //too, and they are as much a setting as the rest. + ui->m_labels_gb->setDisabled(ro); if (!ro && ui->m_display_has_contacts_rb->isChecked()) { ui->m_cross_properties_gb->setDisabled(true); @@ -100,10 +133,16 @@ void XRefPropertiesWidget::setReadOnly(bool ro) { */ void XRefPropertiesWidget::buildUi() { + //Wording of the master label as the .ui wrote it, kept to put it + //back for every type which does not rename it. + m_master_label_text = ui->label_6->text(); ui -> m_type_cb -> addItem(tr("Coil"), "coil"); ui -> m_type_cb -> addItem(tr("Organ of protection"), "protection"); ui -> m_type_cb -> addItem(tr("Switch / button"), "commutator"); ui -> m_type_cb -> addItem(tr("Programmable Logic Controller (PLC)"), "plc"); + //Not an element: the type of the cross references a cable writes + //in its own label when it runs on several folios. + ui -> m_type_cb -> addItem(tr("Cable"), "cable"); ui -> m_snap_to_cb -> addItem(tr("Footer"), "bottom"); ui -> m_snap_to_cb -> addItem(tr("Under the label of the element"), "label"); @@ -152,6 +191,7 @@ void XRefPropertiesWidget::saveProperties(int index) { xrp.setPrefix("switch", ui->m_switch_prefix_le->text()); xrp.setMasterLabel(ui->m_master_le->text()); xrp.setSlaveLabel(ui->m_slave_le->text()); + xrp.setFont(m_current_font); //The boxes cannot show a value below their minimum (the offset's //minimum is its "Default" entry, standing for the stored 0): keep //the stored value unless the box shows something else. @@ -185,6 +225,10 @@ void XRefPropertiesWidget::updateDisplay() QString master = xrp.masterLabel(); ui->m_master_le->setText(master); + //Which font that type's reference is written with: the button + //offers it for a cable alone, but every type carries its own. + m_current_font = xrp.font(); + QString slave = xrp.slaveLabel(); ui->m_slave_le->setText(slave); @@ -236,8 +280,42 @@ void XRefPropertiesWidget::updateDisplay() ui->m_display_has_cross_rb->setVisible(!is_plc); ui->m_show_terminal_name_cb->setVisible(!is_plc); ui->m_show_all_slaves_cb->setVisible(!is_plc); - ui->m_stack_overlapping_cb->setVisible(!is_plc); - ui->m_cross_properties_gb->setVisible(!is_plc); + + //A cable is not an element: it draws no cross of its own and stands + //at no place of the sheet to be pointed at. Its reference is a line + //of the label of the cable itself, written once per other folio the + //cable runs on, so the whole presentation (where a cross is put, how + //it is drawn) and the text of the slave have no meaning for it: what + //is left is the one text which is written into that line. + const bool is_cable = type == QLatin1String("cable"); + ui->m_display_gb->setVisible(!is_cable); + ui->label_7->setVisible(!is_cable); + ui->m_slave_le->setVisible(!is_cable); + //The wording of every type which is not a cable comes back from + //the .ui, so the label cannot drift away from the designer one. + ui->label_6->setText(is_cable ? tr("Text:") : m_master_label_text); + + //Only a cable writes its reference into its own label, so only a + //cable has a text whose font to choose here: the cross references + //of the elements keep the font they have always been drawn with. + ui->m_font_pb->setVisible(is_cable); + if (m_current_font.isEmpty()) + { + ui->m_font_pb->setToolTip(tr("Font and size: those of the cable texts")); + } + else + { + QFont font; + if (QETUtils::fontFromString(font, m_current_font)) { + ui->m_font_pb->setToolTip(tr("Font and size: %1, %2 pt") + .arg(font.family()).arg(font.pointSizeF())); + } + } + + //A cable stacks the lines of its references by itself, so the + //option is only worth showing for the elements. + ui->m_stack_overlapping_cb->setVisible(!is_plc && !is_cable); + ui->m_cross_properties_gb->setVisible(!is_plc && !is_cable); } /** @@ -267,3 +345,31 @@ void XRefPropertiesWidget::enableOffsetSB(int i){ ui->m_offset_sb->setEnabled(true); ui->m_stack_overlapping_cb->setEnabled(ui->m_offset_sb->isEnabled()); } + +/** + @brief XRefPropertiesWidget::chooseXRefFont + Let him pick the font -- family, size and style, all three at once -- + the reference of a cable is written with. + + The font is only kept when he presses OK: cancelling the dialog + leaves the previous one standing. What he picked is written into the + properties by saveProperties(), the way every other field of this + form is, so leaving the type or pressing OK in the settings window + is what really stores it. +*/ +void XRefPropertiesWidget::chooseXRefFont() +{ + QFont initial; + if (m_current_font.isEmpty() || !QETUtils::fontFromString(initial, m_current_font)) { + initial = QETApp::cableTextsFont(); + } + + bool ok = false; + const QFont font = QFontDialog::getFont(&ok, initial, this, + tr("Font of the cross-reference text")); + if (!ok) return; + + m_current_font = QETUtils::fontToString(font); + ui->m_font_pb->setToolTip(tr("Font and size: %1, %2 pt") + .arg(font.family()).arg(font.pointSizeF())); +} diff --git a/sources/ui/xrefpropertieswidget.h b/sources/ui/xrefpropertieswidget.h index 8bd255b74..12a1a7099 100644 --- a/sources/ui/xrefpropertieswidget.h +++ b/sources/ui/xrefpropertieswidget.h @@ -46,7 +46,9 @@ class XRefPropertiesWidget : public QWidget private: void buildUi(); + void fillMissingTypes(); void saveProperties(int index); + void chooseXRefFont(); private slots: void updateDisplay(); @@ -58,6 +60,10 @@ class XRefPropertiesWidget : public QWidget Ui::XRefPropertiesWidget *ui; QHash m_properties; int m_previous_type_index; + ///The font picked for the type shown, as the settings describe it + QString m_current_font; + ///Wording of the master label, as the .ui gives it + QString m_master_label_text; }; #endif // XREFPROPERTIESWIDGET_H diff --git a/sources/ui/xrefpropertieswidget.ui b/sources/ui/xrefpropertieswidget.ui index 06e3568ca..5e3ac7f71 100644 --- a/sources/ui/xrefpropertieswidget.ui +++ b/sources/ui/xrefpropertieswidget.ui @@ -189,100 +189,110 @@ - - + + + + + + + + + Label cross reference + + - - - Label cross reference - - - - - - - - - 0 - 0 - - - - - 48 - 0 - - - - Master - - - - - - - - - - %f-%l%c - - - - - - - - - - - - 0 - 0 - - - - - 48 - 0 - - - - Slave - - - false - - - - - - - (%f-%l%c) - - - - - - - - - Create your own text using the following variables: + + + + + + 0 + 0 + + + + + 48 + 0 + + + + Master + + + + + + + + + + %f-%l%c + + + + + + + Choose the font and size of the cable cross-reference text + + + Font… + + + + + + + + + + + + 0 + 0 + + + + + 48 + 0 + + + + Slave + + + false + + + + + + + (%f-%l%c) + + + + + + + + + Create your own text using the following variables: %f: the sheet number %F: the sheet label %l: the row number %c: the column number %M: the installation %LM: the location - - - - + - - - + + + diff --git a/sources/undocommand/deleteqgraphicsitemcommand.cpp b/sources/undocommand/deleteqgraphicsitemcommand.cpp index 5de7f90af..521bee8eb 100644 --- a/sources/undocommand/deleteqgraphicsitemcommand.cpp +++ b/sources/undocommand/deleteqgraphicsitemcommand.cpp @@ -18,6 +18,7 @@ #include "deleteqgraphicsitemcommand.h" #include "../wiringrules.h" +#include "../cable/cablemanager.h" #include "../diagram.h" #include "../qetproject.h" #include "addgraphicsobjectcommand.h" @@ -337,6 +338,15 @@ void DeleteQGraphicsItemCommand::undo() } } + //Back on the sheet and linked again: the cables which run over + //this report claim their wire on the other side once more. + for (Element *e : m_removed_contents.m_elements) { + if (e && (e->linkType() & Element::AllReport)) { + CableManager::refreshLabels(m_diagram->project()); + break; + } + } + QUndoCommand::undo(); } @@ -390,5 +400,16 @@ void DeleteQGraphicsItemCommand::redo() for(QGraphicsItem *item : m_removed_contents.items()) m_diagram->removeItem(item); + //A folio report carries a cable core over to the next sheet: + //taking one away (putting it back is undo, below) changes which + //wires belong to which cable, so the cable fields are worked + //out again straight away. + for (Element *e : m_removed_contents.m_elements) { + if (e && (e->linkType() & Element::AllReport)) { + CableManager::refreshLabels(m_diagram->project()); + break; + } + } + QUndoCommand::redo(); } diff --git a/sources/undocommand/linkelementcommand.cpp b/sources/undocommand/linkelementcommand.cpp index 0599eb8ad..e5e2f3162 100644 --- a/sources/undocommand/linkelementcommand.cpp +++ b/sources/undocommand/linkelementcommand.cpp @@ -17,6 +17,7 @@ */ #include "linkelementcommand.h" +#include "../cable/cablemanager.h" #include "../conductorautonumerotation.h" #include "../diagram.h" #include "../diagramposition.h" @@ -59,6 +60,23 @@ static QString plcCrossRefText(Element *master, Element *slave) xrp.slaveLabel(), seq, master->diagram(), master); } +/** + @brief refreshCableLabelsForReport + A folio report carries a cable core over to the next sheet, so + making or breaking such a link changes which wires belong to which + cable. Work the cable fields out again straight away, so the wire + coming out of the linked arrow shows the cable right after the link + is made -- and drops it right after the link is taken away. + @param element the element the link was made or broken on +*/ +static void refreshCableLabelsForReport(Element *element) +{ + if (!element || !(element->linkType() & Element::AllReport)) return; + if (Diagram *diagram = element->diagram()) { + CableManager::refreshLabels(diagram->project()); + } +} + /** @brief LinkElementCommand::LinkElementCommand Constructor @@ -302,6 +320,7 @@ void LinkElementCommand::undo() } makeLink(m_linked_before); + refreshCableLabelsForReport(m_element); QUndoCommand::undo(); } @@ -329,6 +348,7 @@ void LinkElementCommand::redo() } m_first_redo = false; } + refreshCableLabelsForReport(m_element); QUndoCommand::redo(); } diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 3572ed416..d871a96dd 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -87,6 +87,15 @@ if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja") --source ${CMAKE_CURRENT_SOURCE_DIR}/attached_plain_paste_probe.cpp --fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet) set_tests_properties(tst_attachedplainpaste PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE) + # Deleting a cable, undoing the delete twice and then editing anything + # has both cable commands tearing down the same cable. Runs under the + # same objects the application uses, so a second free is caught. + add_test(NAME tst_cableundointegration + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_cable_undo_probe.py + --build ${CMAKE_BINARY_DIR} --binary $ + --source ${CMAKE_CURRENT_SOURCE_DIR}/cable_undo_probe.cpp + --fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet) + set_tests_properties(tst_cableundointegration PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE) endif() if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja") add_test(NAME tst_darkimageintegration @@ -254,6 +263,23 @@ add_test(NAME tst_legacyelementtypes COMMAND tst_legacyelementtypes) target_include_directories(tst_legacyelementtypes PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_legacyelementtypes PRIVATE Qt::Test) +# The cable type catalogue is plain data (it reads and writes the CSV file +# holding the cable kinds), so it is compiled here on its own, together with +# the material file helpers it shares and the element information wording. +add_executable( + tst_cabletypelist + tst_cabletypelist.cpp + ${QET_DIR}/sources/cablelist/cabletypelist.cpp + ${QET_DIR}/sources/cablelist/cabletypelist.h + ${QET_DIR}/sources/materiallist/materiallist.cpp + ${QET_DIR}/sources/materiallist/materiallist.h + ${QET_DIR}/sources/qetinformation.cpp + ${QET_DIR}/sources/qetinformation.h) +add_test(NAME tst_cabletypelist COMMAND tst_cabletypelist) +target_include_directories(tst_cabletypelist PRIVATE ${QET_DIR}/sources) +# qetinformation.h uses QRegularExpressionValidator, which lives in QtGui. +target_link_libraries(tst_cabletypelist PRIVATE Qt::Test Qt::Gui) + add_executable( tst_qetpalette tst_qetpalette.cpp diff --git a/tests/qttest/cable_undo_probe.cpp b/tests/qttest/cable_undo_probe.cpp new file mode 100644 index 000000000..b02cc06df --- /dev/null +++ b/tests/qttest/cable_undo_probe.cpp @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Cable undo ownership regression, linked against the application's objects. +// +// Deleting a cable and undoing the delete twice leaves the cable out of +// the project while AddCableCommand still holds it, and the next edit +// makes the stack throw both commands away. Whoever of the two runs +// first frees the cable; the other one must not follow it into the freed +// memory. A raw pointer there meant freeing the same cable twice, which +// a normal build hides and AddressSanitizer reports as a heap +// use-after-free in ~AddCableCommand. +#include +#include +#include +#include +#include +#include +#include + +#include "../../sources/diagram.h" +#include "../../sources/qetproject.h" +#include "../../sources/qetmessagebox.h" +#include "../../sources/cable/addcablecommand.h" +#include "../../sources/cable/cable.h" +#include "../../sources/cable/cablepart.h" +#include "../../sources/cable/editcablecommand.h" + +static void check(bool value, const char *message) +{ + if (!value) throw std::runtime_error(message); +} + +// Any edit the user makes afterwards: pushing it is what makes the stack +// discard the commands it still holds undone, and that is where the two +// destructors meet the same cable. +struct AnyEdit : QUndoCommand +{ + void redo() override {} + void undo() override {} +}; + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + std::freopen(argv[2], "w", stdout); + + QTemporaryDir settings; + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, settings.path()); + QCoreApplication::setOrganizationName("QETCableUndoRegression"); + QETProject::setBackupEnabled(false); + QET::QetMessageBox::setNonInteractive(true); + QFontDatabase::addApplicationFont(":/fonts/LiberationSans-Regular.ttf"); + + try + { + QETProject project(QString::fromLocal8Bit(argv[1])); + check(project.state() == QETProject::Ok, "fixture opens"); + Diagram *scene = project.diagrams().first(); + + // The state the cable tool leaves behind when it has drawn one + // line and the dialog accepted it: a cable holding the section, + // a line on the folio pointing back at that cable. + CablePartData data; + data.uuid = QUuid::createUuid(); + data.diagram = scene->uuid(); + data.p1 = QPointF(100, 100); + data.p2 = QPointF(300, 100); + + //The cable the create dialog hands over: owned by the project, + //which is the only way it may enter the project's list at all. + auto *cable = project.newCable(); + cable->addPart(data); + auto *item = new CablePart(data); + item->setCable(cable); + scene->addItem(item); + + // Draw the line. + scene->undoStack().push(new AddCableCommand(cable, item, scene, true)); + check(project.cables().contains(cable), "drawn cable is in the project"); + + // Select all and delete. + scene->undoStack().push(new RemoveCableCommand(scene, {item})); + check(!project.cables().contains(cable), "delete took the cable out of the project"); + + // Ctrl+Z, Ctrl+Z: the cable is in nobody's hands now, but both + // commands still stand and both think they may have to clean it up. + scene->undoStack().undo(); + scene->undoStack().undo(); + check(!project.cables().contains(cable), "cable orphaned by two undos"); + + // The next edit of any kind: the stack throws the undone commands + // away, and ~RemoveCableCommand frees the cable before + // ~AddCableCommand gets to look at it. + scene->undoStack().push(new AnyEdit); + + check(project.state() == QETProject::Ok, "project survives the edit"); + std::puts("PASS: deleting a cable, undoing twice and editing again frees it exactly once"); + } + catch (const std::exception &error) + { + std::printf("FAIL: %s\n", error.what()); + return 1; + } + return 0; +} diff --git a/tests/qttest/run_cable_undo_probe.py b/tests/qttest/run_cable_undo_probe.py new file mode 100755 index 000000000..7613ec164 --- /dev/null +++ b/tests/qttest/run_cable_undo_probe.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Link the cable undo 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 + +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.', 'cable_undo_probe.cpp.') +probe_binary = binary.with_name('cable_undo_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 / 'cable-undo-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 / 'cable-undo' +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')) +sys.exit(result.returncode) diff --git a/tests/qttest/tst_cabletypelist.cpp b/tests/qttest/tst_cabletypelist.cpp new file mode 100644 index 000000000..65f1cb2b1 --- /dev/null +++ b/tests/qttest/tst_cabletypelist.cpp @@ -0,0 +1,205 @@ +/* + 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 "cablelist/cabletypelist.h" + +#include +#include +#include + +/** + The cable type catalogue is read back the way it was written, and the + three columns it really uses are the only ones a new file gets: + + - a catalogue carrying the material file's whole shape (ten columns, + two header lines) reads as ten columns, its machine header line is + not mistaken for a cable, and the type comes out of it : this is + the catalogue a user already has on disk; + - a file the program creates itself holds the two header lines and + the three columns, no more; + - a file written by hand with the label line alone still gives the + type, so dropping the machine header never costs a column; + - and a line of cables is never taken for the machine header, even + when it happens to be written in key shaped words. +*/ +class tst_cabletypelist : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + QString write(const QString &name, const QString &content) + { + const QString path = m_dir.filePath(name); + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return {}; + } + file.write(content.toUtf8()); + file.close(); + return path; + } + + bool load(const QString &path, CableTypeListData *data) + { + QString error; + const bool ok = CableTypeList::load(path, data, &error); + if (!ok) { + qWarning("%s", qPrintable(error)); + } + return ok; + } + + private slots: + void tenColumnCatalogueReadsBack() + { + const QString path = write(QStringLiteral("ten.csv"), + QStringLiteral( + "Désignation;Nombre d'âmes;Couleurs des âmes;Section du câble;Couleur du fourreau;Fabricant;Numéro de commande;Fournisseur;Description textuelle;Notes\n" + "designation;cores;core_colors;section;color;manufacturer;manufacturer_reference;supplier;description;notes\n" + "H07V-K 3G1,5;3;wh|bn|bl;1.5;PVC;Nexans;X123;Rexel;courant fort;note\n" + "NYM-J 3G2,5;3;wh|bn|bl;2.5;PVC;;;;;\n")); + QVERIFY(!path.isEmpty()); + + CableTypeListData data; + QVERIFY2(load(path, &data), "the catalogue could not be read"); + + //The machine header line is not one of the cables... + QCOMPARE(data.records.size(), 2); + //... and every column it names is read under that name, even the + //ones this program never gave a meaning to. + QCOMPARE(data.columns, QStringList({ + QStringLiteral("designation"), QStringLiteral("cores"), + QStringLiteral("core_colors"), QStringLiteral("section"), + QStringLiteral("color"), QStringLiteral("manufacturer"), + QStringLiteral("manufacturer_reference"), + QStringLiteral("supplier"), QStringLiteral("description"), + QStringLiteral("notes")})); + + QCOMPARE(CableTypeList::designation(data.records.at(0)), + QStringLiteral("H07V-K 3G1,5")); + QCOMPARE(CableTypeList::coreCount(data.records.at(0)), 3); + QCOMPARE(CableTypeList::coreColors(data.records.at(0)), + QStringList({QStringLiteral("wh"), QStringLiteral("bn"), + QStringLiteral("bl")})); + //A column outside the three the program uses keeps its name and + //its data. + QCOMPARE(data.records.at(0).value(QStringLiteral("section")), + QStringLiteral("1.5")); + QCOMPARE(CableTypeList::designation(data.records.at(1)), + QStringLiteral("NYM-J 3G2,5")); + } + + void newFileHasThreeColumns() + { + const QString path = m_dir.filePath(QStringLiteral("new.csv")); + QVERIFY2(CableTypeList::createFile(path), "the file was not created"); + + CableTypeListData data; + QVERIFY(load(path, &data)); + QCOMPARE(data.columns, QStringList({ + QStringLiteral("designation"), QStringLiteral("cores"), + QStringLiteral("core_colors")})); + QVERIFY(data.records.isEmpty()); + //The label line reads the way the cable form names the type. + QCOMPARE(CableTypeList::translatedHeader(data.columns), QStringList({ + QStringLiteral("Designation"), + QStringLiteral("Number of cores"), + QStringLiteral("Core colors")})); + + CableTypeRecord record; + record.setValue(QStringLiteral("designation"), + QStringLiteral("H07V-K 3G1,5")); + record.setValue(QStringLiteral("cores"), QStringLiteral("3")); + record.setValue(QStringLiteral("core_colors"), + QStringLiteral("wh|bn|bl")); + QVERIFY2(CableTypeList::appendRecord(path, record), + "the record was not appended"); + + CableTypeListData read_back; + QVERIFY(load(path, &read_back)); + QCOMPARE(read_back.columns, data.columns); + QCOMPARE(read_back.records.size(), 1); + QCOMPARE(CableTypeList::designation(read_back.records.first()), + QStringLiteral("H07V-K 3G1,5")); + QCOMPARE(CableTypeList::coreCount(read_back.records.first()), 3); + QCOMPARE(CableTypeList::coreColors(read_back.records.first()), + QStringList({QStringLiteral("wh"), QStringLiteral("bn"), + QStringLiteral("bl")})); + } + + //The folder a cable type file goes into may not exist yet: a fresh + //profile has no documents folder, and a path typed into the settings + //may name one which was never created. The write makes the folder + //rather than failing with a message which says nothing about why. + void aMissingFolderIsCreated() + { + const QString path = m_dir.filePath( + QStringLiteral("not/there/yet/qet_cable_types.csv")); + QVERIFY(!QFile::exists(path)); + + QString error; + QVERIFY2(CableTypeList::createFile(path, &error), qPrintable(error)); + QVERIFY2(QFile::exists(path), "the file was not created"); + + CableTypeListData data; + QVERIFY2(load(path, &data), "the file just created could not be read"); + QVERIFY(data.records.isEmpty()); + } + + void labelLineAloneStillGivesTheType() + { + const QString path = write(QStringLiteral("hand.csv"), + QStringLiteral( + "Désignation;Nombre d'âmes;Couleurs des âmes\n" + "H07V-K 3G1,5;3;wh|bn|bl\n")); + QVERIFY(!path.isEmpty()); + + CableTypeListData data; + QVERIFY2(load(path, &data), "the hand written file could not be read"); + QCOMPARE(data.columns, QStringList({ + QStringLiteral("designation"), QStringLiteral("cores"), + QStringLiteral("core_colors")})); + QCOMPARE(data.records.size(), 1); + QCOMPARE(CableTypeList::designation(data.records.first()), + QStringLiteral("H07V-K 3G1,5")); + QCOMPARE(CableTypeList::coreCount(data.records.first()), 3); + } + + void aCableIsNeverMistakenForTheHeader() + { + //Three cells which all look like column keys, none of which means + //anything here : far more likely to be a cable than a header. + const QString path = write(QStringLiteral("unknown.csv"), + QStringLiteral( + "Désignation;Nombre d'âmes;Couleurs des âmes\n" + "ymj;;wh\n" + "H07V-K 3G1,5;3;wh|bn|bl\n")); + QVERIFY(!path.isEmpty()); + + CableTypeListData data; + QVERIFY2(load(path, &data), "the file could not be read"); + QCOMPARE(data.records.size(), 2); + QCOMPARE(CableTypeList::designation(data.records.first()), + QStringLiteral("ymj")); + QCOMPARE(CableTypeList::designation(data.records.last()), + QStringLiteral("H07V-K 3G1,5")); + } +}; + +QTEST_APPLESS_MAIN(tst_cabletypelist) +#include "tst_cabletypelist.moc"