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