From 2adc58c1c075f68878bb85b23a8017797ccf90a2 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 21 Sep 2026 19:00:04 +1200 Subject: [PATCH] Let a script add the text and shapes a folio carries The drawing furniture beside the circuit: a free-standing note, a line, a rectangle, an ellipse, a polygon. texts() addText() setTextContent() setTextColor() setTextRotation() deleteText() shapes() addShape() deleteShape() Added with the same AddGraphicsObjectCommand the corresponding GUI tools use, and changed through the plainText/color/rotation properties those items already publish, so a script's note undoes like a hand-placed one. These are addressed by index into a listing sorted by position, reading order, because they have no better identity: unlike an element they carry no uuid, and unlike a conductor they have no terminal to be named by. Position is what they have and it persists, so the ordering survives a save and reload -- verified by listing before and after, including a rotated text whose bounding box moves. It does not survive adding or deleting one: indexes after that point shift the way a list's do, which is why texts() and shapes() exist rather than a caller keeping a handle. The sort is on sceneBoundingRect(), not pos(). A QetShapeItem keeps its geometry in its line/rect/polygon and leaves pos() at the origin, so sorting on pos() put three shapes drawn in three different places all at (0, 0) and made every shape index refer to whichever the set yielded first -- which is what the first version of this did, and the test that caught it was asking for three shapes and getting index 0 three times. Path is deliberately not offered: it is built by successive clicks and has no two-point form to give here. Verified headlessly: three texts added bottom-up and listed in reading order, edited, recoloured, rotated, one deleted; three shapes added, listed with their real geometry, the middle one deleted and the right one gone; unknown shape name, invalid colour and out-of-range index all decline with a reason. Saved, reloaded, both listings identical. Qt 6.10.2, build clean, ctest matches master, qet-lint clean on the generated project, qet-coherence-check clean on the example corpus. Co-Authored-By: Claude Opus 5 (1M context) --- sources/scripting/qetscriptapi.cpp | 251 +++++++++++++++++++++++++++++ sources/scripting/qetscriptapi.h | 33 ++++ 2 files changed, 284 insertions(+) diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 0f0430e43..747176a59 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -30,6 +30,8 @@ #include "../qetproject.h" #include "../qetresult.h" #include "../qetgraphicsitem/conductor.h" +#include "../qetgraphicsitem/independenttextitem.h" +#include "../qetgraphicsitem/qetshapeitem.h" #include "../qetgraphicsitem/terminal.h" #include "../qetinformation.h" #include "../titleblockproperties.h" @@ -913,6 +915,255 @@ bool QetScriptApi::unlinkElement(int folioIndex, const QString &elementUuid) return element->linkedElements().isEmpty(); } +namespace { + +/** + Reading order for items that have no identity but their position: + top to bottom, then left to right. + + On sceneBoundingRect(), not pos(): a QetShapeItem keeps its geometry in + its line/rect/polygon, and its pos() stays at the origin, so three + shapes drawn in different places all sort as (0, 0) and the ordering + collapses -- which is exactly what the first version of this did, and + it made every shape index refer to whichever one the set happened to + yield first. The scene bounding rect reflects where the item actually + is for both kinds. +*/ +template +QList sortedByPosition(const QSet &items) +{ + QList list(items.cbegin(), items.cend()); + std::sort(list.begin(), list.end(), [](T *a, T *b) { + const QPointF pa = a->sceneBoundingRect().topLeft(); + const QPointF pb = b->sceneBoundingRect().topLeft(); + if (pa.y() != pb.y()) return pa.y() < pb.y(); + if (pa.x() != pb.x()) return pa.x() < pb.x(); + // Two items genuinely at the same point still need a total order, + // or std::sort's result depends on the set's iteration order. + return a < b; + }); + return list; +} + +} // namespace + +QList QetScriptApi::sortedTexts(int folioIndex) const +{ + if (!m_project) return {}; + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return {}; + DiagramContent content(diagrams.at(folioIndex), false); + return sortedByPosition(content.m_text_fields); +} + +QList QetScriptApi::sortedShapes(int folioIndex) const +{ + if (!m_project) return {}; + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return {}; + DiagramContent content(diagrams.at(folioIndex), false); + return sortedByPosition(content.m_shapes); +} + +IndependentTextItem *QetScriptApi::findText(int folioIndex, int textIndex, const QString &caller) +{ + const QList list = sortedTexts(folioIndex); + if (textIndex < 0 || textIndex >= list.count()) { + log(QStringLiteral("qet.%1: folio %2 has %3 independent text(s), no index %4") + .arg(caller).arg(folioIndex).arg(list.count()).arg(textIndex)); + return nullptr; + } + return list.at(textIndex); +} + +QStringList QetScriptApi::texts(int folioIndex) const +{ + QStringList out; + const QList list = sortedTexts(folioIndex); + for (int i = 0 ; i < list.count() ; ++i) + { + IndependentTextItem *t = list.at(i); + const QPointF at = t->sceneBoundingRect().topLeft(); + out << QStringLiteral("%1: '%2' at (%3, %4)") + .arg(i) + .arg(t->toPlainText()) + .arg(at.x()) + .arg(at.y()); + } + return out; +} + +/** + @brief QetScriptApi::addText + Place a free-standing text, as the "add text" tool does. + @return its index in texts(), or -1 +*/ +int QetScriptApi::addText(int folioIndex, const QString &text, double x, double y) +{ + if (!m_project) return -1; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.addText: project is read-only")); + return -1; + } + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return -1; + Diagram *diagram = diagrams.at(folioIndex); + + auto *item = new IndependentTextItem(); + item->setPlainText(text); + diagram->undoStack().push(new AddGraphicsObjectCommand(item, diagram, QPointF(x, y))); + return sortedTexts(folioIndex).indexOf(item); +} + +bool QetScriptApi::setTextContent(int folioIndex, int textIndex, const QString &text) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.setTextContent: project is read-only")); + return false; + } + IndependentTextItem *item = findText(folioIndex, textIndex, QStringLiteral("setTextContent")); + if (!item) return false; + if (item->toPlainText() == text) return true; + + auto *cmd = new QPropertyUndoCommand(item, "plainText", + QVariant(item->toPlainText()), QVariant(text)); + cmd->setText(QObject::tr("Modifier un texte")); + m_project->undoStack()->push(cmd); + return true; +} + +bool QetScriptApi::setTextColor(int folioIndex, int textIndex, const QString &color) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.setTextColor: project is read-only")); + return false; + } + const QColor new_color(color); + if (!new_color.isValid()) { + log(QStringLiteral("qet.setTextColor: '%1' is not a valid colour").arg(color)); + return false; + } + IndependentTextItem *item = findText(folioIndex, textIndex, QStringLiteral("setTextColor")); + if (!item) return false; + if (item->color() == new_color) return true; + + auto *cmd = new QPropertyUndoCommand(item, "color", + QVariant(item->color()), QVariant(new_color)); + cmd->setText(QObject::tr("Modifier la couleur d'un texte")); + m_project->undoStack()->push(cmd); + return true; +} + +bool QetScriptApi::setTextRotation(int folioIndex, int textIndex, double angle) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.setTextRotation: project is read-only")); + return false; + } + IndependentTextItem *item = findText(folioIndex, textIndex, QStringLiteral("setTextRotation")); + if (!item) return false; + + auto *cmd = new QPropertyUndoCommand(item, "rotation", + QVariant(item->rotation()), + QVariant(item->rotation() + angle)); + cmd->setText(QObject::tr("Pivoter un texte")); + m_project->undoStack()->push(cmd); + return true; +} + +bool QetScriptApi::deleteText(int folioIndex, int textIndex) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.deleteText: project is read-only")); + return false; + } + IndependentTextItem *item = findText(folioIndex, textIndex, QStringLiteral("deleteText")); + if (!item) return false; + Diagram *diagram = m_project->diagrams().at(folioIndex); + + DiagramContent content; + content.m_text_fields << item; + diagram->undoStack().push(new DeleteQGraphicsItemCommand(diagram, content)); + return true; +} + +QStringList QetScriptApi::shapes(int folioIndex) const +{ + QStringList out; + const QList list = sortedShapes(folioIndex); + for (int i = 0 ; i < list.count() ; ++i) + { + QetShapeItem *shape = list.at(i); + const QRectF r = shape->sceneBoundingRect(); + out << QStringLiteral("%1: %2 (%3, %4) to (%5, %6)") + .arg(i) + .arg(shape->name()) + .arg(r.left()).arg(r.top()).arg(r.right()).arg(r.bottom()); + } + return out; +} + +/** + @brief QetScriptApi::addShape + Draw a line, rectangle, ellipse or polygon, as the shape tools do. + Path is deliberately absent: it is built by successive clicks and has + no two-point form to give here. + @return the shape's index in shapes(), or -1 +*/ +int QetScriptApi::addShape(int folioIndex, const QString &type, + double x1, double y1, double x2, double y2) +{ + if (!m_project) return -1; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.addShape: project is read-only")); + return -1; + } + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return -1; + + QetShapeItem::ShapeType shape_type; + const QString t = type.toLower(); + if (t == QLatin1String("line")) shape_type = QetShapeItem::Line; + else if (t == QLatin1String("rectangle")) shape_type = QetShapeItem::Rectangle; + else if (t == QLatin1String("ellipse")) shape_type = QetShapeItem::Ellipse; + else if (t == QLatin1String("polygon")) shape_type = QetShapeItem::Polygon; + else { + log(QStringLiteral("qet.addShape: unknown shape '%1'; expected line, " + "rectangle, ellipse or polygon").arg(type)); + return -1; + } + + Diagram *diagram = diagrams.at(folioIndex); + auto *shape = new QetShapeItem(QPointF(x1, y1), QPointF(x2, y2), shape_type); + diagram->undoStack().push(new AddGraphicsObjectCommand(shape, diagram, QPointF(0, 0))); + return sortedShapes(folioIndex).indexOf(shape); +} + +bool QetScriptApi::deleteShape(int folioIndex, int shapeIndex) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.deleteShape: project is read-only")); + return false; + } + const QList list = sortedShapes(folioIndex); + if (shapeIndex < 0 || shapeIndex >= list.count()) { + log(QStringLiteral("qet.deleteShape: folio %1 has %2 shape(s), no index %3") + .arg(folioIndex).arg(list.count()).arg(shapeIndex)); + return false; + } + Diagram *diagram = m_project->diagrams().at(folioIndex); + + DiagramContent content; + content.m_shapes << list.at(shapeIndex); + diagram->undoStack().push(new DeleteQGraphicsItemCommand(diagram, content)); + return true; +} + int QetScriptApi::addFolio() { if (!m_project) return -1; diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index ef6673772..816cbb9ad 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -27,6 +27,8 @@ class DiagramView; class Element; class Terminal; class Conductor; +class IndependentTextItem; +class QetShapeItem; /** @brief The QetScriptApi class @@ -116,6 +118,21 @@ class Conductor; conductor is ambiguous and is refused rather than guessed at -- which in practice means a potential is addressed from one of its leaf terminals, not from the hub several conductors meet at. + - @b Text and @b shapes: the drawing furniture a folio carries beside + its circuit -- a free-standing note, a line, a rectangle, an ellipse + -- added with the same AddGraphicsObjectCommand the corresponding GUI + tools use, and changed through the plainText/color/rotation + properties those items already publish. + + These are addressed by @b index into a listing sorted by position + (top to bottom, then left to right), because unlike an element they + carry no uuid and unlike a conductor they have no terminal to be + named by. Position is the only identity they have, and it persists, + so the ordering is the same after a save and reload -- verified + against exactly that. What it is @b not stable against is adding or + deleting one: indexes after the affected position shift, the way a + list's do. Call texts() or shapes() again rather than holding an + index across an edit that adds or removes one. - @b Navigating and @b messaging: select an element, zoom the active view, and show the user a message. Deliberately narrow: selection and messaging work with no view at all (headless `--run`); zoom is a no-op @@ -211,6 +228,19 @@ class QetScriptApi : public QObject int folioIndexB, const QString &elementUuidB); Q_INVOKABLE bool unlinkElement(int folioIndex, const QString &elementUuid); + // -- independent text and drawing shapes -- + Q_INVOKABLE QStringList texts(int folioIndex) const; + Q_INVOKABLE int addText(int folioIndex, const QString &text, double x, double y); + Q_INVOKABLE bool setTextContent(int folioIndex, int textIndex, const QString &text); + Q_INVOKABLE bool setTextColor(int folioIndex, int textIndex, const QString &color); + Q_INVOKABLE bool setTextRotation(int folioIndex, int textIndex, double angle); + Q_INVOKABLE bool deleteText(int folioIndex, int textIndex); + + Q_INVOKABLE QStringList shapes(int folioIndex) const; + Q_INVOKABLE int addShape(int folioIndex, const QString &type, + double x1, double y1, double x2, double y2); + Q_INVOKABLE bool deleteShape(int folioIndex, int shapeIndex); + // -- folios -- Q_INVOKABLE int addFolio(); Q_INVOKABLE bool setFolioTitle(int folioIndex, const QString &title); @@ -238,6 +268,9 @@ class QetScriptApi : public QObject const QString &caller); Conductor *findConductor(int folioIndex, const QString &elementUuid, int terminalIndex, const QString &caller); + QList sortedTexts(int folioIndex) const; + QList sortedShapes(int folioIndex) const; + IndependentTextItem *findText(int folioIndex, int textIndex, const QString &caller); bool setInfoKey(int folioIndex, const QString &elementUuid, const QString &key, const QString &value, const QString &caller);