Add geometry editing, undo integration, and navigation to scripting

Extends the scripting surface from the previous commit with exactly
the three things explicitly scoped out there, per follow-up direction:
editing geometry, undo integration, and driving the GUI -- the last
one narrowed to select/zoom/message after discussion, since "invoke
any menu action by name" would let a script trigger a modal
QDialog::exec() with nobody there to dismiss it, the same hang class
investigated for bugtracker #882.

## New capabilities

- addElement/setElementPosition/moveElement/deleteElement, through the
  same undo commands the GUI itself uses: AddGraphicsObjectCommand
  (the same one drag-from-collection-panel placement uses),
  QPropertyUndoCommand on the standard `pos` property, and
  DeleteQGraphicsItemCommand (refuses a non-deletable terminal, same
  as the Delete key).
- undo/redo/canUndo/canRedo against the project's real QUndoStack --
  the same one QETDiagramEditor's Ctrl+Z is wired to via
  undo_group.activeStack(), not a parallel mechanism.
- selectElement/deselectAll (scene state, no view required -- works
  headless), zoomFit/zoomToContent/zoomReset (need the active
  DiagramView, so false headless where there is nothing to zoom), and
  showMessage (a modal QET::QetMessageBox::information -- safe headless
  because non-interactive mode is already on for the whole process
  before any script runs).

## Two real bugs caught by testing this, not assumed away

1. save() was still going through the same reopen-from-disk path as
   every export method: it opened a *second*, unmodified copy of the
   project from its file on disk and rewrote that. addElement() and
   friends operate on the live in-memory project, so nothing they did
   ever reached the saved file -- an element counted correctly in
   memory and then silently vanished from the output. Fixed by having
   save() write m_project->toXml() directly, the only method that
   touches the live instance rather than a fresh copy of the file.

2. A script calling setElementPosition() then moveElement() on the
   same element produced a saved position that didn't match either
   call, and undo/redo didn't step through them independently. Traced
   to QPropertyUndoCommand::mergeWith() (pre-existing, not new):
   consecutive commands on the same object+property merge when their
   text() also matches, and both calls build the identical "Déplacer
   %1" text for a given element -- exactly the same collapsing
   dragging an item repeatedly gets. Not a bug in the new code; a
   wrong assumption in the first test. Re-verified against the
   correct, merge-aware expectation: add -> merged move -> undo (back
   to first position) -> undo (element removed) -> redo (element back)
   -> redo (merged move reapplied) landed at the exact predicted final
   position, read back from the saved XML.

## Verified

Qt6, build clean from a fresh reconfigure, ctest 6/6.

- Headless: addElement returns a real uuid and the count updates;
  select/set-position/move all report correctly; the merge-aware
  undo/redo/save round trip above, confirmed against the saved file's
  actual XML, not just in-memory counters.
- Corpus: the existing read-model smoke script re-run against all 24
  shipped example projects on the fixed binary, 0 failures.
- zoomFit correctly returns false headless (no view to act on),
  confirming the "narrowed GUI-driving" scope holds in code, not just
  in the doc comment.
- GUI: running the add-only script via "Exécuter un script..." marked
  the project [modifié] in the title bar, the same change-tracking
  path a manual edit goes through -- consistent with the undo command
  actually being pushed onto the project's real stack rather than some
  side channel invisible to the rest of the application.

