From a3594c6633bf674fc5c7a4b433d494a139b39fc3 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 9 Oct 2026 23:27:37 +1300 Subject: [PATCH] Add a generic device: a box symbol with terminals on any side Edit > Add > Generic device (also on the Add toolbar, and found by the command search as diagrameditor.add_generic_device) opens a three-page wizard for devices used too rarely to draw a symbol for, such as a VSD or a power supply: 1. Layout: terminals per side (0 for none), a gap every N terminals, divider marks on gaps, groups lined up across opposite sides, pitch and extra size. 2. Terminals: a table per side and the same list as one line, where In{4} is In1..In4, Q{z4} is Q0..Q3, X{3-6} is X3..X6 and an empty entry is a gap; number pins 1..n counter-clockwise; paste rows of side, name, function from a spreadsheet or a CSV file. 3. Information: name, label, manufacturer, reference, description. A preview beside every page shows the box as it will be placed. Finish, available from the first page, writes an ordinary symbol to the folder "Generic devices" of the project's collection and puts it on the cursor. The box sizes itself to its terminals and their names; every terminal lands on the grid and gets a uuid. No new XML: any QElectroTech reads it. The layout is pure (sources/genericdevice/genericdevice.cpp) and tested by tst_genericdevice. ElementPictureFactory gains pictureFromDefinition() to draw the preview with the same code as a placed element. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 4 + ico/copyright | 1 + ico/icon-themes.qrc | 28 + ico/qet/scalable/16/generic-device.svg | 12 + ico/qet/scalable/generic-device.svg | 13 + .../generated/scalable-22/generic-device.svg | 13 + .../qet-dark/scalable-16/generic-device.svg | 12 + .../qet-dark/scalable-22/generic-device.svg | 13 + .../qet-dark/scalable/generic-device.svg | 13 + misc/make_icon_themes.py | 1 + sources/diagramtoolbarsettings.cpp | 3 +- sources/diagramview.cpp | 59 + sources/diagramview.h | 1 + sources/factory/elementpicturefactory.cpp | 23 + sources/factory/elementpicturefactory.h | 1 + sources/genericdevice/genericdevice.cpp | 1006 +++++++++++++++++ sources/genericdevice/genericdevice.h | 185 +++ sources/genericdevice/genericdevicewizard.cpp | 760 +++++++++++++ sources/genericdevice/genericdevicewizard.h | 61 + sources/qetdiagrameditor.cpp | 7 + sources/qeticons.cpp | 2 + sources/qeticons.h | 1 + tests/qttest/CMakeLists.txt | 9 + tests/qttest/tst_genericdevice.cpp | 502 ++++++++ 24 files changed, 2729 insertions(+), 1 deletion(-) create mode 100644 ico/qet/scalable/16/generic-device.svg create mode 100644 ico/qet/scalable/generic-device.svg create mode 100644 ico/themes/generated/scalable-22/generic-device.svg create mode 100644 ico/themes/qet-dark/scalable-16/generic-device.svg create mode 100644 ico/themes/qet-dark/scalable-22/generic-device.svg create mode 100644 ico/themes/qet-dark/scalable/generic-device.svg create mode 100644 sources/genericdevice/genericdevice.cpp create mode 100644 sources/genericdevice/genericdevice.h create mode 100644 sources/genericdevice/genericdevicewizard.cpp create mode 100644 sources/genericdevice/genericdevicewizard.h create mode 100644 tests/qttest/tst_genericdevice.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 50e917912..94d81db28 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -244,6 +244,10 @@ set(QET_SRC_FILES ${QET_DIR}/sources/exportpropertieswidget.h ${QET_DIR}/sources/genericpanel.cpp ${QET_DIR}/sources/genericpanel.h + ${QET_DIR}/sources/genericdevice/genericdevice.cpp + ${QET_DIR}/sources/genericdevice/genericdevice.h + ${QET_DIR}/sources/genericdevice/genericdevicewizard.cpp + ${QET_DIR}/sources/genericdevice/genericdevicewizard.h ${QET_DIR}/sources/itemgroups.cpp ${QET_DIR}/sources/itemgroups.h ${QET_DIR}/sources/shownkinds.cpp diff --git a/ico/copyright b/ico/copyright index 334a2f14f..d1f6e0206 100644 --- a/ico/copyright +++ b/ico/copyright @@ -149,6 +149,7 @@ Comment: ico/qet/traced/*/circle.svg, ellipse.svg, view-fit-window.svg by ispyisail from the QElectroTech team, 16 pixel versions by the QElectroTech team ico/qet/traced/*/ellipse-to-bezier.svg, rect-to-bezier.svg, rect-to-polyline.svg by the QElectroTech team, redrawn from Andre Rummler's drawings, with permission ico/qet/traced/* other than the above by the QElectroTech team, traced from QElectroTech's PNG icons of the same names + ico/qet/scalable/generic-device.svg by ispyisail from the QElectroTech team, a box with terminals and a divider on each side ico/qet/scalable/16/* by the QElectroTech team, 16 pixel versions of the ico/qet/scalable/ icons of the same names ico/colors/* by Arnaud, contributed in 2020 diff --git a/ico/icon-themes.qrc b/ico/icon-themes.qrc index 7ea5f7b3a..30d2b9de1 100644 --- a/ico/icon-themes.qrc +++ b/ico/icon-themes.qrc @@ -21,6 +21,7 @@ qet/scalable/folio-delete.svg qet/scalable/folio-new.svg qet/scalable/folio-properties.svg + qet/scalable/generic-device.svg qet/scalable/label.svg qet/scalable/pdf-import.svg qet/scalable/snap-to-grid.svg @@ -1332,6 +1333,18 @@ themes/generated/breeze-24/format-text-underline.svg breeze/16/format-text-underline.svg breeze/16/format-text-underline.svg + qet/scalable/16/generic-device.svg + themes/generated/scalable-22/generic-device.svg + qet/scalable/16/generic-device.svg + qet/scalable/generic-device.svg + qet/scalable/16/generic-device.svg + qet/scalable/16/generic-device.svg + qet/scalable/16/generic-device.svg + themes/generated/scalable-22/generic-device.svg + qet/scalable/16/generic-device.svg + qet/scalable/generic-device.svg + qet/scalable/16/generic-device.svg + qet/scalable/16/generic-device.svg breeze/16/go-bottom.svg breeze/22/go-bottom.svg themes/generated/breeze-24/go-bottom.svg @@ -2623,6 +2636,7 @@ themes/qet-dark/scalable/folio-delete.svg themes/qet-dark/scalable/folio-new.svg themes/qet-dark/scalable/folio-properties.svg + themes/qet-dark/scalable/generic-device.svg themes/qet-dark/scalable/label.svg themes/qet-dark/scalable/pdf-import.svg themes/qet-dark/scalable/snap-to-grid.svg @@ -2994,6 +3008,8 @@ themes/qet-dark/breeze/16/format-text-underline.svg themes/qet-dark/breeze/22/format-text-underline.svg themes/qet-dark/breeze-24/format-text-underline.svg + themes/qet-dark/scalable-16/generic-device.svg + themes/qet-dark/scalable-22/generic-device.svg themes/qet-dark/breeze/16/go-bottom.svg themes/qet-dark/breeze/22/go-bottom.svg themes/qet-dark/breeze-24/go-bottom.svg @@ -4520,6 +4536,18 @@ themes/qet-dark/breeze-24/format-text-underline.svg themes/qet-dark/breeze/16/format-text-underline.svg themes/qet-dark/breeze/16/format-text-underline.svg + themes/qet-dark/scalable-16/generic-device.svg + themes/qet-dark/scalable-22/generic-device.svg + themes/qet-dark/scalable-16/generic-device.svg + themes/qet-dark/scalable/generic-device.svg + themes/qet-dark/scalable-16/generic-device.svg + themes/qet-dark/scalable-16/generic-device.svg + themes/qet-dark/scalable-16/generic-device.svg + themes/qet-dark/scalable-22/generic-device.svg + themes/qet-dark/scalable-16/generic-device.svg + themes/qet-dark/scalable/generic-device.svg + themes/qet-dark/scalable-16/generic-device.svg + themes/qet-dark/scalable-16/generic-device.svg themes/qet-dark/breeze/16/go-bottom.svg themes/qet-dark/breeze/22/go-bottom.svg themes/qet-dark/breeze-24/go-bottom.svg diff --git a/ico/qet/scalable/16/generic-device.svg b/ico/qet/scalable/16/generic-device.svg new file mode 100644 index 000000000..c8485214f --- /dev/null +++ b/ico/qet/scalable/16/generic-device.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/ico/qet/scalable/generic-device.svg b/ico/qet/scalable/generic-device.svg new file mode 100644 index 000000000..d898eae27 --- /dev/null +++ b/ico/qet/scalable/generic-device.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/generated/scalable-22/generic-device.svg b/ico/themes/generated/scalable-22/generic-device.svg new file mode 100644 index 000000000..3237df9d5 --- /dev/null +++ b/ico/themes/generated/scalable-22/generic-device.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable-16/generic-device.svg b/ico/themes/qet-dark/scalable-16/generic-device.svg new file mode 100644 index 000000000..3dd281a60 --- /dev/null +++ b/ico/themes/qet-dark/scalable-16/generic-device.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/ico/themes/qet-dark/scalable-22/generic-device.svg b/ico/themes/qet-dark/scalable-22/generic-device.svg new file mode 100644 index 000000000..ed0e79ca6 --- /dev/null +++ b/ico/themes/qet-dark/scalable-22/generic-device.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/generic-device.svg b/ico/themes/qet-dark/scalable/generic-device.svg new file mode 100644 index 000000000..7f16e198f --- /dev/null +++ b/ico/themes/qet-dark/scalable/generic-device.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/misc/make_icon_themes.py b/misc/make_icon_themes.py index 7a784c7d2..23bab1cf8 100644 --- a/misc/make_icon_themes.py +++ b/misc/make_icon_themes.py @@ -183,6 +183,7 @@ SVGS = [ "qet/scalable/folio-delete.svg", "qet/scalable/folio-new.svg", "qet/scalable/folio-properties.svg", + "qet/scalable/generic-device.svg", "qet/scalable/label.svg", "qet/scalable/pdf-import.svg", "qet/scalable/snap-to-grid.svg", diff --git a/sources/diagramtoolbarsettings.cpp b/sources/diagramtoolbarsettings.cpp index 02b6c6e06..9fed1b76a 100644 --- a/sources/diagramtoolbarsettings.cpp +++ b/sources/diagramtoolbarsettings.cpp @@ -207,7 +207,8 @@ QStringList DiagramToolbarSettings::defaultIds(const QString &name) QStringLiteral("diagrameditor.add_polyline"), QStringLiteral("diagrameditor.add_path"), QStringLiteral("diagrameditor.add_fillet"), - QStringLiteral("diagrameditor.add_terminal_strip")}; + QStringLiteral("diagrameditor.add_terminal_strip"), + QStringLiteral("diagrameditor.add_generic_device")}; } if (name == QLatin1String("diagram_depth_toolbar")) { return {QStringLiteral("depth.forward"), diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 5bb94d784..d282999cb 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -51,6 +51,7 @@ #include "ElementsCollection/xmlelementcollection.h" #include "NameList/nameslist.h" #include "elementdialog.h" +#include "genericdevice/genericdevicewizard.h" #include "qetapp.h" #include "qetgraphicsitem/element.h" #include "qetinformation.h" @@ -2416,6 +2417,64 @@ void DiagramView::generateCabinetThumbnails() } } +/** + @brief DiagramView::addGenericDevice + Edit > Add > Generic device. Open the wizard; on Finish, write the box + it describes as an ordinary symbol into the folder "Generic devices" of + this project's embedded collection and put it on the cursor, ready to + be placed. The file name carries a short uuid, so two devices given the + same name never overwrite each other. +*/ +void DiagramView::addGenericDevice() +{ + QETProject *project = m_diagram ? m_diagram->project() : nullptr; + if (!project || project->isReadOnly() || m_diagram->isReadOnly()) { + return; + } + XmlElementCollection *collection = project->embeddedElementCollection(); + if (!collection) { + return; + } + + //Measured with the fonts the folio draws with, so names fit + const GenericDevice::Fonts fonts{QETApp::diagramTextsFont(7), + QETApp::dynamicTextsItemFont(9)}; + GenericDeviceWizard wizard(fonts, this); + if (wizard.exec() != QDialog::Accepted) { + return; + } + const GenericDevice::Spec spec = wizard.spec(); + + const QString dir_name = QStringLiteral("Generic devices"); + const QString dir_path = QStringLiteral("import/") + dir_name; + if (!collection->exist(dir_path)) + { + NamesList dir_names; + dir_names.addName(QStringLiteral("en"), dir_name); + //Stored per language, like an element's names: not tr(), which + //would store the interface's language under "en" + dir_names.addName(QStringLiteral("fr"), QStringLiteral("Appareils génériques")); + if (!collection->createDir(QStringLiteral("import"), dir_name, dir_names)) { + return; + } + } + + const QString file_name = QET::stringToFileName(spec.name) + + QLatin1Char('-') + + QUuid::createUuid().toString(QUuid::Id128).left(8) + + QStringLiteral(".elmt"); + QDomDocument doc; + const QDomElement definition = GenericDevice::toDefinition( + spec, fonts, doc, QetVersion::currentVersion().toString()); + if (!collection->addElementDefinition(dir_path, file_name, definition)) { + return; + } + project->setModified(true); + + const ElementsLocation location(dir_path + QLatin1Char('/') + file_name, project); + startElementPlacement(location, defaultPlacementPos()); +} + /** @return l'editeur de schemas parent ou 0 */ diff --git a/sources/diagramview.h b/sources/diagramview.h index 0c4d02d02..96e5f3820 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -196,6 +196,7 @@ class DiagramView : public PaletteGraphicsView void adjustSceneRect(); void updateWindowTitle(); void resetConductors(); + void addGenericDevice(); private slots: void adjustGridToZoom(); diff --git a/sources/factory/elementpicturefactory.cpp b/sources/factory/elementpicturefactory.cpp index 7fa9fa831..75a789ab1 100644 --- a/sources/factory/elementpicturefactory.cpp +++ b/sources/factory/elementpicturefactory.cpp @@ -257,6 +257,29 @@ ElementPictureFactory::~ElementPictureFactory() } } +/** + @brief ElementPictureFactory::pictureFromDefinition + @return the drawing of an element @a definition that is in no + collection yet, such as one being made by a dialog, drawn by the same + code as a placed element. Not cached. +*/ +QPicture ElementPictureFactory::pictureFromDefinition(const QDomElement &definition) const +{ + QPicture picture; + QPainter painter(&picture); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::TextAntialiasing, true); + primitives primitives_; + const QDomElement description = definition.firstChildElement(QStringLiteral("description")); + for (QDomElement part = description.firstChildElement() ; !part.isNull() ; + part = part.nextSiblingElement()) { + parseElement(part, painter, primitives_); + } + painter.end(); + qDeleteAll(primitives_.m_texts); + return picture; +} + /** @brief ElementPictureFactory::build Build the picture from location. diff --git a/sources/factory/elementpicturefactory.h b/sources/factory/elementpicturefactory.h index b2b16c26b..cd7fcd763 100644 --- a/sources/factory/elementpicturefactory.h +++ b/sources/factory/elementpicturefactory.h @@ -90,6 +90,7 @@ class ElementPictureFactory QPixmap pixmap(const ElementsLocation &location); ElementPictureFactory::primitives getPrimitives(const ElementsLocation &location); void dropCache(const ElementsLocation &location); + QPicture pictureFromDefinition(const QDomElement &definition) const; private: ElementPictureFactory() {} diff --git a/sources/genericdevice/genericdevice.cpp b/sources/genericdevice/genericdevice.cpp new file mode 100644 index 000000000..9cd059a43 --- /dev/null +++ b/sources/genericdevice/genericdevice.cpp @@ -0,0 +1,1006 @@ +/* + 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 . +*/ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "genericdevice.h" + +#include "../editor/terminalnamecheck.h" + +#include +#include +#include +#include + +#include + +namespace GenericDevice { + +namespace { + +int ceilGrid(qreal v) +{ + return int(qCeil(v / Grid - 1e-9)) * Grid; +} + +int textWidth(const QFont &font, const QString &text) +{ + //+2: QTextDocument, which draws static texts, lays out a hair + //wider than the font metrics on some fonts + return int(qCeil(QFontMetricsF(font).horizontalAdvance(text))) + 2; +} + +QString number(qreal v) +{ + return QString::number(qRound(v * 100.0) / 100.0); +} + +const char *orientationOf(Side side) +{ + switch (side) { + case Left: return "w"; + case Right: return "e"; + case Top: return "n"; + case Bottom: return "s"; + } + return "w"; +} + +int terminalsIn(const Slots &list) +{ + return int(std::count_if(list.cbegin(), list.cend(), + [](const Slot &s) { return s.isTerminal(); })); +} + +QString normalisedType(const QString &type, bool *ok) +{ + for (const QString &known : {QStringLiteral("Generic"), + QStringLiteral("Inner"), + QStringLiteral("Outer")}) { + if (type.compare(known, Qt::CaseInsensitive) == 0) { + *ok = true; + return known; + } + } + *ok = type.isEmpty(); + return QStringLiteral("Generic"); +} + + /// The list of all four sides, in pin number order: counter-clockwise + /// from the top of the left side, as on an IC package. +QList pinOrder(Spec &spec) +{ + QList order; + for (Slot &s : spec.sides[Left]) order << &s; + for (Slot &s : spec.sides[Bottom]) order << &s; + for (int i = spec.sides[Right].size() - 1; i >= 0; --i) + order << &spec.sides[Right][i]; + for (int i = spec.sides[Top].size() - 1; i >= 0; --i) + order << &spec.sides[Top][i]; + return order; +} + +} // namespace + +QString Slot::shownText() const +{ + return function.isEmpty() ? name : name + QLatin1Char(' ') + function; +} + +int Spec::terminalCount() const +{ + int count = 0; + for (const Slots &side : sides) + count += terminalsIn(side); + return count; +} + +QString sideName(Side side) +{ + switch (side) { + case Left: return QCoreApplication::translate("GenericDevice", "Left"); + case Right: return QCoreApplication::translate("GenericDevice", "Right"); + case Top: return QCoreApplication::translate("GenericDevice", "Top"); + case Bottom: return QCoreApplication::translate("GenericDevice", "Bottom"); + } + return QString(); +} + +/** + @brief expandPattern + Read one side written as a line: entries separated by commas, an empty + entry is a gap, and one {…} per entry expands to numbered names: + {n} is 1…n, {zn} is 0…n-1, {a-b} is a…b. The number may sit anywhere in + the name: "X1:{4}" gives X1:1 … X1:4. + @return the list, or an error saying which entry could not be read +*/ +PatternResult expandPattern(const QString &pattern) +{ + PatternResult result; + if (pattern.trimmed().isEmpty()) + return result; + + static const QRegularExpression braces( + QStringLiteral("^([^{}]*)\\{(z?)(\\d+)(?:-(\\d+))?\\}([^{}]*)$")); + + const QStringList entries = pattern.split(QLatin1Char(',')); + for (const QString &raw : entries) + { + const QString entry = raw.trimmed(); + if (entry.isEmpty()) { + result.list << Slot::gap(); + continue; + } + if (!entry.contains(QLatin1Char('{')) && !entry.contains(QLatin1Char('}'))) { + result.list << Slot::terminal(entry); + continue; + } + + const QRegularExpressionMatch m = braces.match(entry); + if (!m.hasMatch()) { + result.error = QCoreApplication::translate("GenericDevice", "Cannot read \"%1\": use one {n}, {zn} or {a-b} per name.").arg(entry); + result.list.clear(); + return result; + } + const bool from_zero = !m.captured(2).isEmpty(); + const bool range = !m.captured(4).isEmpty(); + int first = 1, last = 0; + if (range) { + if (from_zero) { + result.error = QCoreApplication::translate("GenericDevice", "Cannot read \"%1\": z does not go with a range.").arg(entry); + result.list.clear(); + return result; + } + first = m.captured(3).toInt(); + last = m.captured(4).toInt(); + } else { + const int count = m.captured(3).toInt(); + first = from_zero ? 0 : 1; + last = first + count - 1; + } + const int count = last - first + 1; + if (count < 1) { + result.error = QCoreApplication::translate("GenericDevice", "\"%1\" gives no terminal.").arg(entry); + result.list.clear(); + return result; + } + if (count > MaxPatternCount) { + result.error = QCoreApplication::translate("GenericDevice", "\"%1\" gives %2 terminals; at most %3 at once.") + .arg(entry).arg(count).arg(MaxPatternCount); + result.list.clear(); + return result; + } + for (int n = first ; n <= last ; ++n) + result.list << Slot::terminal(m.captured(1) + QString::number(n) + m.captured(5)); + } + return result; +} + +/** + @brief toPattern + Write @a list as a pattern line, the reverse of expandPattern(). Runs + of three or more numbered names are folded: In1 In2 In3 is "In{3}". + @param expressible : set to false when a name holds a comma, a brace + or surrounding spaces, which a pattern cannot say +*/ +QString toPattern(const Slots &list, bool *expressible) +{ + bool ok = true; + static const QRegularExpression numbered(QStringLiteral("^(.*?)(\\d+)$")); + + QStringList entries; + int i = 0; + while (i < list.size()) + { + const Slot &slot = list.at(i); + if (!slot.isTerminal()) { + entries << QString(); + ++i; + continue; + } + const QString &name = slot.name; + if (name.isEmpty() || name != name.trimmed() + || name.contains(QLatin1Char(',')) || name.contains(QLatin1Char('{')) + || name.contains(QLatin1Char('}'))) { + ok = false; + } + + //Fold a run prefix+n, prefix+n+1, … written without leading zeros + const QRegularExpressionMatch m = numbered.match(name); + int run = 1; + if (m.hasMatch() && QString::number(m.captured(2).toInt()) == m.captured(2)) + { + const QString prefix = m.captured(1); + const int start = m.captured(2).toInt(); + while (i + run < list.size() && list.at(i + run).isTerminal() + && list.at(i + run).name == prefix + QString::number(start + run)) { + ++run; + } + if (run >= 3) { + const int end = start + run - 1; + if (start == 1) + entries << prefix + QStringLiteral("{%1}").arg(run); + else if (start == 0) + entries << prefix + QStringLiteral("{z%1}").arg(run); + else + entries << prefix + QStringLiteral("{%1-%2}").arg(start).arg(end); + i += run; + continue; + } + } + entries << name; + ++i; + } + + if (expressible) + *expressible = ok; + return entries.join(QStringLiteral(",")); +} + +/** + @brief applyPattern + The list of a side after its pattern line was edited: the names and + gaps come from @a expanded, and the n-th terminal keeps the function, + type and uuid of the n-th terminal of @a current, so renaming through + the pattern loses nothing typed in the table. +*/ +Slots applyPattern(const Slots ¤t, const Slots &expanded) +{ + Slots old_terminals; + for (const Slot &s : current) + if (s.isTerminal()) old_terminals << s; + + Slots result; + int n = 0; + for (Slot s : expanded) + { + if (s.isTerminal() && n < old_terminals.size()) { + const Slot &old = old_terminals.at(n); + s.function = old.function; + s.type = old.type; + s.uuid = old.uuid; + } + if (s.isTerminal()) ++n; + result << s; + } + return result; +} + +/** + @brief groupEvery + The terminals of @a list with a gap after every @a n of them, never + after the last. Gaps already there are replaced. @a n of 0 means no + groups: the terminals alone. +*/ +Slots groupEvery(const Slots &list, int n) +{ + Slots terminals; + for (const Slot &s : list) + if (s.isTerminal()) terminals << s; + if (n <= 0) + return terminals; + + Slots result; + for (int i = 0 ; i < terminals.size() ; ++i) + { + if (i > 0 && i % n == 0) + result << Slot::gap(); + result << terminals.at(i); + } + return result; +} + +/** + @brief resize + @a list with @a count terminals: new ones, unnamed, are added at the + end and extra ones removed from the end, so every name already typed is + kept. With @a group_every above 0 the gaps are laid again. +*/ +Slots resize(const Slots &list, int count, int group_every) +{ + Slots result = list; + count = std::max(0, count); + int have = terminalsIn(result); + while (have > count) + { + const Slot last = result.takeLast(); + if (last.isTerminal()) --have; + } + //Do not leave the side ending on a gap + while (!result.isEmpty() && !result.last().isTerminal()) + result.removeLast(); + while (have < count) { + result << Slot::terminal(QString()); + ++have; + } + if (group_every > 0) + result = groupEvery(result, group_every); + return result; +} + +/** + @brief lineUpGroups + Pad the shorter of each pair of groups facing each other, so that the + k-th gap of @a a and the k-th gap of @a b fall on the same row. Only + groups followed by a gap on both sides are padded; padding goes just + before that gap. +*/ +void lineUpGroups(Slots &a, Slots &b) +{ + auto split = [](const Slots &list) { + QList groups{Slots()}; + for (const Slot &s : list) { + if (s.kind == Slot::Gap) + groups << Slots(); + else + groups.last() << s; + } + return groups; + }; + + QList ga = split(a), gb = split(b); + const int gaps = std::min(ga.size(), gb.size()) - 1; + for (int k = 0 ; k < gaps ; ++k) + { + while (ga[k].size() < gb[k].size()) ga[k] << Slot::padding(); + while (gb[k].size() < ga[k].size()) gb[k] << Slot::padding(); + } + + auto join = [](const QList &groups) { + Slots list; + for (int k = 0 ; k < groups.size() ; ++k) { + if (k) list << Slot::gap(); + list << groups.at(k); + } + return list; + }; + a = join(ga); + b = join(gb); +} + +/** + @brief numberPins + Name every terminal by its pin number, counter-clockwise from the top + of the left side as on an IC package. Gaps take no number. +*/ +void numberPins(Spec &spec) +{ + int n = 1; + for (Slot *s : pinOrder(spec)) + if (s->isTerminal()) s->name = QString::number(n++); +} + +/** + @return true if every terminal of @a spec is named by numberPins(), so + the names were never typed and may be renumbered. +*/ +bool hasNumberedPins(const Spec &spec) +{ + Spec numbered = spec; + numberPins(numbered); + for (int side = 0 ; side < SideCount ; ++side) + for (int i = 0 ; i < spec.sides[side].size() ; ++i) + if (spec.sides[side][i].name != numbered.sides[side][i].name) + return false; + return true; +} + +/** + @brief nameUnnamed + Give every terminal without a name the pin number of its position, and + leave the named ones alone. +*/ +void nameUnnamed(Spec &spec) +{ + Spec numbered = spec; + numberPins(numbered); + for (int side = 0 ; side < SideCount ; ++side) + for (int i = 0 ; i < spec.sides[side].size() ; ++i) + if (spec.sides[side][i].isTerminal() && spec.sides[side][i].name.isEmpty()) + spec.sides[side][i].name = numbered.sides[side][i].name; +} + +/** + @brief parsePastedTable + Read rows of "side, name, function, type" pasted from a spreadsheet + (tabs) or a CSV (commas). Side is left/right/top/bottom or L/R/T/B; an + empty name is a gap; function and type are optional. A first row whose + side reads "side" is a header and skipped. Rows that cannot be read + are listed, as typed. +*/ +PasteResult parsePastedTable(const QString &text) +{ + PasteResult result; + const QStringList lines = text.split(QRegularExpression(QStringLiteral("\\r\\n|\\n|\\r"))); + bool first = true; + for (const QString &line : lines) + { + if (line.trimmed().isEmpty()) + continue; + const QChar separator = line.contains(QLatin1Char('\t')) ? QLatin1Char('\t') + : QLatin1Char(','); + QStringList fields = line.split(separator); + for (QString &f : fields) f = f.trimmed(); + + const QString side_text = fields.value(0).toLower(); + if (first && side_text == QLatin1String("side")) { + first = false; + continue; + } + first = false; + + int side = -1; + if (side_text == QLatin1String("left") || side_text == QLatin1String("l")) side = Left; + else if (side_text == QLatin1String("right") || side_text == QLatin1String("r")) side = Right; + else if (side_text == QLatin1String("top") || side_text == QLatin1String("t")) side = Top; + else if (side_text == QLatin1String("bottom") || side_text == QLatin1String("b")) side = Bottom; + + bool type_ok = false; + const QString type = normalisedType(fields.value(3), &type_ok); + if (side < 0 || fields.size() > 4 || !type_ok) { + result.bad_rows << line.trimmed(); + continue; + } + + const QString name = fields.value(1); + Slot slot = name.isEmpty() ? Slot::gap() : Slot::terminal(name); + if (slot.isTerminal()) { + slot.function = fields.value(2); + slot.type = type; + } + result.sides[side] << slot; + result.has_side[side] = true; + ++result.rows; + } + return result; +} + +/** + @brief layout + Where everything of @a spec goes. The body is as small as the terminals + and their names allow, then grown by the extra width and height. + Opposite sides are packed from the top (left), never spread, so their + rows line up and every terminal stays on the grid. +*/ +Layout layout(const Spec &spec, const Fonts &fonts) +{ + Layout l; + for (int s = 0 ; s < SideCount ; ++s) + l.sides[s] = spec.sides[s]; + if (spec.line_up) { + lineUpGroups(l.sides[Left], l.sides[Right]); + lineUpGroups(l.sides[Top], l.sides[Bottom]); + } + + const int pitch = std::clamp(spec.pitch / Grid * Grid, 10, 50); + auto longest = [&](Side side) { + int w = 0; + for (const Slot &s : l.sides[side]) + if (s.isTerminal()) w = std::max(w, textWidth(fonts.names, s.shownText())); + return w; + }; + auto has = [&](Side side) { return terminalsIn(l.sides[side]) > 0; }; + + const int rows = int(std::max(l.sides[Left].size(), l.sides[Right].size())); + const int cols = int(std::max(l.sides[Top].size(), l.sides[Bottom].size())); + const int label_w = textWidth(fonts.label, QStringLiteral("-XX000")); + const int label_h = int(qCeil(QFontMetricsF(fonts.label).height())); + + int width_for_names = 0; + if (has(Left) || has(Right)) + width_for_names = longest(Left) + longest(Right) + label_w + 4 * Inset + Grid; + int height_for_names = 0; + if (has(Top) || has(Bottom)) + height_for_names = longest(Top) + longest(Bottom) + 2 * label_h + 4 * Inset; + + //Where two sides meet, the first and last terminal of one side keep + //clear of the other side's names, or "+24V" at the end of the + //bottom row runs into "PE" at the foot of the right column. + auto clearance = [&](Side side, int along) { + if (has(side) && along) + return std::max(pitch, ceilGrid(longest(side) + 2 * Inset + 2)); + return pitch; + }; + const int x_first = clearance(Left, cols), x_last = clearance(Right, cols); + const int y_first = clearance(Top, rows), y_last = clearance(Bottom, rows); + + l.auto_width = ceilGrid(std::max({MinWidth, + cols ? x_first + (cols - 1) * pitch + x_last : 0, + width_for_names, + label_w + 2 * Inset})); + l.auto_height = ceilGrid(std::max({MinHeight, + rows ? y_first + (rows - 1) * pitch + y_last : 0, + height_for_names, + 2 * label_h})); + l.body_width = l.auto_width + std::max(0, spec.extra_width) * Grid; + l.body_height = l.auto_height + std::max(0, spec.extra_height) * Grid; + const int w = l.body_width, h = l.body_height; + + //A name is centred on its terminal's line: half the visible height + //of the font below the line is where its baseline goes + const QFontMetricsF names_metrics(fonts.names); + const qreal centre = (names_metrics.ascent() - names_metrics.descent()) / 2.0; + + for (int s = 0 ; s < SideCount ; ++s) + { + const Side side = Side(s); + const bool vertical = side == Left || side == Right; + for (int i = 0 ; i < l.sides[s].size() ; ++i) + { + const Slot &slot = l.sides[s].at(i); + const qreal along = (vertical ? y_first : x_first) + pitch * i; + if (slot.kind == Slot::Gap && spec.mark_gaps) + { + switch (side) { + case Left: l.dividers << QLineF(0, along, Grid, along); break; + case Right: l.dividers << QLineF(w, along, w - Grid, along); break; + case Top: l.dividers << QLineF(along, 0, along, Grid); break; + case Bottom: l.dividers << QLineF(along, h, along, h - Grid); break; + } + } + if (!slot.isTerminal()) + continue; + + Layout::PlacedTerminal t{slot, side, {}, {}}; + Layout::Name name; + name.text = slot.shownText(); + const int tw = textWidth(fonts.names, name.text); + switch (side) { + case Left: + t.point = {qreal(-Stub), along}; t.edge = {0, along}; + name.pos = {qreal(Inset), along + centre}; + break; + case Right: + t.point = {qreal(w + Stub), along}; t.edge = {qreal(w), along}; + name.pos = {qreal(w - Inset - tw), along + centre}; + break; + case Top: + t.point = {along, qreal(-Stub)}; t.edge = {along, 0}; + name.pos = {along + centre, qreal(Inset + tw)}; + name.rotation = 270; + break; + case Bottom: + t.point = {along, qreal(h + Stub)}; t.edge = {along, qreal(h)}; + name.pos = {along + centre, qreal(h - Inset)}; + name.rotation = 270; + break; + } + l.terminals << t; + l.names << name; + } + } + + //The label and the reference sit between the left and right names; + //with no room there they are centred and may cross the names rather + //than vanish. + const int left_room = has(Left) ? longest(Left) + 2 * Inset : Inset; + const int right_room = has(Right) ? longest(Right) + 2 * Inset : Inset; + qreal text_w = w - left_room - right_room; + qreal text_x = left_room; + if (text_w < label_w) { + text_w = label_w; + text_x = (w - text_w) / 2.0; + } + l.label_box = QRectF(text_x, h / 2.0 - label_h, text_w, label_h); + + if (pitch < 20 && spec.terminalCount()) + l.warnings << QCoreApplication::translate("GenericDevice", "At a pitch of 1 grid square the names of neighbouring terminals overlap."); + QStringList names; + for (const auto &t : l.terminals) names << t.slot.name; + const auto repeated = TerminalNameCheck::repeatedNames(names); + if (!repeated.isEmpty()) + l.warnings << QCoreApplication::translate("GenericDevice", "Repeated terminal names: %1. Fine if the device really repeats them, as N or PE often are.") + .arg(TerminalNameCheck::describe(repeated)); + if (TerminalNameCheck::unnamedCount(names)) + l.warnings << QCoreApplication::translate("GenericDevice", "%n terminal(s) without a name.", "", + TerminalNameCheck::unnamedCount(names)); + return l; +} + +/** + @brief toDefinition + @return the element definition of @a spec, appended to @a document: an + ordinary symbol made of a rectangle, lines and static texts, with a + dynamic text for the label and one for the manufacturer reference. Each + terminal keeps the uuid of its slot, or gets a new one. + @param version : the version written in the definition +*/ +QDomElement toDefinition(const Spec &spec, + const Fonts &fonts, + QDomDocument &document, + const QString &version) +{ + const Layout l = layout(spec, fonts); + const int w = l.body_width, h = l.body_height; + + //The declared box contains the drawing: the body, the stubs and the + //label, with a margin, rounded out to the grid. + qreal min_x = std::min(0.0, l.label_box.left()), max_x = std::max(qreal(w), l.label_box.right()); + qreal min_y = 0, max_y = h; + for (const auto &t : l.terminals) { + min_x = std::min(min_x, t.point.x()); max_x = std::max(max_x, t.point.x()); + min_y = std::min(min_y, t.point.y()); max_y = std::max(max_y, t.point.y()); + } + const int hotspot_x = ceilGrid(-min_x + 5), hotspot_y = ceilGrid(-min_y + 5); + const int width = ceilGrid(max_x + hotspot_x + 5), height = ceilGrid(max_y + hotspot_y + 5); + + QDomElement definition = document.createElement(QStringLiteral("definition")); + if (!version.isEmpty()) + definition.setAttribute(QStringLiteral("version"), version); + definition.setAttribute(QStringLiteral("type"), QStringLiteral("element")); + definition.setAttribute(QStringLiteral("link_type"), QStringLiteral("simple")); + definition.setAttribute(QStringLiteral("width"), width); + definition.setAttribute(QStringLiteral("height"), height); + definition.setAttribute(QStringLiteral("hotspot_x"), hotspot_x); + definition.setAttribute(QStringLiteral("hotspot_y"), hotspot_y); + document.appendChild(definition); + + QDomElement uuid = document.createElement(QStringLiteral("uuid")); + uuid.setAttribute(QStringLiteral("uuid"), QUuid::createUuid().toString()); + definition.appendChild(uuid); + + //Typed once, so stored once, under English, which every language + //falls back to (NamesList::name) + QDomElement names = document.createElement(QStringLiteral("names")); + QDomElement name = document.createElement(QStringLiteral("name")); + name.setAttribute(QStringLiteral("lang"), QStringLiteral("en")); + name.appendChild(document.createTextNode(spec.name.trimmed())); + names.appendChild(name); + definition.appendChild(names); + + QDomElement informations = document.createElement(QStringLiteral("elementInformations")); + const QList> infos{ + {QStringLiteral("description"), spec.description}, + {QStringLiteral("label"), spec.label}, + {QStringLiteral("manufacturer"), spec.manufacturer}, + {QStringLiteral("manufacturer_reference"), spec.reference}}; + for (const auto &info : infos) + { + if (info.second.trimmed().isEmpty()) + continue; + QDomElement e = document.createElement(QStringLiteral("elementInformation")); + e.setAttribute(QStringLiteral("show"), 1); + e.setAttribute(QStringLiteral("name"), info.first); + e.appendChild(document.createTextNode(info.second.trimmed())); + informations.appendChild(e); + } + definition.appendChild(informations); + + QDomElement description = document.createElement(QStringLiteral("description")); + const QString style = QStringLiteral("line-style:normal;line-weight:normal;filling:none;color:black"); + + QDomElement rect = document.createElement(QStringLiteral("rect")); + rect.setAttribute(QStringLiteral("x"), 0); + rect.setAttribute(QStringLiteral("y"), 0); + rect.setAttribute(QStringLiteral("width"), w); + rect.setAttribute(QStringLiteral("height"), h); + rect.setAttribute(QStringLiteral("rx"), 0); + rect.setAttribute(QStringLiteral("ry"), 0); + rect.setAttribute(QStringLiteral("style"), style); + rect.setAttribute(QStringLiteral("antialias"), QStringLiteral("false")); + description.appendChild(rect); + + auto add_line = [&](const QLineF &line) { + QDomElement e = document.createElement(QStringLiteral("line")); + e.setAttribute(QStringLiteral("x1"), number(line.x1())); + e.setAttribute(QStringLiteral("y1"), number(line.y1())); + e.setAttribute(QStringLiteral("x2"), number(line.x2())); + e.setAttribute(QStringLiteral("y2"), number(line.y2())); + e.setAttribute(QStringLiteral("end1"), QStringLiteral("none")); + e.setAttribute(QStringLiteral("end2"), QStringLiteral("none")); + e.setAttribute(QStringLiteral("length1"), QStringLiteral("1.5")); + e.setAttribute(QStringLiteral("length2"), QStringLiteral("1.5")); + e.setAttribute(QStringLiteral("style"), style); + e.setAttribute(QStringLiteral("antialias"), QStringLiteral("false")); + description.appendChild(e); + }; + for (const auto &t : l.terminals) + add_line(QLineF(t.edge, t.point)); + for (const QLineF ÷r : l.dividers) + add_line(divider); + + for (const auto &n : l.names) + { + QDomElement e = document.createElement(QStringLiteral("text")); + e.setAttribute(QStringLiteral("x"), number(n.pos.x())); + e.setAttribute(QStringLiteral("y"), number(n.pos.y())); + e.setAttribute(QStringLiteral("text"), n.text); + e.setAttribute(QStringLiteral("font"), fonts.names.toString()); + e.setAttribute(QStringLiteral("rotation"), n.rotation); + e.setAttribute(QStringLiteral("color"), QStringLiteral("#000000")); + e.setAttribute(QStringLiteral("uuid"), QUuid::createUuid().toString()); + description.appendChild(e); + } + + auto add_dynamic = [&](const QString &info_name, const QString &cached, qreal y, const QFont &font) { + QDomElement e = document.createElement(QStringLiteral("dynamic_text")); + e.setAttribute(QStringLiteral("x"), number(l.label_box.x())); + e.setAttribute(QStringLiteral("y"), number(y)); + e.setAttribute(QStringLiteral("z"), 2); + e.setAttribute(QStringLiteral("text_width"), number(l.label_box.width())); + e.setAttribute(QStringLiteral("Halignment"), QStringLiteral("AlignHCenter")); + e.setAttribute(QStringLiteral("Valignment"), QStringLiteral("AlignTop")); + e.setAttribute(QStringLiteral("frame"), QStringLiteral("false")); + e.setAttribute(QStringLiteral("rotation"), 0); + e.setAttribute(QStringLiteral("keep_visual_rotation"), QStringLiteral("true")); + e.setAttribute(QStringLiteral("text_from"), QStringLiteral("ElementInfo")); + e.setAttribute(QStringLiteral("uuid"), QUuid::createUuid().toString()); + e.setAttribute(QStringLiteral("font"), font.toString()); + QDomElement text = document.createElement(QStringLiteral("text")); + text.appendChild(document.createTextNode(cached)); + e.appendChild(text); + QDomElement info = document.createElement(QStringLiteral("info_name")); + info.appendChild(document.createTextNode(info_name)); + e.appendChild(info); + description.appendChild(e); + }; + add_dynamic(QStringLiteral("label"), spec.label.trimmed(), l.label_box.y(), fonts.label); + if (spec.show_reference && !spec.reference.trimmed().isEmpty()) + add_dynamic(QStringLiteral("manufacturer_reference"), spec.reference.trimmed(), + l.label_box.bottom(), fonts.names); + + for (const auto &t : l.terminals) + { + QDomElement e = document.createElement(QStringLiteral("terminal")); + e.setAttribute(QStringLiteral("x"), number(t.point.x())); + e.setAttribute(QStringLiteral("y"), number(t.point.y())); + e.setAttribute(QStringLiteral("uuid"), + (t.slot.uuid.isNull() ? QUuid::createUuid() : t.slot.uuid).toString()); + e.setAttribute(QStringLiteral("name"), t.slot.name); + e.setAttribute(QStringLiteral("orientation"), QLatin1String(orientationOf(t.side))); + e.setAttribute(QStringLiteral("type"), t.slot.type); + description.appendChild(e); + } + + definition.appendChild(description); + return definition; +} + +/** + @brief fromDefinition + Read back a definition written by toDefinition(): side from each + terminal's orientation, order from its position along the edge, gaps + from the pitch list with no terminal, names, types and uuids from the + terminals, functions from the drawn names. + + Not recovered: gaps before the first or after the last terminal of a + side, which leave no trace in the drawing. With divider marks on, an + unmarked empty slot is line-up padding and is dropped. + @return false if @a definition is not a generic device: anything other + than one body rectangle, stubs, divider marks, names and terminals on + the edges at a common pitch. +*/ +bool fromDefinition(const QDomElement &definition, + const Fonts &fonts, + Spec *spec) +{ + if (definition.tagName() != QLatin1String("definition") || !spec) + return false; + const QDomElement description = definition.firstChildElement(QStringLiteral("description")); + if (description.isNull()) + return false; + + Spec s; + s.mark_gaps = false; + s.show_reference = false; + + const QDomElement names = definition.firstChildElement(QStringLiteral("names")); + for (QDomElement n = names.firstChildElement(QStringLiteral("name")) ; !n.isNull() ; + n = n.nextSiblingElement(QStringLiteral("name"))) { + if (s.name.isEmpty() || n.attribute(QStringLiteral("lang")) == QLatin1String("en")) + s.name = n.text(); + } + const QDomElement infos = definition.firstChildElement(QStringLiteral("elementInformations")); + for (QDomElement i = infos.firstChildElement(QStringLiteral("elementInformation")) ; !i.isNull() ; + i = i.nextSiblingElement(QStringLiteral("elementInformation"))) { + const QString key = i.attribute(QStringLiteral("name")); + if (key == QLatin1String("label")) s.label = i.text(); + else if (key == QLatin1String("manufacturer")) s.manufacturer = i.text(); + else if (key == QLatin1String("manufacturer_reference")) s.reference = i.text(); + else if (key == QLatin1String("description")) s.description = i.text(); + } + + int rects = 0, w = 0, h = 0; + QList lines; + struct TextPart { QPointF pos; QString text; int rotation; }; + QList texts; + struct Read { Slot slot; qreal along; QPointF point; }; + QList by_side[SideCount]; + for (QDomElement e = description.firstChildElement() ; !e.isNull() ; e = e.nextSiblingElement()) + { + const QString tag = e.tagName(); + if (tag == QLatin1String("rect")) { + if (++rects > 1 || e.attribute(QStringLiteral("x")).toDouble() != 0 + || e.attribute(QStringLiteral("y")).toDouble() != 0) + return false; + w = e.attribute(QStringLiteral("width")).toInt(); + h = e.attribute(QStringLiteral("height")).toInt(); + } else if (tag == QLatin1String("line")) { + lines << QLineF(e.attribute(QStringLiteral("x1")).toDouble(), e.attribute(QStringLiteral("y1")).toDouble(), + e.attribute(QStringLiteral("x2")).toDouble(), e.attribute(QStringLiteral("y2")).toDouble()); + } else if (tag == QLatin1String("text")) { + texts << TextPart{QPointF(e.attribute(QStringLiteral("x")).toDouble(), e.attribute(QStringLiteral("y")).toDouble()), + e.attribute(QStringLiteral("text")), e.attribute(QStringLiteral("rotation")).toInt()}; + } else if (tag == QLatin1String("dynamic_text")) { + const QString info = e.firstChildElement(QStringLiteral("info_name")).text(); + if (info == QLatin1String("manufacturer_reference")) + s.show_reference = true; + else if (info != QLatin1String("label")) + return false; + } else if (tag == QLatin1String("terminal")) { + Slot slot = Slot::terminal(e.attribute(QStringLiteral("name"))); + slot.type = e.attribute(QStringLiteral("type"), QStringLiteral("Generic")); + slot.uuid = QUuid(e.attribute(QStringLiteral("uuid"))); + const qreal x = e.attribute(QStringLiteral("x")).toDouble(); + const qreal y = e.attribute(QStringLiteral("y")).toDouble(); + const QString o = e.attribute(QStringLiteral("orientation")); + const QPointF point(x, y); + if (o == QLatin1String("w")) by_side[Left] << Read{slot, y, point}; + else if (o == QLatin1String("e")) by_side[Right] << Read{slot, y, point}; + else if (o == QLatin1String("n")) by_side[Top] << Read{slot, x, point}; + else if (o == QLatin1String("s")) by_side[Bottom] << Read{slot, x, point}; + else return false; + } else { + return false; + } + } + if (rects != 1 || w <= 0 || h <= 0) + return false; + + //Every terminal sits one stub outside its edge, joined to it by a + //line; every other line is a divider mark on the inside of an edge + QList stubs; + for (int side = 0 ; side < SideCount ; ++side) + for (const Read &r : by_side[side]) + { + QPointF edge; + switch (side) { + case Left: edge = QPointF(0, r.along); break; + case Right: edge = QPointF(w, r.along); break; + case Top: edge = QPointF(r.along, 0); break; + case Bottom: edge = QPointF(r.along, h); break; + } + const QPointF out = edge + (side == Left ? QPointF(-Stub, 0) : side == Right ? QPointF(Stub, 0) + : side == Top ? QPointF(0, -Stub) : QPointF(0, Stub)); + if (r.point != out || !lines.contains(QLineF(edge, out))) + return false; + stubs << QLineF(edge, out); + } + + QList dividers[SideCount]; + for (const QLineF &line : lines) + { + if (stubs.contains(line)) + continue; + if (line == QLineF(0, line.y1(), Grid, line.y1())) dividers[Left] << line.y1(); + else if (line == QLineF(w, line.y1(), w - Grid, line.y1())) dividers[Right] << line.y1(); + else if (line == QLineF(line.x1(), 0, line.x1(), Grid)) dividers[Top] << line.x1(); + else if (line == QLineF(line.x1(), h, line.x1(), h - Grid)) dividers[Bottom] << line.x1(); + else return false; + } + + //The pitch is the smallest step between two list of a side; every + //other step must be a multiple of it + QList alongs[SideCount]; + int pitch = 0; + for (int side = 0 ; side < SideCount ; ++side) + { + std::sort(by_side[side].begin(), by_side[side].end(), + [](const Read &a, const Read &b) { return a.along < b.along; }); + for (const Read &r : by_side[side]) alongs[side] << r.along; + alongs[side] << dividers[side]; + std::sort(alongs[side].begin(), alongs[side].end()); + for (int i = 1 ; i < alongs[side].size() ; ++i) { + const int step = qRound(alongs[side][i] - alongs[side][i - 1]); + if (step > 0 && (!pitch || step < pitch)) pitch = step; + } + } + if (!pitch) + pitch = 20; + if (pitch % Grid || pitch < 10 || pitch > 50) + return false; + s.pitch = pitch; + + int padding = 0; + for (int side = 0 ; side < SideCount ; ++side) + { + if (alongs[side].isEmpty()) + continue; + const qreal first = alongs[side].first(); + const qreal last = alongs[side].last(); + if (qRound(last - first) % pitch) + return false; + int next = 0; + for (qreal along = first ; along <= last + 0.5 ; along += pitch) + { + if (next < by_side[side].size() && qFuzzyCompare(by_side[side][next].along + 1, along + 1)) { + s.sides[side] << by_side[side][next++].slot; + } else if (std::any_of(dividers[side].cbegin(), dividers[side].cend(), + [&](qreal d) { return qFuzzyCompare(d + 1, along + 1); })) { + s.sides[side] << Slot::gap(); + s.mark_gaps = true; + } else { + Slot gap = Slot::gap(); + gap.kind = Slot::Padding; //sorted out below + s.sides[side] << gap; + } + } + if (next != by_side[side].size()) + return false; + } + + //With divider marks on, every gap the user made is marked, so an + //unmarked one is padding; with them off the two cannot be told + //apart, and every gap is kept as typed. + for (Slots &side : s.sides) + for (int i = side.size() - 1 ; i >= 0 ; --i) + if (side[i].kind == Slot::Padding) { + if (s.mark_gaps) { side.removeAt(i); ++padding; } + else side[i].kind = Slot::Gap; + } + if (s.mark_gaps) { + //Line-up was on if it padded, or if it would change nothing + Spec check = s; + lineUpGroups(check.sides[Left], check.sides[Right]); + lineUpGroups(check.sides[Top], check.sides[Bottom]); + bool adds = false; + for (int side = 0 ; side < SideCount ; ++side) + adds |= check.sides[side].size() != s.sides[side].size(); + s.line_up = padding > 0 || !adds; + } else { + s.line_up = false; + } + + //Functions: the drawn name is "name function", on the terminal's + //line (within half a pitch), on the terminal's side of the body + for (int side = 0 ; side < SideCount ; ++side) + { + const bool vertical = side == Left || side == Right; + int next = 0; + for (Slot &slot : s.sides[side]) + { + if (!slot.isTerminal()) + continue; + const qreal along = by_side[side].at(next++).along; + const QString prefix = slot.name + QLatin1Char(' '); + for (const TextPart &t : texts) + { + if ((t.rotation != 0) == vertical || !t.text.startsWith(prefix)) + continue; + const qreal t_along = vertical ? t.pos.y() : t.pos.x(); + if (qAbs(t_along - along) >= pitch / 2.0) + continue; + const bool near_start = vertical ? t.pos.x() == Inset : t.pos.y() != h - Inset; + if (near_start != (side == Left || side == Top)) + continue; + slot.function = t.text.mid(prefix.size()); + break; + } + } + } + + //The extra size is whatever the body has beyond the automatic size + Spec sized = s; + sized.extra_width = sized.extra_height = 0; + const Layout auto_layout = layout(sized, fonts); + s.extra_width = std::max(0, (w - auto_layout.auto_width) / Grid); + s.extra_height = std::max(0, (h - auto_layout.auto_height) / Grid); + + *spec = s; + return true; +} + +} // namespace GenericDevice diff --git a/sources/genericdevice/genericdevice.h b/sources/genericdevice/genericdevice.h new file mode 100644 index 000000000..635855c9c --- /dev/null +++ b/sources/genericdevice/genericdevice.h @@ -0,0 +1,185 @@ +/* + 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 . +*/ +// SPDX-License-Identifier: GPL-2.0-or-later +#ifndef GENERICDEVICE_H +#define GENERICDEVICE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + @brief The GenericDevice namespace + A generic device is a plain box symbol with terminals on any side, made + by the generic device wizard for a device used too rarely to draw a + symbol by hand (a VSD, a power supply). Everything here is pure: a + description of the device (Spec) goes in, an ordinary element + definition comes out. No widget, no scene, no project. + + Coordinates are the element's own, with (0, 0) at the top-left corner + of the box body. Every terminal, stub and the body size are multiples + of the grid step, so a device placed on the grid puts every terminal on + a grid point and wires to it run straight. +*/ +namespace GenericDevice +{ + enum Side { Left = 0, Right, Top, Bottom }; + constexpr int SideCount = 4; + + constexpr int Grid = 10; ///< grid step of the folio + constexpr int Stub = 10; ///< terminal point is this far outside the body + constexpr int MinWidth = 40; + constexpr int MinHeight = 30; + constexpr int Inset = 3; ///< names sit this far inside the body edge + constexpr int MaxPatternCount = 200; ///< largest {n} or {a-b} a pattern may expand + + /** + @brief The Slot struct + One position along a side: a terminal, a gap the user asked for, or + padding added to line groups up across opposite sides. Gaps and + padding take up a pitch of space and hold no terminal; a gap may + carry a divider mark, padding never does. + */ + struct Slot + { + enum Kind { Terminal, Gap, Padding }; + Kind kind = Terminal; + QString name; + QString function; + QString type = QStringLiteral("Generic"); ///< Generic, Inner or Outer + QUuid uuid; + + bool isTerminal() const { return kind == Terminal; } + static Slot terminal(const QString &name) { Slot s; s.name = name; return s; } + static Slot gap() { Slot s; s.kind = Gap; return s; } + static Slot padding() { Slot s; s.kind = Padding; return s; } + /// The text drawn beside the terminal: "name function", or the name. + QString shownText() const; + }; + using Slots = QList; + + /** + @brief The Spec struct + Everything the wizard collects. sides[] are ordered top to bottom + (left, right) and left to right (top, bottom). + */ + struct Spec + { + QString name; + QString label; + QString manufacturer; + QString reference; + QString description; + bool show_reference = true; + + Slots sides[SideCount]; + int pitch = 20; ///< 10 to 50, a multiple of the grid + int extra_width = 0; ///< grid squares added to the automatic size + int extra_height = 0; + bool mark_gaps = true; ///< draw a divider mark on each gap + bool line_up = true; ///< pad groups so dividers meet across the box + + int terminalCount() const; + }; + + /** + @brief The Fonts struct + What the names and the label are drawn with. Measuring with the font + the folio will draw with is what keeps names inside the box. + */ + struct Fonts + { + QFont names; + QFont label; + }; + + /** + @brief The Layout struct + Where everything goes, computed from a Spec. Pure geometry. + */ + struct Layout + { + struct PlacedTerminal { + Slot slot; + Side side; + QPointF point; ///< the terminal point, outside the body + QPointF edge; ///< where its stub meets the body + }; + struct Name { + QPointF pos; ///< baseline-left, as a static text part is anchored + QString text; + int rotation = 0; + }; + + int body_width = 0; + int body_height = 0; + int auto_width = 0; ///< the size before the extra width/height + int auto_height = 0; + Slots sides[SideCount]; ///< the spec's list after line-up padding + QList terminals; + QList names; + QList dividers; + QRectF label_box; ///< x, y, width and the height of one line + QStringList warnings; + }; + + struct PatternResult + { + Slots list; + QString error; ///< empty when the pattern was read + bool ok() const { return error.isEmpty(); } + }; + + struct PasteResult + { + Slots sides[SideCount]; + bool has_side[SideCount] = {false, false, false, false}; + int rows = 0; ///< rows read + QStringList bad_rows; ///< rows that could not be read, as typed + }; + + QString sideName(Side side); + + PatternResult expandPattern(const QString &pattern); + QString toPattern(const Slots &list, bool *expressible = nullptr); + Slots applyPattern(const Slots ¤t, const Slots &expanded); + Slots groupEvery(const Slots &list, int n); + Slots resize(const Slots &list, int count, int group_every); + void lineUpGroups(Slots &a, Slots &b); + void numberPins(Spec &spec); + bool hasNumberedPins(const Spec &spec); + void nameUnnamed(Spec &spec); + PasteResult parsePastedTable(const QString &text); + + Layout layout(const Spec &spec, const Fonts &fonts); + QDomElement toDefinition(const Spec &spec, + const Fonts &fonts, + QDomDocument &document, + const QString &version = QString()); + bool fromDefinition(const QDomElement &definition, + const Fonts &fonts, + Spec *spec); +} + +#endif // GENERICDEVICE_H diff --git a/sources/genericdevice/genericdevicewizard.cpp b/sources/genericdevice/genericdevicewizard.cpp new file mode 100644 index 000000000..db5a5b13c --- /dev/null +++ b/sources/genericdevice/genericdevicewizard.cpp @@ -0,0 +1,760 @@ +/* + 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 . +*/ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "genericdevicewizard.h" + +#include "../editor/terminalnamecheck.h" +#include "../factory/elementpicturefactory.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace GenericDevice; + +namespace { + + /// After terminals were added: renumber every pin if the names were + /// still the automatic ones, otherwise only name the new, unnamed ones. +void nameNewTerminals(Spec &spec, bool were_numbered) +{ + if (were_numbered) + numberPins(spec); + else + nameUnnamed(spec); +} + +} // namespace + +/** + @brief The GenericDevicePreview class + The box as it will be placed, drawn by ElementPictureFactory like any + placed element, with its label and reference, size and warnings. +*/ +class GenericDevicePreview : public QWidget +{ + public: + explicit GenericDevicePreview(QWidget *parent = nullptr) : + QWidget(parent) + { + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + m_canvas = new Canvas(this); + m_size = new QLabel(this); + m_size->setAlignment(Qt::AlignHCenter); + m_warnings = new QLabel(this); + m_warnings->setWordWrap(true); + layout->addWidget(m_canvas, 1); + layout->addWidget(m_size); + layout->addWidget(m_warnings); + setMinimumWidth(280); + } + + void setSpec(const Spec &spec, const Fonts &fonts) + { + QDomDocument document; + const QDomElement definition = toDefinition(spec, fonts, document); + m_canvas->picture = ElementPictureFactory::instance()->pictureFromDefinition(definition); + m_canvas->layout = GenericDevice::layout(spec, fonts); + m_canvas->label = spec.label.trimmed(); + m_canvas->reference = spec.show_reference ? spec.reference.trimmed() : QString(); + m_canvas->fonts = fonts; + m_canvas->update(); + + const Layout &l = m_canvas->layout; + m_size->setText(QCoreApplication::translate("GenericDeviceWizard", "Box: %1 × %2 grid squares") + .arg(l.body_width / Grid).arg(l.body_height / Grid)); + m_warnings->setText(l.warnings.join(QLatin1Char('\n'))); + m_warnings->setVisible(!l.warnings.isEmpty()); + } + + private: + class Canvas : public QWidget + { + public: + using QWidget::QWidget; + QPicture picture; + Layout layout; + Fonts fonts; + QString label; + QString reference; + + protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.fillRect(rect(), Qt::white); + painter.setRenderHint(QPainter::Antialiasing); + + QRectF bounds(-Stub, -Stub, layout.body_width + 2 * Stub, + layout.body_height + 2 * Stub); + bounds = bounds.united(layout.label_box).adjusted(-10, -10, 10, 10); + const qreal scale = std::min({width() / bounds.width(), + height() / bounds.height(), 3.0}); + painter.translate(width() / 2.0, height() / 2.0); + painter.scale(scale, scale); + painter.translate(-bounds.center()); + + painter.drawPicture(0, 0, picture); + + //Terminals are items on a folio, not drawing: mark the + //point each wire will reach + painter.setPen(QPen(QColor(0, 102, 204), 1.5 / scale)); + for (const auto &t : layout.terminals) { + painter.drawLine(t.point + QPointF(-2, 0), t.point + QPointF(2, 0)); + painter.drawLine(t.point + QPointF(0, -2), t.point + QPointF(0, 2)); + } + + painter.setPen(Qt::black); + const QRectF box = layout.label_box; + painter.setFont(fonts.label); + painter.drawText(QRectF(box.x(), box.y(), box.width(), box.height()), + Qt::AlignHCenter | Qt::AlignTop, label); + painter.setFont(fonts.names); + painter.drawText(QRectF(box.x(), box.bottom(), box.width(), box.height()), + Qt::AlignHCenter | Qt::AlignTop, reference); + } + }; + + Canvas *m_canvas = nullptr; + QLabel *m_size = nullptr; + QLabel *m_warnings = nullptr; +}; + +/** + @brief The GenericDeviceLayoutPage class + Page 1: how many terminals on each side, how they are grouped, the + pitch and any extra size. +*/ +class GenericDeviceLayoutPage : public QWizardPage +{ + Q_OBJECT + + public: + explicit GenericDeviceLayoutPage(GenericDeviceWizard *wizard) : + QWizardPage(wizard), + m_wizard(wizard) + { + setTitle(tr("Layout")); + //Finish from here makes the device with what is set so far + setFinalPage(true); + setSubTitle(tr("How many terminals on each side, 0 for none. " + "They are numbered 1…n; name them on the next page.")); + + auto *grid = new QGridLayout(); + grid->addWidget(new QLabel(tr("Terminals")), 0, 1); + grid->addWidget(new QLabel(tr("Group every")), 0, 2); + for (int side = 0 ; side < SideCount ; ++side) + { + grid->addWidget(new QLabel(sideName(Side(side))), side + 1, 0); + m_count[side] = new QSpinBox(); + m_count[side]->setRange(0, MaxPatternCount); + m_group[side] = new QSpinBox(); + m_group[side]->setRange(0, MaxPatternCount); + m_group[side]->setSpecialValueText(tr("no groups")); + m_group[side]->setToolTip(tr("Leave a gap after every this many terminals")); + grid->addWidget(m_count[side], side + 1, 1); + grid->addWidget(m_group[side], side + 1, 2); + connect(m_count[side], qOverload(&QSpinBox::valueChanged), + this, [this, side](int value) { countChanged(Side(side), value); }); + connect(m_group[side], qOverload(&QSpinBox::valueChanged), + this, [this, side](int value) { groupChanged(Side(side), value); }); + } + + m_mark_gaps = new QCheckBox(tr("Mark gaps with a divider")); + m_line_up = new QCheckBox(tr("Line up groups across opposite sides")); + m_line_up->setToolTip(tr("Adds blank rows to the shorter group, so that " + "the gaps on facing sides fall on the same row")); + + m_pitch = new QSpinBox(); + m_pitch->setRange(1, 5); + m_pitch->setSuffix(tr(" grid squares")); + m_extra_width = new QSpinBox(); + m_extra_width->setRange(0, 100); + m_extra_width->setSuffix(tr(" grid squares")); + m_extra_height = new QSpinBox(); + m_extra_height->setRange(0, 100); + m_extra_height->setSuffix(tr(" grid squares")); + + auto *form = new QFormLayout(); + form->addRow(tr("Space between terminals:"), m_pitch); + form->addRow(tr("Extra width:"), m_extra_width); + form->addRow(tr("Extra height:"), m_extra_height); + + auto *layout = new QVBoxLayout(this); + layout->addLayout(grid); + layout->addWidget(m_mark_gaps); + layout->addWidget(m_line_up); + layout->addLayout(form); + layout->addStretch(); + + connect(m_mark_gaps, &QCheckBox::toggled, this, [this](bool on) { + m_wizard->editableSpec().mark_gaps = on; changed(); }); + connect(m_line_up, &QCheckBox::toggled, this, [this](bool on) { + m_wizard->editableSpec().line_up = on; changed(); }); + connect(m_pitch, qOverload(&QSpinBox::valueChanged), this, [this](int v) { + m_wizard->editableSpec().pitch = v * Grid; changed(); }); + connect(m_extra_width, qOverload(&QSpinBox::valueChanged), this, [this](int v) { + m_wizard->editableSpec().extra_width = v; changed(); }); + connect(m_extra_height, qOverload(&QSpinBox::valueChanged), this, [this](int v) { + m_wizard->editableSpec().extra_height = v; changed(); }); + } + + void initializePage() override + { + //Back from page 2 shows what was done there + const Spec &spec = m_wizard->editableSpec(); + for (int side = 0 ; side < SideCount ; ++side) { + QSignalBlocker blocker(m_count[side]); + int count = 0; + for (const Slot &s : spec.sides[side]) + if (s.isTerminal()) ++count; + m_count[side]->setValue(count); + } + QSignalBlocker b1(m_mark_gaps), b2(m_line_up), b3(m_pitch), b4(m_extra_width), b5(m_extra_height); + m_mark_gaps->setChecked(spec.mark_gaps); + m_line_up->setChecked(spec.line_up); + m_pitch->setValue(spec.pitch / Grid); + m_extra_width->setValue(spec.extra_width); + m_extra_height->setValue(spec.extra_height); + } + + /// Going Back must never change anything (scope §8) + void cleanupPage() override {} + + bool isComplete() const override + { + return m_wizard->editableSpec().terminalCount() > 0; + } + + private: + void countChanged(Side side, int value) + { + Spec &spec = m_wizard->editableSpec(); + const bool numbered = hasNumberedPins(spec); + spec.sides[side] = GenericDevice::resize(spec.sides[side], value, m_group[side]->value()); + nameNewTerminals(spec, numbered); + changed(); + } + + void groupChanged(Side side, int value) + { + Spec &spec = m_wizard->editableSpec(); + const Slots regrouped = groupEvery(spec.sides[side], value); + const Slots expected = groupEvery(spec.sides[side], m_group_value[side]); + auto same = [](const Slots &a, const Slots &b) { + if (a.size() != b.size()) return false; + for (int i = 0 ; i < a.size() ; ++i) + if (a[i].kind != b[i].kind) return false; + return true; + }; + //Gaps typed on page 2 would be replaced: say so first + if (!same(spec.sides[side], expected) + && QMessageBox::question(this, tr("Group terminals"), + tr("Grouping replaces the gaps you made on the %1 side. Continue?") + .arg(sideName(side).toLower())) != QMessageBox::Yes) + { + QSignalBlocker blocker(m_group[side]); + m_group[side]->setValue(m_group_value[side]); + return; + } + m_group_value[side] = value; + spec.sides[side] = regrouped; + changed(); + } + + void changed() + { + m_wizard->specChanged(); + emit completeChanged(); + } + + GenericDeviceWizard *m_wizard; + QSpinBox *m_count[SideCount]; + QSpinBox *m_group[SideCount]; + int m_group_value[SideCount] = {0, 0, 0, 0}; + QCheckBox *m_mark_gaps; + QCheckBox *m_line_up; + QSpinBox *m_pitch; + QSpinBox *m_extra_width; + QSpinBox *m_extra_height; +}; + +/** + @brief The GenericDeviceTerminalsPage class + Page 2: one table per side, and the same list as a pattern line above + it. Editing either rewrites the other. +*/ +class GenericDeviceTerminalsPage : public QWizardPage +{ + Q_OBJECT + + public: + explicit GenericDeviceTerminalsPage(GenericDeviceWizard *wizard) : + QWizardPage(wizard), + m_wizard(wizard) + { + setTitle(tr("Terminals")); + //Finish from here makes the device with what is set so far + setFinalPage(true); + setSubTitle(tr("Name the terminals. A side can be typed as one line: " + "In{4} gives In1 to In4, Q{z4} Q0 to Q3, X{3-6} X3 to X6, " + "and an empty entry is a gap.")); + + m_tabs = new QTabWidget(); + for (int side = 0 ; side < SideCount ; ++side) + { + auto *tab = new QWidget(); + auto *layout = new QVBoxLayout(tab); + auto *line = new QHBoxLayout(); + line->addWidget(new QLabel(tr("As a line:"))); + m_pattern[side] = new QLineEdit(); + m_pattern[side]->setPlaceholderText(tr("e.g. L1,L2,L3,,PE")); + line->addWidget(m_pattern[side], 1); + layout->addLayout(line); + m_error[side] = new QLabel(); + m_error[side]->setWordWrap(true); + m_error[side]->setStyleSheet(QStringLiteral("color: #c0392b")); + m_error[side]->hide(); + layout->addWidget(m_error[side]); + + m_table[side] = new QTableWidget(0, 3); + m_table[side]->setHorizontalHeaderLabels({tr("Name"), tr("Function"), tr("Type")}); + m_table[side]->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + m_table[side]->setSelectionBehavior(QAbstractItemView::SelectRows); + layout->addWidget(m_table[side], 1); + m_tabs->addTab(tab, sideName(Side(side))); + + connect(m_pattern[side], &QLineEdit::textEdited, this, + [this, side](const QString &text) { patternEdited(Side(side), text); }); + connect(m_table[side], &QTableWidget::itemChanged, this, + [this, side](QTableWidgetItem *item) { itemChanged(Side(side), item); }); + } + + auto *add = new QPushButton(tr("Add terminal")); + auto *gap = new QPushButton(tr("Add gap")); + auto *remove = new QPushButton(tr("Delete")); + auto *up = new QPushButton(tr("Up")); + auto *down = new QPushButton(tr("Down")); + auto *buttons = new QHBoxLayout(); + for (QPushButton *b : {add, gap, remove, up, down}) + buttons->addWidget(b); + buttons->addStretch(); + + auto *number = new QPushButton(tr("Number pins 1…n")); + number->setToolTip(tr("Rename every terminal by its pin number, " + "counter-clockwise from the top of the left side")); + auto *paste = new QPushButton(tr("Paste table")); + paste->setToolTip(tr("Rows of side, name, function and type, copied from " + "a spreadsheet or a CSV file. Side is left, right, top, " + "bottom or L, R, T, B; an empty name is a gap.")); + auto *global = new QHBoxLayout(); + global->addWidget(number); + global->addWidget(paste); + global->addStretch(); + + m_paste_report = new QLabel(); + m_paste_report->setWordWrap(true); + m_paste_report->hide(); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(m_tabs, 1); + layout->addLayout(buttons); + layout->addLayout(global); + layout->addWidget(m_paste_report); + + connect(add, &QPushButton::clicked, this, [this] { insert(Slot::terminal(QString())); }); + connect(gap, &QPushButton::clicked, this, [this] { insert(Slot::gap()); }); + connect(remove, &QPushButton::clicked, this, &GenericDeviceTerminalsPage::removeRows); + connect(up, &QPushButton::clicked, this, [this] { move(-1); }); + connect(down, &QPushButton::clicked, this, [this] { move(1); }); + connect(number, &QPushButton::clicked, this, [this] { + numberPins(m_wizard->editableSpec()); + rebuildAll(); + changed(); + }); + connect(paste, &QPushButton::clicked, this, &GenericDeviceTerminalsPage::pasteTable); + } + + void initializePage() override + { + rebuildAll(); + //Open on the first side that has terminals + const Spec &spec = m_wizard->editableSpec(); + for (int side = 0 ; side < SideCount ; ++side) + if (!spec.sides[side].isEmpty()) { m_tabs->setCurrentIndex(side); break; } + } + + void cleanupPage() override {} + + bool isComplete() const override + { + return m_wizard->editableSpec().terminalCount() > 0; + } + + private: + Side currentSide() const { return Side(m_tabs->currentIndex()); } + + void rebuildAll() + { + for (int side = 0 ; side < SideCount ; ++side) { + rebuildTable(Side(side)); + updatePattern(Side(side)); + } + } + + void rebuildTable(Side side) + { + QTableWidget *table = m_table[side]; + const Slots &list = m_wizard->editableSpec().sides[side]; + QSignalBlocker blocker(table); + table->clearSpans(); + table->setRowCount(int(list.size())); + for (int row = 0 ; row < list.size() ; ++row) + { + const Slot &slot = list.at(row); + table->removeCellWidget(row, 2); + if (!slot.isTerminal()) + { + auto *item = new QTableWidgetItem(tr("gap")); + item->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + item->setForeground(palette().color(QPalette::PlaceholderText)); + item->setTextAlignment(Qt::AlignCenter); + table->setItem(row, 0, item); + delete table->takeItem(row, 1); + table->setSpan(row, 0, 1, 3); + continue; + } + table->setItem(row, 0, new QTableWidgetItem(slot.name)); + table->setItem(row, 1, new QTableWidgetItem(slot.function)); + auto *type = new QComboBox(); + type->addItem(tr("Generic"), QStringLiteral("Generic")); + type->addItem(tr("Inner"), QStringLiteral("Inner")); + type->addItem(tr("Outer"), QStringLiteral("Outer")); + type->setCurrentIndex(std::max(0, type->findData(slot.type))); + connect(type, qOverload(&QComboBox::currentIndexChanged), this, + [this, side, type, row](int) { + Slots &l = m_wizard->editableSpec().sides[side]; + if (row < l.size()) l[row].type = type->currentData().toString(); + changed(); + }); + table->setCellWidget(row, 2, type); + } + markRepeatedNames(); + } + + /// A repeated name gets a warning icon on its row, never a block + void markRepeatedNames() + { + const Spec &spec = m_wizard->editableSpec(); + QStringList all; + for (const Slots &side : spec.sides) + for (const Slot &s : side) + if (s.isTerminal()) all << s.name.trimmed(); + QSet repeated; + for (const auto &pair : TerminalNameCheck::repeatedNames(all)) + repeated << pair.first; + + for (int side = 0 ; side < SideCount ; ++side) + { + QSignalBlocker blocker(m_table[side]); + const Slots &list = spec.sides[side]; + for (int row = 0 ; row < list.size() ; ++row) + { + QTableWidgetItem *item = m_table[side]->item(row, 0); + if (!item || !list.at(row).isTerminal()) continue; + const bool twice = repeated.contains(list.at(row).name.trimmed()); + item->setIcon(twice ? QIcon::fromTheme(QStringLiteral("dialog-warning")) : QIcon()); + item->setToolTip(twice ? tr("This name is used more than once. " + "Fine if the device repeats it, as N or PE often are.") + : QString()); + } + } + } + + void updatePattern(Side side) + { + bool expressible = true; + const QString text = toPattern(m_wizard->editableSpec().sides[side], &expressible); + if (!m_pattern[side]->hasFocus()) + m_pattern[side]->setText(text); + m_pattern[side]->setReadOnly(!expressible); + m_pattern[side]->setToolTip(expressible ? QString() + : tr("A name holds a comma or a brace, which a line " + "cannot say: edit this side in the table.")); + m_error[side]->hide(); + } + + void patternEdited(Side side, const QString &text) + { + const PatternResult result = expandPattern(text); + if (!result.ok()) { + m_error[side]->setText(result.error); + m_error[side]->show(); + return; + } + m_error[side]->hide(); + Slots &list = m_wizard->editableSpec().sides[side]; + list = applyPattern(list, result.list); + rebuildTable(side); + changed(); + } + + void itemChanged(Side side, QTableWidgetItem *item) + { + Slots &list = m_wizard->editableSpec().sides[side]; + if (item->row() >= list.size() || !list[item->row()].isTerminal()) + return; + if (item->column() == 0) + list[item->row()].name = item->text().trimmed(); + else if (item->column() == 1) + list[item->row()].function = item->text().trimmed(); + updatePattern(side); + markRepeatedNames(); + changed(); + } + + void insert(const Slot &slot) + { + const Side side = currentSide(); + Spec &spec = m_wizard->editableSpec(); + const bool numbered = hasNumberedPins(spec); + const int current = m_table[side]->currentRow(); + const int row = current < 0 ? int(spec.sides[side].size()) : current + 1; + spec.sides[side].insert(row, slot); + if (slot.isTerminal()) + nameNewTerminals(spec, numbered); + rebuildAll(); + m_table[side]->setCurrentCell(row, 0); + changed(); + } + + void removeRows() + { + const Side side = currentSide(); + QList rows; + for (const QModelIndex &index : m_table[side]->selectionModel()->selectedRows()) + rows << index.row(); + std::sort(rows.rbegin(), rows.rend()); + Spec &spec = m_wizard->editableSpec(); + const bool numbered = hasNumberedPins(spec); + for (int row : rows) + spec.sides[side].removeAt(row); + if (numbered) numberPins(spec); + rebuildAll(); + changed(); + } + + void move(int step) + { + const Side side = currentSide(); + Slots &list = m_wizard->editableSpec().sides[side]; + const int row = m_table[side]->currentRow(); + const int to = row + step; + if (row < 0 || to < 0 || to >= list.size()) + return; + list.swapItemsAt(row, to); + rebuildTable(side); + updatePattern(side); + m_table[side]->setCurrentCell(to, 0); + changed(); + } + + void pasteTable() + { + const PasteResult result = parsePastedTable(QApplication::clipboard()->text()); + if (!result.rows) + { + m_paste_report->setText(tr("Nothing to paste: copy rows of side, name, function " + "from a spreadsheet or a CSV file first.")); + m_paste_report->show(); + return; + } + Spec &spec = m_wizard->editableSpec(); + for (int side = 0 ; side < SideCount ; ++side) + if (result.has_side[side]) spec.sides[side] = result.sides[side]; + rebuildAll(); + changed(); + if (result.bad_rows.isEmpty()) { + m_paste_report->setText(tr("Pasted %n row(s).", "", result.rows)); + } else { + m_paste_report->setText(tr("Pasted %1 rows; skipped these, which could not be read: %2") + .arg(result.rows) + .arg(result.bad_rows.join(QStringLiteral(" · ")))); + } + m_paste_report->show(); + } + + void changed() + { + m_wizard->specChanged(); + emit completeChanged(); + } + + GenericDeviceWizard *m_wizard; + QTabWidget *m_tabs; + QLineEdit *m_pattern[SideCount]; + QLabel *m_error[SideCount]; + QTableWidget *m_table[SideCount]; + QLabel *m_paste_report; +}; + +/** + @brief The GenericDeviceInformationPage class + Page 3: the name of the symbol and the element information. +*/ +class GenericDeviceInformationPage : public QWizardPage +{ + Q_OBJECT + + public: + explicit GenericDeviceInformationPage(GenericDeviceWizard *wizard) : + QWizardPage(wizard), + m_wizard(wizard) + { + setTitle(tr("Information")); + setSubTitle(tr("The name is how the symbol is listed in the project's " + "collection; the rest can be changed later on the placed device.")); + + m_name = new QLineEdit(); + m_name->setPlaceholderText(tr("Generic device")); + m_label = new QLineEdit(); + m_label->setPlaceholderText(tr("e.g. -U1")); + m_manufacturer = new QLineEdit(); + m_reference = new QLineEdit(); + m_description = new QLineEdit(); + m_show_reference = new QCheckBox(tr("Show the manufacturer reference in the box")); + + auto *form = new QFormLayout(this); + form->addRow(tr("Name:"), m_name); + form->addRow(tr("Label:"), m_label); + form->addRow(tr("Manufacturer:"), m_manufacturer); + form->addRow(tr("Manufacturer reference:"), m_reference); + form->addRow(tr("Description:"), m_description); + form->addRow(m_show_reference); + + auto bind = [this](QLineEdit *edit, QString Spec::*field) { + connect(edit, &QLineEdit::textChanged, this, [this, field](const QString &text) { + m_wizard->editableSpec().*field = text; + m_wizard->specChanged(); + }); + }; + bind(m_name, &Spec::name); + bind(m_label, &Spec::label); + bind(m_manufacturer, &Spec::manufacturer); + bind(m_reference, &Spec::reference); + bind(m_description, &Spec::description); + connect(m_show_reference, &QCheckBox::toggled, this, [this](bool on) { + m_wizard->editableSpec().show_reference = on; + m_wizard->specChanged(); + }); + } + + void initializePage() override + { + const Spec &spec = m_wizard->editableSpec(); + for (auto pair : {qMakePair(m_name, spec.name), qMakePair(m_label, spec.label), + qMakePair(m_manufacturer, spec.manufacturer), + qMakePair(m_reference, spec.reference), + qMakePair(m_description, spec.description)}) { + QSignalBlocker blocker(pair.first); + pair.first->setText(pair.second); + } + QSignalBlocker blocker(m_show_reference); + m_show_reference->setChecked(spec.show_reference); + } + + void cleanupPage() override {} + + bool isComplete() const override + { + return m_wizard->editableSpec().terminalCount() > 0; + } + + private: + GenericDeviceWizard *m_wizard; + QLineEdit *m_name; + QLineEdit *m_label; + QLineEdit *m_manufacturer; + QLineEdit *m_reference; + QLineEdit *m_description; + QCheckBox *m_show_reference; +}; + +/** + @brief GenericDeviceWizard::GenericDeviceWizard + @param fonts : the fonts the folio draws names and labels with, so the + box is sized for them +*/ +GenericDeviceWizard::GenericDeviceWizard(const Fonts &fonts, QWidget *parent) : + QWizard(parent), + m_fonts(fonts) +{ + setWindowTitle(tr("Generic device")); + setOption(QWizard::HaveFinishButtonOnEarlyPages); + setOption(QWizard::NoBackButtonOnStartPage); + + //Something to see on opening: two numbered terminals left and right + m_spec.sides[Left] = GenericDevice::resize({}, 2, 0); + m_spec.sides[Right] = GenericDevice::resize({}, 2, 0); + numberPins(m_spec); + + m_preview = new GenericDevicePreview(this); + setSideWidget(m_preview); + + setPage(LayoutPageId, new GenericDeviceLayoutPage(this)); + setPage(TerminalsPageId, new GenericDeviceTerminalsPage(this)); + setPage(InformationPageId, new GenericDeviceInformationPage(this)); + resize(960, 600); + specChanged(); +} + +/** + @return the device as filled in, with a name even if none was typed +*/ +Spec GenericDeviceWizard::spec() const +{ + Spec spec = m_spec; + spec.name = spec.name.trimmed(); + if (spec.name.isEmpty()) + spec.name = tr("Generic device"); + return spec; +} + +void GenericDeviceWizard::specChanged() +{ + m_preview->setSpec(m_spec, m_fonts); + emit specUpdated(); +} + +#include "genericdevicewizard.moc" diff --git a/sources/genericdevice/genericdevicewizard.h b/sources/genericdevice/genericdevicewizard.h new file mode 100644 index 000000000..12e77fd67 --- /dev/null +++ b/sources/genericdevice/genericdevicewizard.h @@ -0,0 +1,61 @@ +/* + 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 . +*/ +// SPDX-License-Identifier: GPL-2.0-or-later +#ifndef GENERICDEVICEWIZARD_H +#define GENERICDEVICEWIZARD_H + +#include "genericdevice.h" + +#include + +class GenericDevicePreview; + +/** + @brief The GenericDeviceWizard class + Three pages, layout, terminals and information, that fill one + GenericDevice::Spec, with a preview of the box beside every page. + Finish is offered from the first page: a box with numbered pins needs + nothing more. +*/ +class GenericDeviceWizard : public QWizard +{ + Q_OBJECT + + public: + enum { LayoutPageId, TerminalsPageId, InformationPageId }; + + GenericDeviceWizard(const GenericDevice::Fonts &fonts, + QWidget *parent = nullptr); + + GenericDevice::Spec spec() const; + GenericDevice::Spec &editableSpec() { return m_spec; } + const GenericDevice::Fonts &fonts() const { return m_fonts; } + + /// Redraw the preview and tell the pages the spec changed. + void specChanged(); + + signals: + void specUpdated(); + + private: + GenericDevice::Spec m_spec; + GenericDevice::Fonts m_fonts; + GenericDevicePreview *m_preview = nullptr; +}; + +#endif // GENERICDEVICEWIZARD_H diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index ae44dbcd5..02538c4dd 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -1242,6 +1242,7 @@ void QETDiagramEditor::setUpActions() QAction *add_path = m_add_item_actions_group.addAction(QET::Icons::PartBezier, tr("Add a curve")); 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…")); add_text ->setStatusTip(tr("Adds a text field to the current sheet")); add_image ->setStatusTip(tr("Add an image to the current sheet")); @@ -1256,6 +1257,7 @@ void QETDiagramEditor::setUpActions() add_path ->setStatusTip(tr("Adds a Bézier curve to the current sheet")); 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")); add_text ->setData(QStringLiteral("text")); add_image ->setData(QStringLiteral("image")); @@ -1270,6 +1272,7 @@ void QETDiagramEditor::setUpActions() add_path ->setData(QStringLiteral("path")); add_fillet ->setData(QStringLiteral("fillet")); add_terminal_strip->setData(QStringLiteral("terminal_strip")); + add_generic_device->setData(QStringLiteral("generic_device")); add_text->setCheckable(true); add_line->setCheckable(true); @@ -2321,6 +2324,10 @@ void QETDiagramEditor::addItemGroupTriggered(QAction *action) { diagram_event = new DiagramEventAddText(d); } + else if (value == QLatin1String("generic_device")) + { + currentDiagramView()->addGenericDevice(); + } else if (value == QLatin1String("terminal_strip")) { const auto diagram_view{currentDiagramView()}; diff --git a/sources/qeticons.cpp b/sources/qeticons.cpp index 4b3893665..8436b77a9 100644 --- a/sources/qeticons.cpp +++ b/sources/qeticons.cpp @@ -232,6 +232,7 @@ namespace QET { QIcon listDrawings; QIcon AutoNum; QIcon TerminalStrip; + QIcon GenericDevice; QIcon QETManual; QIcon QETDonate; QIcon QETDownload; @@ -592,6 +593,7 @@ void QET::Icons::initIcons() TableOfContent = QIcon::fromTheme("table-of-content"); Terminal = QIcon::fromTheme("terminal"); TerminalStrip = QIcon::fromTheme("terminalstrip"); + GenericDevice = QIcon::fromTheme("generic-device"); TitleBlock = QIcon::fromTheme("label"); TitleBlockBottom = QIcon::fromTheme("titleblock-bottom"); TitleBlockRight = QIcon::fromTheme("titleblock-right"); diff --git a/sources/qeticons.h b/sources/qeticons.h index fb14114e3..aec05c184 100644 --- a/sources/qeticons.h +++ b/sources/qeticons.h @@ -240,6 +240,7 @@ namespace QET { extern QIcon listDrawings; extern QIcon AutoNum; extern QIcon TerminalStrip; + extern QIcon GenericDevice; extern QIcon QETManual; extern QIcon QETDonate; extern QIcon QETDownload; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 066b99144..8653c86a4 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -185,6 +185,15 @@ add_test(NAME tst_symbolscale COMMAND tst_symbolscale) target_include_directories(tst_symbolscale PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_symbolscale PRIVATE Qt::Test) +# genericdevice.cpp is pure: the generic device wizard's layout, pattern +# lines, groups and pin numbers, and the symbol it writes. +add_executable(tst_genericdevice tst_genericdevice.cpp + ${QET_DIR}/sources/genericdevice/genericdevice.cpp) +add_test(NAME tst_genericdevice COMMAND tst_genericdevice) +set_tests_properties(tst_genericdevice PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_genericdevice PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_genericdevice PRIVATE Qt::Test Qt::Gui Qt::Xml) + # configprofile.h is header-only: which settings a saved configuration # profile carries, and how loading one replaces the live settings. # borderproperties.cpp: the real writer of the folio size defaults, to check diff --git a/tests/qttest/tst_genericdevice.cpp b/tests/qttest/tst_genericdevice.cpp new file mode 100644 index 000000000..d4637799b --- /dev/null +++ b/tests/qttest/tst_genericdevice.cpp @@ -0,0 +1,502 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include "genericdevice/genericdevice.h" + +using namespace GenericDevice; + +namespace { + + /// QElectroTech's default diagram font, at the sizes the wizard uses +Fonts fonts() +{ + QFont names(QStringLiteral("Liberation Sans")); + names.setPointSizeF(7); + QFont label(QStringLiteral("Liberation Sans")); + label.setPointSizeF(9); + return {names, label}; +} + +QStringList names(const Slots &slot_list) +{ + QStringList list; + for (const Slot &s : slot_list) + list << (s.kind == Slot::Terminal ? s.name : s.kind == Slot::Gap ? QStringLiteral("_") + : QStringLiteral("~")); + return list; +} + +Slots parse(const QString &pattern) +{ + const PatternResult r = expandPattern(pattern); + Q_ASSERT(r.ok()); + return r.list; +} + + /// The VSD of the scope document, §3 +Spec vsd() +{ + Spec spec; + spec.name = QStringLiteral("VSD"); + spec.label = QStringLiteral("-U1"); + spec.manufacturer = QStringLiteral("ACME"); + spec.reference = QStringLiteral("VF-400"); + spec.sides[Left] = parse(QStringLiteral("L1,L2,L3,,PE")); + spec.sides[Right] = parse(QStringLiteral("U,V,W,,PE")); + spec.sides[Bottom] = parse(QStringLiteral("DI{4},COM,+24V")); + return spec; +} + +QString toText(const QDomElement &e) +{ + QString out; + QTextStream stream(&out); + e.save(stream, 1); + return out; +} + +} // namespace + +class tst_genericdevice : public QObject +{ + Q_OBJECT + +private slots: + void expandPattern_data() + { + QTest::addColumn("pattern"); + QTest::addColumn("expected"); + QTest::newRow("plain") << "L1" << QStringList{"L1"}; + QTest::newRow("gap") << "L3,,PE" << QStringList{"L3", "_", "PE"}; + QTest::newRow("count") << "In{5}" << QStringList{"In1", "In2", "In3", "In4", "In5"}; + QTest::newRow("from zero") << "Q{z5}" << QStringList{"Q0", "Q1", "Q2", "Q3", "Q4"}; + QTest::newRow("range") << "X{3-8}" << QStringList{"X3", "X4", "X5", "X6", "X7", "X8"}; + QTest::newRow("prefix") << "X1:{4}" << QStringList{"X1:1", "X1:2", "X1:3", "X1:4"}; + QTest::newRow("suffix") << "{2}.1" << QStringList{"1.1", "2.1"}; + QTest::newRow("spaces") << " L1 , L2 " << QStringList{"L1", "L2"}; + QTest::newRow("empty") << "" << QStringList{}; + QTest::newRow("vsd bottom") << "DI{4},COM,+24V" + << QStringList{"DI1", "DI2", "DI3", "DI4", "COM", "+24V"}; + } + void expandPattern() + { + QFETCH(QString, pattern); + QFETCH(QStringList, expected); + const PatternResult r = GenericDevice::expandPattern(pattern); + QVERIFY2(r.ok(), qPrintable(r.error)); + QCOMPARE(names(r.list), expected); + } + + void patternErrors_data() + { + QTest::addColumn("pattern"); + QTest::newRow("two braces") << "X{2}{3}"; + QTest::newRow("unclosed") << "X{2"; + QTest::newRow("letters") << "X{a}"; + QTest::newRow("z with range") << "X{z1-3}"; + QTest::newRow("backwards range") << "X{5-3}"; + QTest::newRow("zero count") << "X{0}"; + QTest::newRow("over the cap") << "X{201}"; + } + void patternErrors() + { + QFETCH(QString, pattern); + const PatternResult r = GenericDevice::expandPattern(pattern); + QVERIFY(!r.ok()); + QVERIFY(r.list.isEmpty()); + } + + void capIsInclusive() + { + QCOMPARE(GenericDevice::expandPattern(QStringLiteral("X{200}")).list.size(), 200); + } + + void toPatternFoldsRuns() + { + bool ok = false; + QCOMPARE(toPattern(parse(QStringLiteral("In{5},COM")), &ok), QStringLiteral("In{5},COM")); + QVERIFY(ok); + QCOMPARE(toPattern(parse(QStringLiteral("Q{z4}"))), QStringLiteral("Q{z4}")); + QCOMPARE(toPattern(parse(QStringLiteral("X{3-8}"))), QStringLiteral("X{3-8}")); + QCOMPARE(toPattern(parse(QStringLiteral("L1,L2,L3,,PE"))), QStringLiteral("L{3},,PE")); + //Two in a row stay as written + QCOMPARE(toPattern(parse(QStringLiteral("A1,A2"))), QStringLiteral("A1,A2")); + //Leading zeros are not numbers to fold + QCOMPARE(toPattern(parse(QStringLiteral("01,02,03"))), QStringLiteral("01,02,03")); + } + + void toPatternRoundTrips_data() + { + QTest::addColumn("pattern"); + for (const char *p : {"L1,L2,L3,,PE", "DI{4},COM,+24V", "Q{z5}", "X1:{4}", ",A,,B,", + "1,2,3,4,5", "X9,X10,X11", "A,A,A"}) + QTest::newRow(p) << QString::fromLatin1(p); + } + void toPatternRoundTrips() + { + QFETCH(QString, pattern); + const Slots original = parse(pattern); + QCOMPARE(names(parse(toPattern(original))), names(original)); + } + + void toPatternFlagsWhatItCannotSay() + { + bool ok = true; + Slots s{Slot::terminal(QStringLiteral("A,B"))}; + toPattern(s, &ok); + QVERIFY(!ok); + ok = true; + toPattern({Slot::terminal(QStringLiteral("{x}"))}, &ok); + QVERIFY(!ok); + } + + void applyPatternKeepsWhatTheTableHolds() + { + Slots current = parse(QStringLiteral("1,2")); + current[0].function = QStringLiteral("OUT1"); + current[0].uuid = QUuid::createUuid(); + current[1].type = QStringLiteral("Inner"); + const Slots result = applyPattern(current, parse(QStringLiteral("A,,B,C"))); + QCOMPARE(names(result), (QStringList{"A", "_", "B", "C"})); + QCOMPARE(result[0].function, QStringLiteral("OUT1")); + QCOMPARE(result[0].uuid, current[0].uuid); + QCOMPARE(result[2].type, QStringLiteral("Inner")); + QVERIFY(result[3].uuid.isNull()); + } + + void groupEvery_data() + { + QTest::addColumn("count"); + QTest::addColumn("n"); + QTest::addColumn("expected"); + QTest::newRow("9 by 4") << 9 << 4 << "1 2 3 4 _ 5 6 7 8 _ 9"; + QTest::newRow("8 by 4") << 8 << 4 << "1 2 3 4 _ 5 6 7 8"; + QTest::newRow("none") << 3 << 0 << "1 2 3"; + QTest::newRow("by 1") << 3 << 1 << "1 _ 2 _ 3"; + QTest::newRow("n = count") << 4 << 4 << "1 2 3 4"; + QTest::newRow("n > count") << 3 << 8 << "1 2 3"; + } + void groupEvery() + { + QFETCH(int, count); + QFETCH(int, n); + QFETCH(QString, expected); + const Slots s = parse(QStringLiteral("{%1}").arg(count)); + QCOMPARE(names(GenericDevice::groupEvery(s, n)).join(' '), expected); + } + + void groupEveryReplacesHandGaps() + { + QCOMPARE(names(GenericDevice::groupEvery(parse(QStringLiteral("1,,2,3,,,4")), 2)).join(' '), + QStringLiteral("1 2 _ 3 4")); + } + + void resizeKeepsTypedNames() + { + const Slots s = parse(QStringLiteral("L1,L2,,PE")); + QCOMPARE(names(resize(s, 5, 0)).join(' '), QStringLiteral("L1 L2 _ PE ")); + QCOMPARE(names(resize(s, 2, 0)).join(' '), QStringLiteral("L1 L2")); + QCOMPARE(names(resize(s, 0, 0)), QStringList()); + QCOMPARE(names(resize(s, 5, 2)).join(' '), QStringLiteral("L1 L2 _ PE _ ")); + } + + void lineUpGroups() + { + Slots a = parse(QStringLiteral("L1,L2,L3,N,,PE")); + Slots b = parse(QStringLiteral("U,V,W,,PE")); + GenericDevice::lineUpGroups(a, b); + QCOMPARE(names(a).join(' '), QStringLiteral("L1 L2 L3 N _ PE")); + QCOMPARE(names(b).join(' '), QStringLiteral("U V W ~ _ PE")); + QCOMPARE(a.size(), b.size()); + } + + void lineUpLeavesEqualAndUngroupedSidesAlone() + { + Slots a = parse(QStringLiteral("L1,L2,L3,,PE")); + Slots b = parse(QStringLiteral("U,V,W,,PE")); + GenericDevice::lineUpGroups(a, b); + QCOMPARE(names(b).join(' '), QStringLiteral("U V W _ PE")); + + //No gap on one side: nothing faces the other's groups + Slots c = parse(QStringLiteral("1,2,3,4,5")); + Slots d = parse(QStringLiteral("A,,B")); + GenericDevice::lineUpGroups(c, d); + QCOMPARE(names(c).join(' '), QStringLiteral("1 2 3 4 5")); + QCOMPARE(names(d).join(' '), QStringLiteral("A _ B")); + + //More groups on one side: the extra ones simply follow + Slots e = parse(QStringLiteral("A,,B,B2,,C")); + Slots f = parse(QStringLiteral("X,X2,,Y")); + GenericDevice::lineUpGroups(e, f); + QCOMPARE(names(e).join(' '), QStringLiteral("A ~ _ B B2 _ C")); + QCOMPARE(names(f).join(' '), QStringLiteral("X X2 _ Y")); + } + + void numberPinsCounterClockwise() + { + Spec spec; + spec.sides[Left] = resize({}, 3, 0); + spec.sides[Bottom] = {Slot::terminal({}), Slot::gap(), Slot::terminal({})}; + spec.sides[Right] = resize({}, 2, 0); + spec.sides[Top] = resize({}, 2, 0); + numberPins(spec); + QCOMPARE(names(spec.sides[Left]).join(' '), QStringLiteral("1 2 3")); + QCOMPARE(names(spec.sides[Bottom]).join(' '), QStringLiteral("4 _ 5")); + QCOMPARE(names(spec.sides[Right]).join(' '), QStringLiteral("7 6")); + QCOMPARE(names(spec.sides[Top]).join(' '), QStringLiteral("9 8")); + QVERIFY(hasNumberedPins(spec)); + spec.sides[Top][0].name = QStringLiteral("PE"); + QVERIFY(!hasNumberedPins(spec)); + } + + void nameUnnamedLeavesTypedNames() + { + Spec spec; + spec.sides[Left] = {Slot::terminal(QStringLiteral("L1")), Slot::terminal({}), Slot::gap(), + Slot::terminal({})}; + nameUnnamed(spec); + QCOMPARE(names(spec.sides[Left]).join(' '), QStringLiteral("L1 2 _ 3")); + } + + void pasteTabsAndCommasReadAlike() + { + const QString tabs = QStringLiteral( + "side\tname\tfunction\n" + "L\tL1\n" "L\tL2\n" "L\tL3\n" "L\t\n" "L\tPE\n" + "right\tU\tmotor\n" "R\tV\n" "R\tW\n" "R\t\n" "R\tPE\n" + "bottom\tDI1\t\tInner\n"); + QString commas = tabs; + commas.replace('\t', ','); + for (const QString &text : {tabs, commas}) + { + const PasteResult r = parsePastedTable(text); + QVERIFY2(r.bad_rows.isEmpty(), qPrintable(r.bad_rows.join('|'))); + QCOMPARE(r.rows, 11); + QCOMPARE(names(r.sides[Left]).join(' '), QStringLiteral("L1 L2 L3 _ PE")); + QCOMPARE(r.sides[Right][0].function, QStringLiteral("motor")); + QCOMPARE(r.sides[Bottom][0].type, QStringLiteral("Inner")); + QVERIFY(r.has_side[Left] && r.has_side[Right] && r.has_side[Bottom]); + QVERIFY(!r.has_side[Top]); + } + } + + void pasteListsBadRows() + { + const PasteResult r = parsePastedTable(QStringLiteral( + "left,A\r\nmiddle,B\r\ntop,C,,Sideways\r\n\r\nbottom,D,f,Generic,extra\r\nT,E")); + QCOMPARE(r.rows, 2); + QCOMPARE(r.bad_rows, (QStringList{"middle,B", "top,C,,Sideways", "bottom,D,f,Generic,extra"})); + QCOMPARE(names(r.sides[Top]), QStringList{"E"}); + } + + /// Scope §3: the VSD body is 160 × 140 with the terminals packed + /// from the top and clear of the corners + void vsdSize() + { + const Layout l = layout(vsd(), fonts()); + QCOMPARE(l.body_width, 160); + QCOMPARE(l.body_height, 140); + QCOMPARE(l.terminals.size(), 14); + QCOMPARE(l.dividers.size(), 2); + QVERIFY(l.warnings.join(' ').contains(QStringLiteral("PE"))); + } + + void everythingOnTheGrid() + { + Spec spec = vsd(); + spec.sides[Top] = parse(QStringLiteral("X{3}")); + spec.extra_width = 3; + QDomDocument doc; + const QDomElement def = toDefinition(spec, fonts(), doc); + for (const char *a : {"width", "height", "hotspot_x", "hotspot_y"}) + QCOMPARE(def.attribute(a).toInt() % Grid, 0); + const QDomNodeList terminals = def.elementsByTagName(QStringLiteral("terminal")); + QCOMPARE(terminals.size(), 17); + for (int i = 0 ; i < terminals.size() ; ++i) { + const QDomElement t = terminals.at(i).toElement(); + QCOMPARE(qRound(t.attribute("x").toDouble()) % Grid, 0); + QCOMPARE(qRound(t.attribute("y").toDouble()) % Grid, 0); + QVERIFY(!QUuid(t.attribute("uuid")).isNull()); + } + } + + void lineUpPutsPeOnOneRow() + { + Spec spec; + spec.sides[Left] = parse(QStringLiteral("L1,L2,L3,N,,PE")); + spec.sides[Right] = parse(QStringLiteral("U,V,W,,PE")); + Layout l = layout(spec, fonts()); + QList pe; + for (const auto &t : l.terminals) + if (t.slot.name == QLatin1String("PE")) pe << t.point.y(); + QCOMPARE(pe.size(), 2); + QCOMPARE(pe[0], pe[1]); + //Both gaps marked, the padding not + QCOMPARE(l.dividers.size(), 2); + QCOMPARE(l.dividers[0].y1(), l.dividers[1].y1()); + + spec.line_up = false; + l = layout(spec, fonts()); + pe.clear(); + for (const auto &t : l.terminals) + if (t.slot.name == QLatin1String("PE")) pe << t.point.y(); + QVERIFY(pe[0] != pe[1]); + + spec.mark_gaps = false; + QVERIFY(layout(spec, fonts()).dividers.isEmpty()); + } + + void edgeCases() + { + Spec empty; + Layout l = layout(empty, fonts()); + //No terminals: the minimum, or as wide as the label needs + QVERIFY(l.body_width >= MinWidth); + QCOMPARE(l.body_width % Grid, 0); + QCOMPARE(l.body_height, MinHeight); + QVERIFY(l.terminals.isEmpty()); + + Spec one; + one.sides[Top] = parse(QStringLiteral("A")); + l = layout(one, fonts()); + QCOMPARE(l.terminals.size(), 1); + QCOMPARE(l.terminals[0].point.y(), qreal(-Stub)); + + Spec many; + many.sides[Left] = parse(QStringLiteral("{50}")); + l = layout(many, fonts()); + QCOMPARE(l.terminals.size(), 50); + QVERIFY(l.body_height >= 50 * many.pitch); + } + + void extraSizeOnlyAdds() + { + Spec spec = vsd(); + const Layout base = layout(spec, fonts()); + spec.extra_width = 2; + spec.extra_height = 1; + const Layout grown = layout(spec, fonts()); + QCOMPARE(grown.body_width, base.body_width + 20); + QCOMPARE(grown.body_height, base.body_height + 10); + spec.extra_width = -5; + QCOMPARE(layout(spec, fonts()).body_width, base.body_width); + } + + void definitionRoundTrip() + { + Spec spec = vsd(); + spec.sides[Right][0].function = QStringLiteral("motor"); + spec.sides[Left][1].type = QStringLiteral("Inner"); + spec.sides[Top] = parse(QStringLiteral("A,B")); + spec.extra_width = 2; + spec.extra_height = 1; + spec.pitch = 30; + + QDomDocument doc; + const QDomElement def = toDefinition(spec, fonts(), doc, QStringLiteral("0.100.0")); + Spec back; + QVERIFY(fromDefinition(def, fonts(), &back)); + + QCOMPARE(back.name, spec.name); + QCOMPARE(back.label, spec.label); + QCOMPARE(back.manufacturer, spec.manufacturer); + QCOMPARE(back.reference, spec.reference); + QCOMPARE(back.show_reference, true); + QCOMPARE(back.pitch, 30); + QCOMPARE(back.extra_width, 2); + QCOMPARE(back.extra_height, 1); + QVERIFY(back.mark_gaps); + QVERIFY(back.line_up); + for (int s = 0 ; s < SideCount ; ++s) + QCOMPARE(names(back.sides[s]), names(spec.sides[s])); + QCOMPARE(back.sides[Right][0].function, QStringLiteral("motor")); + QCOMPARE(back.sides[Left][1].type, QStringLiteral("Inner")); + + //Written again, the terminals keep their uuids and positions + QDomDocument doc2; + const QDomElement def2 = toDefinition(back, fonts(), doc2, QStringLiteral("0.100.0")); + const QDomNodeList t1 = def.elementsByTagName(QStringLiteral("terminal")); + const QDomNodeList t2 = def2.elementsByTagName(QStringLiteral("terminal")); + QCOMPARE(t2.size(), t1.size()); + for (int i = 0 ; i < t1.size() ; ++i) + for (const char *a : {"x", "y", "uuid", "name", "orientation", "type"}) + QCOMPARE(t2.at(i).toElement().attribute(a), t1.at(i).toElement().attribute(a)); + for (const char *a : {"width", "height", "hotspot_x", "hotspot_y"}) + QCOMPARE(def2.attribute(a), def.attribute(a)); + } + + void roundTripWithPaddingAndNoMarks() + { + Spec spec; + spec.name = QStringLiteral("X"); + spec.sides[Left] = parse(QStringLiteral("L1,L2,L3,N,,PE")); + spec.sides[Right] = parse(QStringLiteral("U,V,W,,PE")); + for (bool marks : {true, false}) + { + spec.mark_gaps = marks; + QDomDocument doc; + const QDomElement def = toDefinition(spec, fonts(), doc); + Spec back; + QVERIFY(fromDefinition(def, fonts(), &back)); + QDomDocument doc2; + const QDomElement def2 = toDefinition(back, fonts(), doc2); + const QDomNodeList t1 = def.elementsByTagName(QStringLiteral("terminal")); + const QDomNodeList t2 = def2.elementsByTagName(QStringLiteral("terminal")); + QCOMPARE(t2.size(), t1.size()); + for (int i = 0 ; i < t1.size() ; ++i) + QCOMPARE(t2.at(i).toElement().attribute("y"), t1.at(i).toElement().attribute("y")); + QCOMPARE(def2.elementsByTagName("line").size(), def.elementsByTagName("line").size()); + } + } + + void foreignDefinitionsAreRefused() + { + QDomDocument doc; + QDomElement def = toDefinition(vsd(), fonts(), doc); + Spec back; + QVERIFY(fromDefinition(def, fonts(), &back)); + + //A circle drawn on it in the symbol editor + QDomElement description = def.firstChildElement(QStringLiteral("description")); + QDomElement extra = doc.createElement(QStringLiteral("ellipse")); + description.appendChild(extra); + QVERIFY(!fromDefinition(def, fonts(), &back)); + description.removeChild(extra); + + //A terminal moved off its edge + QDomElement t = description.firstChildElement(QStringLiteral("terminal")); + t.setAttribute(QStringLiteral("x"), 5); + QVERIFY(!fromDefinition(def, fonts(), &back)); + } + + /// The output is stable apart from the uuids + void definitionIsDeterministic() + { + Spec spec = vsd(); + for (Slots &side : spec.sides) + for (Slot &s : side) + s.uuid = QUuid::createUuid(); + QDomDocument a, b; + QString one = toText(toDefinition(spec, fonts(), a)); + QString two = toText(toDefinition(spec, fonts(), b)); + static const QRegularExpression uuid(QStringLiteral("uuid=\"\\{[^}]+\\}\"")); + static const QRegularExpression part_uuid(QStringLiteral("")); + //Only parts and the definition get fresh uuids; the terminals keep theirs + QCOMPARE(one.count(QStringLiteral("split('\n'); + QStringList kept; + for (QString line : lines) { + if (!line.contains(QLatin1String("