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("