diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index e312aaa39..1d33040a1 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -116,6 +116,57 @@ int QetScriptApi::folioCount() const return m_project ? m_project->diagrams().count() : 0; } +/** + @brief QetScriptApi::currentFolio + The index of the folio on screen, so a script started from the editor + acts where the user is looking. With no view (--run) there is no such + folio, and the first one stands in for it so a script written for the + editor can still be tried headless; -1 if the project has none. +*/ +/** + @brief QetScriptApi::apiSignatures + Every call a script can make, as "returnType name(type param, ...)", + read from the meta-object rather than written out by hand, so the list + is the one this build has and cannot drift from it: for a person + writing a script, and for an assistant that has to write one without + the source at hand. A call with default arguments is listed once, with + all of them. +*/ +QStringList QetScriptApi::apiSignatures() const +{ + QStringList list; + const QMetaObject *meta = metaObject(); + for (int i = meta->methodOffset(); i < meta->methodCount(); ++i) { + const QMetaMethod method = meta->method(i); + if (method.methodType() != QMetaMethod::Method + || method.access() != QMetaMethod::Public + || (method.attributes() & QMetaMethod::Cloned)) { + continue; + } + const QList types = method.parameterTypes(); + const QList names = method.parameterNames(); + QStringList params; + for (int p = 0; p < types.size(); ++p) { + params << QString::fromLatin1(types.at(p) + ' ' + names.value(p)); + } + list << QStringLiteral("%1 %2(%3)") + .arg(QString::fromLatin1(method.typeName()), + QString::fromLatin1(method.name()), + params.join(QStringLiteral(", "))); + } + return list; +} + +int QetScriptApi::currentFolio() const +{ + if (!m_project) return -1; + const QList diagrams = m_project->diagrams(); + if (m_view && m_view->diagram()) { + return diagrams.indexOf(m_view->diagram()); + } + return diagrams.isEmpty() ? -1 : 0; +} + QString QetScriptApi::folioTitle(int index) const { if (!m_project) return QString(); @@ -3938,8 +3989,24 @@ bool QetScriptApi::setFolioTitle(int folioIndex, const QString &title) return true; } +/** + @brief QetScriptApi::setUndoGrouped + Set by QetScripting::runOnProject() while the whole run is one undo + macro. QUndoStack cannot undo or redo inside a macro: it prints a + warning and does nothing, so undo() and redo() say so instead. +*/ +void QetScriptApi::setUndoGrouped(bool grouped) +{ + m_undo_grouped = grouped; +} + bool QetScriptApi::undo() { + if (m_undo_grouped) { + log(QStringLiteral("qet.undo: not available here -- this run is one " + "undo step; press Ctrl+Z after it to undo it")); + return false; + } if (!m_project || !m_project->undoStack()->canUndo()) return false; m_project->undoStack()->undo(); return true; @@ -3947,6 +4014,10 @@ bool QetScriptApi::undo() bool QetScriptApi::redo() { + if (m_undo_grouped) { + log(QStringLiteral("qet.redo: not available while this run is one undo step")); + return false; + } if (!m_project || !m_project->undoStack()->canRedo()) return false; m_project->undoStack()->redo(); return true; diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index 35ef9c5d5..d725cfa2a 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -354,6 +354,7 @@ class QetScriptApi : public QObject Q_INVOKABLE QString projectTitle() const; Q_INVOKABLE QString filePath() const; Q_INVOKABLE int folioCount() const; + Q_INVOKABLE int currentFolio() const; Q_INVOKABLE QString folioTitle(int index) const; Q_INVOKABLE QString folioUuid(int index) const; Q_INVOKABLE int folioIndex(const QString &uuid) const; @@ -583,6 +584,11 @@ class QetScriptApi : public QObject // -- logging: a script has no console of its own -- Q_INVOKABLE void log(const QString &message); + // -- what a script can call, read from this class itself -- + Q_INVOKABLE QStringList apiSignatures() const; + + void setUndoGrouped(bool grouped); + private: bool runFlag(const QString &flag, const QStringList &args); Element *findElement(int folioIndex, const QString &elementUuid) const; @@ -602,6 +608,7 @@ class QetScriptApi : public QObject QETProject *m_project; DiagramView *m_view; + bool m_undo_grouped = false; QString m_query_error; }; diff --git a/sources/scripting/qetscripting.cpp b/sources/scripting/qetscripting.cpp index 789b2dee9..a85bc9782 100644 --- a/sources/scripting/qetscripting.cpp +++ b/sources/scripting/qetscripting.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #ifdef QET_HAS_SCRIPTING #include @@ -162,6 +163,20 @@ bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *v } }); + // From the editor, a run is one undo step: a script that places + // twenty items is undone with one Ctrl+Z, not twenty. Headless --run + // keeps one step per call, which is what a script calling qet.undo() + // itself relies on. QUndoStack keeps an empty macro as a blank entry + // (and marks the project modified), so a script that changed nothing + // has its macro taken off again: an obsolete command is deleted by + // QUndoStack::undo() instead of being undone onto the redo side. + QUndoStack *stack = (view && project) ? project->undoStack() : nullptr; + if (stack) { + stack->beginMacro(QObject::tr("Script : %1") + .arg(QFileInfo(scriptPath).completeBaseName())); + api->setUndoGrouped(true); + } + QJSValue result = engine.evaluate(source, scriptPath); { std::lock_guard lock(mtx); @@ -170,6 +185,18 @@ bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *v cv.notify_one(); watchdog.join(); + if (stack) { + api->setUndoGrouped(false); + stack->endMacro(); + // Always the top of the undo side once the macro is closed, even + // when beginMacro() dropped a redo tail and count() went down. + const QUndoCommand *macro = stack->command(stack->index() - 1); + if (macro && macro->childCount() == 0) { + const_cast(macro)->setObsolete(true); + stack->undo(); + } + } + if (result.isError()) { const QString message = QStringLiteral("Script error: %1:%2: %3") .arg(scriptPath) diff --git a/sources/scripting/qetscripting.h b/sources/scripting/qetscripting.h index 09c8d9e0f..6c9e746be 100644 --- a/sources/scripting/qetscripting.h +++ b/sources/scripting/qetscripting.h @@ -51,8 +51,9 @@ 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. + "Run Script..." GUI macro path). Errors go to stderr, and with a + @p view also to a message box. With a @p view the whole run is one + undo step; without one, each call is its own step, as on --run. @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). diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index e339f3721..f60cbbf95 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -454,6 +454,18 @@ if(QET_HAS_SCRIPTING) target_compile_definitions(tst_scriptterminaluuid PRIVATE "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + + # qet.currentFolio() through --run: the first folio, or -1 with none; and + # --run keeps one undo step per call (only an editor run is grouped). + add_executable( + tst_scriptcurrentfolio + tst_scriptcurrentfolio.cpp) + add_test(NAME tst_scriptcurrentfolio COMMAND tst_scriptcurrentfolio) + add_dependencies(tst_scriptcurrentfolio qelectrotech) + target_link_libraries(tst_scriptcurrentfolio PRIVATE Qt::Test) + target_compile_definitions(tst_scriptcurrentfolio PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") endif() # A symbol saved without a uuid gets the same one on every load, and diff --git a/tests/qttest/tst_scriptcurrentfolio.cpp b/tests/qttest/tst_scriptcurrentfolio.cpp new file mode 100644 index 000000000..928700042 --- /dev/null +++ b/tests/qttest/tst_scriptcurrentfolio.cpp @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include +#include + +// qet.currentFolio() is the folio on screen in the editor. Through --run +// there is none, so it stands in with the first folio, or -1 when the +// project has no folio. --run also keeps one undo step per call: only a run +// started from the editor is grouped into one, and a headless script that +// calls qet.undo() itself must go on working. +class tst_scriptcurrentfolio : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + // Run @p script on @p project in a sandbox of its own and return the + // JSON object it logged. + QJsonObject run(const QString &script, const QString &project) + { + const QString path = m_dir.filePath(QStringLiteral("probe.js")); + const QString home = m_dir.filePath(QStringLiteral("home")); + QDir().mkpath(home); + QFile f(path); + if (!f.open(QIODevice::WriteOnly)) return {}; + f.write(script.toUtf8()); + f.close(); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + env.insert(QStringLiteral("TMPDIR"), m_dir.path()); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), + {QStringLiteral("--run"), path, project}); + if (!proc.waitForFinished(120000)) return {}; + const QString out = QString::fromUtf8(proc.readAllStandardOutput() + + proc.readAllStandardError()); + const QString mark = QStringLiteral("PROBE "); + for (const QString &line : out.split(QLatin1Char('\n'))) { + const int i = line.indexOf(mark); + if (i >= 0) + return QJsonDocument::fromJson(line.mid(i + mark.size()).toUtf8()).object(); + } + return {}; + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + void firstFolioWithoutAView() + { + const QJsonObject r = run(QStringLiteral( + "qet.log('PROBE ' + JSON.stringify({current: qet.currentFolio(),\n" + " folios: qet.folioCount()}));\n"), + QStringLiteral(QET_EXAMPLES_DIR "/perceuse.qet")); + QVERIFY2(!r.isEmpty(), "the script logged nothing"); + QVERIFY(r.value(QStringLiteral("folios")).toInt() > 1); + QCOMPARE(r.value(QStringLiteral("current")).toInt(), 0); + } + + void noFolioIsMinusOne() + { + const QString project = m_dir.filePath(QStringLiteral("empty.qet")); + QFile out(project); + QVERIFY(out.open(QIODevice::WriteOnly)); + out.write("\n\n"); + out.close(); + + const QJsonObject r = run(QStringLiteral( + "qet.log('PROBE ' + JSON.stringify({current: qet.currentFolio(),\n" + " folios: qet.folioCount()}));\n"), project); + QVERIFY2(!r.isEmpty(), "the script logged nothing"); + QCOMPARE(r.value(QStringLiteral("folios")).toInt(), 0); + QCOMPARE(r.value(QStringLiteral("current")).toInt(), -1); + } + + // The list is read from the build itself: the calls a script uses + // are in it with their parameter names, each once. + void apiSignaturesListed() + { + const QJsonObject r = run(QStringLiteral( + "qet.log('PROBE ' + JSON.stringify({sigs: qet.apiSignatures()}));\n"), + QStringLiteral(QET_EXAMPLES_DIR "/perceuse.qet")); + QVERIFY2(!r.isEmpty(), "the script logged nothing"); + QStringList sigs; + for (const QJsonValue &v : r.value(QStringLiteral("sigs")).toArray()) + sigs << v.toString(); + QVERIFY(sigs.size() > 100); + QVERIFY(sigs.contains(QStringLiteral("int currentFolio()"))); + QVERIFY(sigs.contains(QStringLiteral( + "int addText(int folioIndex, QString text, double x, double y)"))); + QVERIFY(sigs.contains(QStringLiteral( + "bool exportPdf(QString output, bool showTerminals)"))); + QCOMPARE(sigs.filter(QStringLiteral(" exportPdf(")).size(), 1); + QVERIFY(sigs.filter(QStringLiteral("setUndoGrouped")).isEmpty()); + } + + void headlessUndoStaysPerCall() + { + const QJsonObject r = run(QStringLiteral( + "var f = qet.currentFolio();\n" + "var before = qet.texts(f).length;\n" + "qet.addText(f, 'one', 40, 40);\n" + "qet.addText(f, 'two', 40, 80);\n" + "var undone = qet.undo();\n" + "qet.log('PROBE ' + JSON.stringify({undone: undone,\n" + " added: qet.texts(f).length - before}));\n"), + QStringLiteral(QET_EXAMPLES_DIR "/perceuse.qet")); + QVERIFY2(!r.isEmpty(), "the script logged nothing"); + QCOMPARE(r.value(QStringLiteral("undone")).toBool(), true); + QCOMPARE(r.value(QStringLiteral("added")).toInt(), 1); + } +}; + +QTEST_MAIN(tst_scriptcurrentfolio) +#include "tst_scriptcurrentfolio.moc"