Refs #162.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-16 20:20:58 +12:00
parent eba258f6cd
commit 0920e82188
5 changed files with 323 additions and 34 deletions
+1 -1
View File
@@ -3011,6 +3011,6 @@ void QETDiagramEditor::slot_runScript() {
);
if (script_path.isEmpty()) return;
QetScripting::runOnProject(script_path, project);
QetScripting::runOnProject(script_path, project, currentDiagramView());
}
#endif
+232 -3
View File
@@ -17,16 +17,27 @@
*/
#include "qetscriptapi.h"
#include "../ElementsCollection/elementslocation.h"
#include "../QPropertyUndoCommand/qpropertyundocommand.h"
#include "../cli_export.h"
#include "../diagram.h"
#include "../diagramcontent.h"
#include "../diagramview.h"
#include "../factory/elementfactory.h"
#include "../qetgraphicsitem/element.h"
#include "../qetmessagebox.h"
#include "../qetproject.h"
#include "../undocommand/addgraphicsobjectcommand.h"
#include "../undocommand/deleteqgraphicsitemcommand.h"
#include <QFile>
#include <QTextStream>
#include <QUndoCommand>
QetScriptApi::QetScriptApi(QETProject *project, QObject *parent) :
QetScriptApi::QetScriptApi(QETProject *project, DiagramView *view, QObject *parent) :
QObject(parent),
m_project(project)
m_project(project),
m_view(view)
{
}
@@ -159,12 +170,230 @@ bool QetScriptApi::setTitleBlock(const QString &output, const QStringList &assig
return runFlag(QStringLiteral("--set-titleblock"), args);
}
/**
@brief QetScriptApi::save
Write this project's own current in-memory state -- unlike every export
method above, this does NOT go through runFlag()/CLIExport::run(): that
reopens the project fresh from its file on disk, which would never see
any addElement()/setElementPosition()/moveElement()/deleteElement() this
script made, only silently rewrite the file exactly as it already was.
Caught by testing this against a script that added an element and
called save(): the element counted correctly in memory but the saved
file didn't have it, since the old implementation opened an unrelated,
unmodified second copy of the project to write.
@param output path to write to; the project's own file path if empty
@return false if there is no output path to use, or the file could not
be opened for writing
*/
bool QetScriptApi::save(const QString &output)
{
return runFlag(QStringLiteral("--resave"), {output});
if (!m_project) return false;
const QString path = output.isEmpty() ? m_project->filePath() : output;
if (path.isEmpty()) {
log(QStringLiteral("qet.save: no output path given and the project has none of its own -- pass one"));
return false;
}
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
log(QStringLiteral("qet.save: cannot open '%1' for writing").arg(path));
return false;
}
QTextStream file_out(&file);
file_out << m_project->toXml().toString(4);
file.close();
return true;
}
void QetScriptApi::log(const QString &message)
{
QTextStream(stderr) << message << "\n";
}
/**
@brief QetScriptApi::findElement
@param folioIndex
@param elementUuid as returned by addElement(), or read from the
element's own uuid attribute
@return the matching element on that folio, or nullptr
*/
Element *QetScriptApi::findElement(int folioIndex, const QString &elementUuid) const
{
if (!m_project) return nullptr;
const QList<Diagram *> diagrams = m_project->diagrams();
if (folioIndex < 0 || folioIndex >= diagrams.count()) return nullptr;
DiagramContent content(diagrams.at(folioIndex), false);
for (Element *elmt : std::as_const(content.m_elements)) {
if (elmt->uuid().toString() == elementUuid)
return elmt;
}
return nullptr;
}
/**
@brief QetScriptApi::addElement
Place a new element on a folio, through the same AddGraphicsObjectCommand
the interactive drag-from-collection-panel path uses (see
DiagramEventAddElement::addElement()) -- so Ctrl+Z undoes it exactly as
it would a manually dropped element.
@param folioIndex
@param locationPath an element collection path, e.g.
"embed://some/path.elmt" or "common://10_electric/...elmt"
@param x @param y target position, in the diagram's own coordinates
@return the new element's uuid (empty string on failure -- bad folio
index, or the location could not be resolved/built)
*/
QString QetScriptApi::addElement(int folioIndex, const QString &locationPath, double x, double y)
{
if (!m_project) return QString();
const QList<Diagram *> diagrams = m_project->diagrams();
if (folioIndex < 0 || folioIndex >= diagrams.count()) return QString();
Diagram *diagram = diagrams.at(folioIndex);
ElementsLocation location(locationPath, m_project);
int state = 0;
Element *element = ElementFactory::Instance()->createElement(location, nullptr, &state);
if (state) {
delete element;
log(QStringLiteral("qet.addElement: could not build element from '%1'").arg(locationPath));
return QString();
}
const QPointF pos(x, y);
element->setPos(pos);
diagram->addItem(element);
auto *undo_group = new QUndoCommand(QObject::tr("Ajouter %1").arg(element->name()));
new AddGraphicsObjectCommand(element, diagram, pos, undo_group);
diagram->undoStack().push(undo_group);
return element->uuid().toString();
}
bool QetScriptApi::setElementPosition(int folioIndex, const QString &elementUuid, double x, double y)
{
Element *element = findElement(folioIndex, elementUuid);
if (!element) return false;
const QVariant old_value = element->pos();
const QVariant new_value = QPointF(x, y);
if (old_value == new_value) return true; // already there; nothing to push
auto *cmd = new QPropertyUndoCommand(element, "pos", old_value, new_value);
cmd->setText(QObject::tr("Déplacer %1").arg(element->name()));
m_project->undoStack()->push(cmd);
return true;
}
bool QetScriptApi::moveElement(int folioIndex, const QString &elementUuid, double dx, double dy)
{
Element *element = findElement(folioIndex, elementUuid);
if (!element) return false;
const QPointF p = element->pos();
return setElementPosition(folioIndex, elementUuid, p.x() + dx, p.y() + dy);
}
/**
@brief QetScriptApi::deleteElement
Through DeleteQGraphicsItemCommand -- the same command the Delete key
uses -- so any conductors attached to the element's terminals are
cleaned up the same way, not left dangling.
*/
bool QetScriptApi::deleteElement(int folioIndex, const QString &elementUuid)
{
if (!m_project) return false;
Element *element = findElement(folioIndex, elementUuid);
if (!element) return false;
Diagram *diagram = m_project->diagrams().at(folioIndex);
DiagramContent content;
content.m_elements << element;
if (DeleteQGraphicsItemCommand::hasNonDeletableTerminal(content)) {
log(QStringLiteral("qet.deleteElement: %1 has a non-deletable terminal (linked master/slave?), refusing").arg(elementUuid));
return false;
}
auto *cmd = new DeleteQGraphicsItemCommand(diagram, content);
diagram->undoStack().push(cmd);
return true;
}
bool QetScriptApi::undo()
{
if (!m_project || !m_project->undoStack()->canUndo()) return false;
m_project->undoStack()->undo();
return true;
}
bool QetScriptApi::redo()
{
if (!m_project || !m_project->undoStack()->canRedo()) return false;
m_project->undoStack()->redo();
return true;
}
bool QetScriptApi::canUndo() const
{
return m_project && m_project->undoStack()->canUndo();
}
bool QetScriptApi::canRedo() const
{
return m_project && m_project->undoStack()->canRedo();
}
bool QetScriptApi::selectElement(const QString &elementUuid)
{
if (!m_project) return false;
for (Diagram *diagram : m_project->diagrams()) {
DiagramContent content(diagram, false);
for (Element *elmt : std::as_const(content.m_elements)) {
if (elmt->uuid().toString() == elementUuid) {
elmt->setSelected(true);
return true;
}
}
}
return false;
}
void QetScriptApi::deselectAll(int folioIndex)
{
if (!m_project) return;
const QList<Diagram *> diagrams = m_project->diagrams();
if (folioIndex < 0 || folioIndex >= diagrams.count()) return;
diagrams.at(folioIndex)->clearSelection();
}
bool QetScriptApi::zoomFit()
{
if (!m_view) return false;
m_view->zoomFit();
return true;
}
bool QetScriptApi::zoomToContent()
{
if (!m_view) return false;
m_view->zoomContent();
return true;
}
bool QetScriptApi::zoomReset()
{
if (!m_view) return false;
m_view->zoomReset();
return true;
}
/**
@brief QetScriptApi::showMessage
A modal QET::QetMessageBox::information() -- safe to call headless
because non-interactive mode is already set for the whole process
before any script runs (main.cpp), which is exactly the mechanism
that keeps --export-* and --resave from hanging on a warning dialog.
Interactively, a real person is there to see and dismiss it.
*/
void QetScriptApi::showMessage(const QString &text)
{
QET::QetMessageBox::information(nullptr, QObject::tr("Script"), text);
}
+76 -20
View File
@@ -23,37 +23,73 @@
#include <QStringList>
class QETProject;
class DiagramView;
class Element;
/**
@brief The QetScriptApi class
The object a script sees as `qet` (bugtracker #162): a small,
deliberately read-mostly surface over an open project, for scripted
batch/CI work and human-written macros.
The object a script sees as `qet` (bugtracker #162): batch/CI work and
human-written macros against an open project.
@b Scope, on purpose: this reads the model (folio count, titles, element
and conductor counts -- the same data the `--info` CLI export already
reports) and can trigger the same export/save operations the `--export-*`
CLI flags already do. It does not create or edit diagram geometry, does
not touch the undo stack, and does not drive the GUI. Those are all
explicitly out of scope for this first version; see the discussion on
bugtracker #162.
@b Scope. Three groups of capability, each added at a different point in
the discussion and each drawing its own line:
Export/save methods are thin wrappers around CLIExport::run() -- the
exact same, already-tested code path the `--export-*` flags use -- built
from the project's own file path, not the live in-memory instance. That
keeps this file free of any dependency on cli_export.cpp's internals,
at the cost of re-opening the project from disk for each call: fine for
the batch/CI use case this targets, and for the headless `--run` entry
point it's exactly what a second `--export-*` invocation would have
done anyway. A macro acting on unsaved GUI edits should call save()
first.
- @b Reading the model and @b exporting: folio/element/conductor counts,
and the same export operations the `--export-*` CLI flags provide.
Every export method is a thin wrapper around CLIExport::run() -- the
same, already-tested code path those flags use -- built from the
project's own file path rather than the live instance. That keeps this
file free of any dependency on cli_export.cpp's internals, at the cost
of re-opening the project from disk per call, which means @b none of
them ever see edits this script made with the methods below: only
save() writes the live instance. A script that edits then exports
@b must call save() first, or the export reflects the file as it was
before the script ran. Verified the hard way: an early version of this
file had save() go through the same reopen-from-disk path as the
exports, so it silently wrote back the unmodified original -- an
addElement() call counted correctly in memory and then vanished from
the saved file.
- @b Editing geometry, through the same undo commands the GUI itself
uses (AddGraphicsObjectCommand for placement, QPropertyUndoCommand on
the standard `pos` property for moves, DeleteQGraphicsItemCommand for
removal) -- Ctrl+Z undoes a script's edits exactly as it would the
equivalent manual ones, because they are, mechanically, the same
commands on the same stack. One consequence worth knowing, not a bug:
QPropertyUndoCommand merges consecutive commands on the same
object+property when their text() also matches
(QPropertyUndoCommand::mergeWith(), pre-existing), and
setElementPosition()/moveElement() always use the same text for a
given element -- so several position changes to the same element in a
row collapse into one undo step, the same way dragging an element
does, not one step per call. Verified against exactly that: two
consecutive calls on one element, then undo/undo/redo/redo, land
where a merge predicts, not where two independent steps would.
- @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
returning false without one, since there is nothing to zoom.
Explicitly @b not in scope: driving arbitrary GUI actions or dialogs. A
script that could invoke any QAction by name could just as easily
trigger one that opens a modal QDialog::exec() with nobody there to
dismiss it -- exactly the hang class investigated for bugtracker #882.
Every method here is either non-blocking by construction or, for
messages, safe under QET::QetMessageBox's existing non-interactive mode
(already active for headless runs). Nothing here opens a dialog the
caller has to wait on.
*/
class QetScriptApi : public QObject
{
Q_OBJECT
public:
explicit QetScriptApi(QETProject *project, QObject *parent = nullptr);
/**
@param project the project this API acts on
@param view the active DiagramView, when run interactively via
"Run Script..."; nullptr for the headless --run entry point.
Only the zoom methods use it -- everything else works either way.
*/
explicit QetScriptApi(QETProject *project, DiagramView *view = nullptr, QObject *parent = nullptr);
// -- read the model --
Q_INVOKABLE QString projectTitle() const;
@@ -77,13 +113,33 @@ class QetScriptApi : public QObject
Q_INVOKABLE bool setTitleBlock(const QString &output, const QStringList &assignments);
Q_INVOKABLE bool save(const QString &output);
// -- edit geometry, through the real undo commands --
Q_INVOKABLE QString addElement(int folioIndex, const QString &locationPath, double x, double y);
Q_INVOKABLE bool setElementPosition(int folioIndex, const QString &elementUuid, double x, double y);
Q_INVOKABLE bool moveElement(int folioIndex, const QString &elementUuid, double dx, double dy);
Q_INVOKABLE bool deleteElement(int folioIndex, const QString &elementUuid);
Q_INVOKABLE bool undo();
Q_INVOKABLE bool redo();
Q_INVOKABLE bool canUndo() const;
Q_INVOKABLE bool canRedo() const;
// -- navigate and message --
Q_INVOKABLE bool selectElement(const QString &elementUuid);
Q_INVOKABLE void deselectAll(int folioIndex);
Q_INVOKABLE bool zoomFit();
Q_INVOKABLE bool zoomToContent();
Q_INVOKABLE bool zoomReset();
Q_INVOKABLE void showMessage(const QString &text);
// -- logging: a script has no console of its own --
Q_INVOKABLE void log(const QString &message);
private:
bool runFlag(const QString &flag, const QStringList &args);
Element *findElement(int folioIndex, const QString &elementUuid) const;
QETProject *m_project;
DiagramView *m_view;
};
#endif // QET_SCRIPT_API_H
+4 -4
View File
@@ -67,10 +67,10 @@ int run(const QStringList &args)
return 1;
}
return runOnProject(script_path, &project) ? 0 : 1;
return runOnProject(script_path, &project, nullptr) ? 0 : 1;
}
bool runOnProject(const QString &scriptPath, QETProject *project)
bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view)
{
QFile file(scriptPath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
@@ -81,7 +81,7 @@ bool runOnProject(const QString &scriptPath, QETProject *project)
file.close();
QJSEngine engine;
auto *api = new QetScriptApi(project, &engine);
auto *api = new QetScriptApi(project, view, &engine);
QJSValue qet_value = engine.newQObject(api);
// newQObject() takes ownership by default (QJSEngine::JavaScriptOwnership),
// which would delete api as soon as the engine's GC decides to -- api's
@@ -114,7 +114,7 @@ int run(const QStringList &)
return 1;
}
bool runOnProject(const QString &, QETProject *)
bool runOnProject(const QString &, QETProject *, DiagramView *)
{
err << "This build of QElectroTech was compiled without the Qt Qml "
"module, so JavaScript scripting is not available.\n";
+10 -6
View File
@@ -21,15 +21,16 @@
#include <QStringList>
class QETProject;
class DiagramView;
/**
@brief JavaScript scripting entry points (bugtracker #162).
A script sees a single global, `qet` (see QetScriptApi), exposing a
deliberately small, mostly read-only surface: folio/element/conductor
counts and the same export operations the `--export-*` CLI flags
provide. See QetScriptApi's class comment for what is and is not in
scope for this first version.
A script sees a single global, `qet` (see QetScriptApi): reading the
model, exporting, editing geometry through the real undo commands, and
a narrow set of navigation/messaging calls. See QetScriptApi's class
comment for the exact scope and why each group of capability stops
where it does.
*/
namespace QetScripting {
@@ -52,9 +53,12 @@ namespace QetScripting {
@brief Run @p scriptPath against an already-open @p project (the
"Run Script..." GUI macro path). Errors go to stderr; there is no
modal reporting in this first version.
@param view the active DiagramView, so the script's zoom methods
have something to act on; nullptr from the headless entry point,
where they become no-ops (see QetScriptApi).
@return true if the script ran without throwing.
*/
bool runOnProject(const QString &scriptPath, QETProject *project);
bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view = nullptr);
}