From e03e52306921ec6ec2d8eb1822a7c4f11ba67613 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 2 Oct 2026 09:01:02 +1300 Subject: [PATCH] Script buttons: stored scripts become commands with an icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every .js file in the "scripts" folder of the user's data folder that starts with a // ==QETScript== header becomes a command: in Projet > Scripts, as a button on a new Scripts toolbar, and, because it is registered with ShortcutManager as diagrameditor.script., in the shortcut settings, the shortcut bar (S) and command search. The header gives its name, icon (a file next to the script or a theme icon; a tile with its initials otherwise), tooltip, default shortcut and when it is enabled (always, with a selection, with a conductor selected). The folder is watched, so a script added, edited or deleted while QET is open appears, changes or goes without a restart. A file with a header that cannot be used gets no button; the Scripts menu lists it with the reason. The menu also opens the folder, and holds "Exécuter un script…". A click runs the script on the current project as one undo step named after it, and asks to switch scripting on first, like "Exécuter un script…" does: scripting stays off by default. ShortcutManager::unregisterAction() takes a command out of the lists when its script is deleted, and lets it come back under a new name. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 3 + sources/qetdiagrameditor.cpp | 189 +++++++++++++++++++++++---- sources/qetdiagrameditor.h | 13 +- sources/scripting/qetscripting.cpp | 8 +- sources/scripting/qetscripting.h | 5 +- sources/scripting/scriptheader.h | 118 +++++++++++++++++ sources/scripting/scriptlibrary.cpp | 194 ++++++++++++++++++++++++++++ sources/scripting/scriptlibrary.h | 72 +++++++++++ sources/shortcutmanager.cpp | 27 ++++ sources/shortcutmanager.h | 2 + tests/qttest/CMakeLists.txt | 12 ++ tests/qttest/tst_scriptheader.cpp | 122 +++++++++++++++++ 12 files changed, 737 insertions(+), 28 deletions(-) create mode 100644 sources/scripting/scriptheader.h create mode 100644 sources/scripting/scriptlibrary.cpp create mode 100644 sources/scripting/scriptlibrary.h create mode 100644 tests/qttest/tst_scriptheader.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 4593d5d56..8dad6c023 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -923,6 +923,9 @@ list(APPEND QET_SRC_FILES ${QET_DIR}/sources/scripting/qetscriptapi.h ${QET_DIR}/sources/scripting/qetscripting.cpp ${QET_DIR}/sources/scripting/qetscripting.h + ${QET_DIR}/sources/scripting/scriptheader.h + ${QET_DIR}/sources/scripting/scriptlibrary.cpp + ${QET_DIR}/sources/scripting/scriptlibrary.h ) if(QET_SPACEMOUSE_ENABLED) diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 0218f7cf8..0e0a77e19 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -18,6 +18,7 @@ #include "qetdiagrameditor.h" #ifdef QET_HAS_SCRIPTING #include "scripting/qetscripting.h" +#include "scripting/scriptlibrary.h" #endif #include #include @@ -81,7 +82,9 @@ #include #include #include +#include #include +#include #include #ifdef BUILD_WITHOUT_KF # include "ui/nokde/kautosavefile.h" @@ -162,6 +165,11 @@ QETDiagramEditor::QETDiagramEditor(const QStringList &files, QWidget *parent) : readSettings(); // restoreGeometry before show() show(); readSettingsState(); // restoreState() must be called after show() in Qt6 +#ifdef QET_HAS_SCRIPTING + //A toolbar saved as shown while there were scripts stays out of + //the way while there are none. + if (m_script_actions.isEmpty()) m_scripts_tool_bar->hide(); +#endif //If valid file path is given as arguments uint opened_projects = 0; @@ -701,6 +709,17 @@ void QETDiagramEditor::setUpActions() tr("Exécute un script JavaScript sur le projet courant (voir qet.*" " dans le script pour l'API disponible)")); connect(m_run_script, &QAction::triggered, this, &QETDiagramEditor::slot_runScript); + + //Stored scripts are files in a folder, written by hand, by the + //script manager or by an assistant: open it to add one. + m_open_scripts_folder = new QAction(QET::Icons::FolderOpen, tr("Ouvrir le dossier des scripts"), this); + m_open_scripts_folder->setStatusTip( + tr("Chaque fichier .js de ce dossier qui commence par un en-tête" + " // ==QETScript== devient un bouton")); + connect(m_open_scripts_folder, &QAction::triggered, this, []() { + QDir().mkpath(ScriptLibrary::folder()); + QDesktopServices::openUrl(QUrl::fromLocalFile(ScriptLibrary::folder())); + }); #endif #ifdef QET_EXPORT_PROJECT_DB @@ -1206,6 +1225,14 @@ void QETDiagramEditor::setUpToolBar() addToolBar(Qt::TopToolBarArea, diagram_tool_bar); addToolBar(Qt::TopToolBarArea, m_add_item_tool_bar); addToolBar(Qt::TopToolBarArea, m_depth_tool_bar); + + m_scripts_tool_bar = new QToolBar(tr("Scripts", "toolbar title"), this); + m_scripts_tool_bar->setObjectName("scripts"); + addToolBar(Qt::TopToolBarArea, m_scripts_tool_bar); +#ifndef QET_HAS_SCRIPTING + m_scripts_tool_bar->toggleViewAction()->setVisible(false); + m_scripts_tool_bar->hide(); +#endif } /** @@ -1327,7 +1354,10 @@ void QETDiagramEditor::setUpMenu() menu_project -> addAction(m_terminal_numbering); menu_project -> addAction(m_reload_element_drawings); #ifdef QET_HAS_SCRIPTING - menu_project -> addAction(m_run_script); + m_scripts_menu = menu_project -> addMenu(tr("Scripts")); + rebuildScriptActions(); + connect(&ScriptLibrary::instance(), &ScriptLibrary::changed, + this, &QETDiagramEditor::rebuildScriptActions); #endif #ifdef QET_EXPORT_PROJECT_DB menu_project -> addSeparator(); @@ -2380,6 +2410,9 @@ void QETDiagramEditor::slot_updateUndoStack() */ void QETDiagramEditor::slot_updateComplexActions() { +#ifdef QET_HAS_SCRIPTING + updateScriptActions(); +#endif DiagramView *dv = currentDiagramView(); if(!dv) { @@ -3723,28 +3756,7 @@ void QETDiagramEditor::slot_runScript() { QETProject *project = currentProject(); if (!project) return; - // Scripting is off until somebody says otherwise, so the first use has - // to ask. Asking here rather than greying the action out keeps the - // feature discoverable: a disabled menu entry tells a user that - // something exists and nothing about how to have it. - if (!QetSettings::scriptingEnabled()) { - const QMessageBox::StandardButton answer = QET::QetMessageBox::question( - this, - tr("Exécuter un script"), - tr("Les scripts sont désactivés.\n\n" - "Un script s'exécute avec vos droits : il peut lire et " - "modifier le projet ouvert et écrire des fichiers. " - "N'exécutez que des scripts dont vous connaissez " - "l'origine.\n\n" - "Activer les scripts ? Ce réglage est modifiable dans " - "Configurer QElectroTech > Général > Projets."), - QMessageBox::Yes | QMessageBox::Cancel, - QMessageBox::Cancel); - if (answer != QMessageBox::Yes) { - return; - } - QetSettings::setScriptingEnabled(true); - } + if (!ensureScriptingEnabled(tr("Exécuter un script"))) return; const QString script_path = QFileDialog::getOpenFileName( this, @@ -3756,6 +3768,137 @@ void QETDiagramEditor::slot_runScript() { QetScripting::runOnProject(script_path, project, currentDiagramView()); } + +/** + @brief QETDiagramEditor::ensureScriptingEnabled + @return true if scripts may run, asking to switch them on if they are + off. Shared by "Run a script..." and the stored script buttons, so + there is one prompt to keep right. +*/ +bool QETDiagramEditor::ensureScriptingEnabled(const QString &title) +{ + // Scripting is off until somebody says otherwise, so the first use has + // to ask. Asking here rather than greying the action out keeps the + // feature discoverable: a disabled menu entry tells a user that + // something exists and nothing about how to have it. + if (!QetSettings::scriptingEnabled()) { + const QMessageBox::StandardButton answer = QET::QetMessageBox::question( + this, + title, + tr("Les scripts sont désactivés.\n\n" + "Un script s'exécute avec vos droits : il peut lire et " + "modifier le projet ouvert et écrire des fichiers. " + "N'exécutez que des scripts dont vous connaissez " + "l'origine.\n\n" + "Activer les scripts ? Ce réglage est modifiable dans " + "Configurer QElectroTech > Général > Projets."), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel); + if (answer != QMessageBox::Yes) { + return false; + } + QetSettings::setScriptingEnabled(true); + } + return true; +} + +/** + @brief QETDiagramEditor::runStoredScript + Run the stored script at @a path on the current project, as one undo + step named after the script. The file is read again on every run, so + an edit to it takes effect on the next click. +*/ +void QETDiagramEditor::runStoredScript(const QString &path, const QString &name) +{ + QETProject *project = currentProject(); + if (!project) return; + if (!ensureScriptingEnabled(name)) return; + QetScripting::runOnProject(path, project, currentDiagramView(), name); +} + +/** + @brief QETDiagramEditor::rebuildScriptActions + One action per stored script, in the Projet > Scripts menu and on the + Scripts toolbar, registered with ShortcutManager under + diagrameditor.script.: that one registration is what lists + it in the shortcut settings, the shortcut bar (S) and command search. + Rebuilt from scratch whenever the scripts folder changes. +*/ +void QETDiagramEditor::rebuildScriptActions() +{ + for (QAction *action : std::as_const(m_script_actions)) { + ShortcutManager::instance().unregisterAction( + action, action->property("qet_script_action_id").toString()); + delete action; + } + m_script_actions.clear(); + m_scripts_menu->clear(); + m_scripts_tool_bar->clear(); + + const ScriptLibrary &library = ScriptLibrary::instance(); + for (const ScriptLibrary::Script &script : library.scripts()) { + const ScriptHeader &h = script.header; + auto *action = new QAction(ScriptLibrary::icon(script), h.name, this); + action->setStatusTip(h.tooltip); + action->setToolTip(h.tooltip.isEmpty() ? h.name : h.name + QLatin1Char('\n') + h.tooltip); + action->setProperty("qet_script_action_id", ScriptLibrary::actionId(h.id)); + action->setProperty("qet_script_context", h.context); + connect(action, &QAction::triggered, this, [this, path = script.path, name = h.name]() { + runStoredScript(path, name); + }); + ShortcutManager::instance().registerAction(action, ScriptLibrary::actionId(h.id), + tr("Scripts"), + QKeySequence::fromString(h.shortcut)); + m_scripts_menu->addAction(action); + m_scripts_tool_bar->addAction(action); + m_script_actions << action; + } + + //A script with a header that cannot be used gets no button: say + //which file and why, where its author will look for the button. + const QStringList errors = library.errors(); + if (!errors.isEmpty()) { + m_scripts_menu->addSeparator(); + for (const QString &error : errors) { + QAction *ignored = m_scripts_menu->addAction(QET::Icons::DialogInformation, + tr("Ignoré : %1").arg(error)); + ignored->setEnabled(false); + } + } + + m_scripts_menu->addSeparator(); + m_scripts_menu->addAction(m_run_script); + m_scripts_menu->addAction(m_open_scripts_folder); + + //Show the toolbar when the first script arrives, hide it when the + //last one goes; in between it is the user's to show or hide. + const bool has_scripts = !m_script_actions.isEmpty(); + if (has_scripts != m_had_scripts) m_scripts_tool_bar->setVisible(has_scripts); + m_had_scripts = has_scripts; + + updateScriptActions(); +} + +/** + @brief QETDiagramEditor::updateScriptActions + Enable each script for what its header's @context asks for: always + (canvas), with something selected (selection), or with a conductor + selected (conductor). None without an open project. +*/ +void QETDiagramEditor::updateScriptActions() +{ + DiagramView *dv = currentDiagramView(); + Diagram *diagram = dv ? dv->diagram() : nullptr; + const bool selection = diagram && !diagram->selectedItems().isEmpty(); + const bool conductor = diagram && !diagram->selectedConductors().isEmpty(); + for (QAction *action : std::as_const(m_script_actions)) { + const QString context = action->property("qet_script_context").toString(); + bool enabled = currentProject() != nullptr; + if (context == QLatin1String("selection")) enabled = enabled && selection; + else if (context == QLatin1String("conductor")) enabled = enabled && conductor; + action->setEnabled(enabled); + } +} #endif /** diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 0345255d4..8455c4476 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -154,6 +154,10 @@ class QETDiagramEditor : public QETMainWindow void slot_reloadElementDrawings(); #ifdef QET_HAS_SCRIPTING void slot_runScript(); + void rebuildScriptActions(); + void updateScriptActions(); + bool ensureScriptingEnabled(const QString &title); + void runStoredScript(const QString &path, const QString &name); #endif void editDiagramProperties(DiagramView *); void editDiagramProperties(Diagram *); @@ -245,6 +249,7 @@ class QETDiagramEditor : public QETMainWindow *m_reload_element_drawings, ///< Action to redraw every placed element from its current definition #ifdef QET_HAS_SCRIPTING *m_run_script, ///< Action to run a JavaScript macro against the current project + *m_open_scripts_folder, ///< Action to open the folder stored scripts are read from #endif *m_export_project_db, ///Export to file the internal database of the current project *m_tile_window, ///< Show MDI subwindows as tile @@ -310,7 +315,13 @@ class QETDiagramEditor : public QETMainWindow *view_tool_bar = nullptr, *diagram_tool_bar = nullptr, *m_add_item_tool_bar = nullptr, - *m_depth_tool_bar = nullptr; + *m_depth_tool_bar = nullptr, + *m_scripts_tool_bar = nullptr; ///< One button per stored script +#ifdef QET_HAS_SCRIPTING + QMenu *m_scripts_menu = nullptr; + QList m_script_actions; ///< One per stored script, rebuilt when the folder changes + bool m_had_scripts = false; +#endif QUndoGroup undo_group; AutoNumberingDockWidget *m_autonumbering_dock; diff --git a/sources/scripting/qetscripting.cpp b/sources/scripting/qetscripting.cpp index a85bc9782..ab9943a51 100644 --- a/sources/scripting/qetscripting.cpp +++ b/sources/scripting/qetscripting.cpp @@ -113,7 +113,8 @@ namespace { constexpr int kScriptTimeoutMs = 30000; } -bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view) +bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view, + const QString &title) { // Checked here as well as at each caller, deliberately: this is the // one function that actually evaluates JavaScript, so it is the one @@ -173,7 +174,8 @@ bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *v QUndoStack *stack = (view && project) ? project->undoStack() : nullptr; if (stack) { stack->beginMacro(QObject::tr("Script : %1") - .arg(QFileInfo(scriptPath).completeBaseName())); + .arg(title.isEmpty() ? QFileInfo(scriptPath).completeBaseName() + : title)); api->setUndoGrouped(true); } @@ -229,7 +231,7 @@ int run(const QStringList &) return 1; } -bool runOnProject(const QString &, QETProject *, DiagramView *) +bool runOnProject(const QString &, QETProject *, DiagramView *, const QString &) { err << "This build of QElectroTech was compiled without the Qt Qml " "module, so JavaScript scripting is not available.\n"; diff --git a/sources/scripting/qetscripting.h b/sources/scripting/qetscripting.h index 6c9e746be..a1d8deafd 100644 --- a/sources/scripting/qetscripting.h +++ b/sources/scripting/qetscripting.h @@ -57,9 +57,12 @@ namespace QetScripting { @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). + @param title what the undo step is called; the script's file name + if empty. @return true if the script ran without throwing. */ - bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view = nullptr); + bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view = nullptr, + const QString &title = QString()); } diff --git a/sources/scripting/scriptheader.h b/sources/scripting/scriptheader.h new file mode 100644 index 000000000..ab2fd494d --- /dev/null +++ b/sources/scripting/scriptheader.h @@ -0,0 +1,118 @@ +/* + 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 . +*/ +#ifndef SCRIPTHEADER_H +#define SCRIPTHEADER_H + +#include +#include +#include + +/** + @brief The ScriptHeader struct + How a stored script's button looks, read from a comment block at the top + of the .js file itself, so one file is all there is to write by hand, + to share, or for an assistant to create: + + @code + // ==QETScript== + // @name Add revision note + // @icon note.svg (a file next to the script, or builtin:) + // @tooltip Puts a "Rev A" note on the folio on screen + // @shortcut Ctrl+Alt+R + // @context canvas (canvas, selection or conductor) + // @api 1 + // ==/QETScript== + @endcode + + Only @name is required. An unknown key is an error rather than ignored: + a misspelt "@shortcut" that silently did nothing would be harder to find + than a script refused with the line that is wrong. + + Header-only, with no QElectroTech dependency, so it is tested on its own. +*/ +struct ScriptHeader +{ + QString id; ///< file name without .js; the action id is diagrameditor.script. + QString name; + QString icon; + QString tooltip; + QString shortcut; ///< as QKeySequence::fromString() reads it + QString context = QStringLiteral("canvas"); + int api = 1; + QString error; ///< empty if the header is usable + + bool isValid() const { return error.isEmpty(); } + + static QStringList contexts() + { + return {QStringLiteral("canvas"), QStringLiteral("selection"), + QStringLiteral("conductor")}; + } + + /** + @brief parse + @param text : the whole script + @param id : the script's file name without its extension + */ + static ScriptHeader parse(const QString &text, const QString &id) + { + ScriptHeader h; + h.id = id; + static const QRegularExpression block( + QStringLiteral("//\\s*==QETScript==\\s*\\n(.*?)//\\s*==/QETScript=="), + QRegularExpression::DotMatchesEverythingOption); + const QRegularExpressionMatch m = block.match(text); + if (!m.hasMatch()) { + h.error = QStringLiteral("no // ==QETScript== header"); + return h; + } + static const QRegularExpression line_re( + QStringLiteral("^\\s*//\\s*@(\\w+)\\s+(.*?)\\s*$")); + const QStringList lines = m.captured(1).split(QLatin1Char('\n')); + for (const QString &line : lines) { + const QRegularExpressionMatch lm = line_re.match(line); + if (!lm.hasMatch()) { + continue; + } + const QString key = lm.captured(1); + const QString value = lm.captured(2); + if (key == QLatin1String("name")) h.name = value; + else if (key == QLatin1String("icon")) h.icon = value; + else if (key == QLatin1String("tooltip")) h.tooltip = value; + else if (key == QLatin1String("shortcut")) h.shortcut = value; + else if (key == QLatin1String("context")) h.context = value; + else if (key == QLatin1String("api")) h.api = value.toInt(); + else { + h.error = QStringLiteral("unknown header key @%1").arg(key); + return h; + } + } + if (h.name.isEmpty()) { + h.error = QStringLiteral("@name is required"); + } else if (!contexts().contains(h.context)) { + h.error = QStringLiteral("@context must be one of: %1") + .arg(contexts().join(QStringLiteral(", "))); + } else if (h.api != 1) { + h.error = QStringLiteral("@api %1 is not supported by this " + "version (1 is)").arg(h.api); + } + return h; + } +}; + +#endif // SCRIPTHEADER_H diff --git a/sources/scripting/scriptlibrary.cpp b/sources/scripting/scriptlibrary.cpp new file mode 100644 index 000000000..0107ab0af --- /dev/null +++ b/sources/scripting/scriptlibrary.cpp @@ -0,0 +1,194 @@ +/* + 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 . +*/ +#include "scriptlibrary.h" + +#include "../qetapp.h" + +#include +#include +#include +#include +#include +#include +#include + +ScriptLibrary &ScriptLibrary::instance() +{ + static ScriptLibrary library; + return library; +} + +/** + @brief ScriptLibrary::folder + Where stored scripts live: "scripts" in the user's data folder, next to + the user's own element and title block collections. +*/ +QString ScriptLibrary::folder() +{ + return QETApp::dataDir() + QStringLiteral("/scripts"); +} + +/** + @brief ScriptLibrary::actionId + The ShortcutManager id of a script's action. The "diagrameditor." + prefix is what puts it among the commands the shortcut bar can show. +*/ +QString ScriptLibrary::actionId(const QString &script_id) +{ + return QStringLiteral("diagrameditor.script.") + script_id; +} + +ScriptLibrary::ScriptLibrary() +{ + //An editor saving a file often writes it more than once, or + //replaces it through a rename: wait for it to settle. + m_rescan_timer.setSingleShot(true); + m_rescan_timer.setInterval(250); + connect(&m_rescan_timer, &QTimer::timeout, this, &ScriptLibrary::rescan); + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, + &m_rescan_timer, qOverload<>(&QTimer::start)); + connect(&m_watcher, &QFileSystemWatcher::fileChanged, + &m_rescan_timer, qOverload<>(&QTimer::start)); + rescan(); +} + +/** + @brief ScriptLibrary::watch + Watch the folder and each script in it, or, while the folder does not + exist yet, the data folder it will be created in. +*/ +void ScriptLibrary::watch() +{ + if (!m_watcher.files().isEmpty()) m_watcher.removePaths(m_watcher.files()); + if (!m_watcher.directories().isEmpty()) m_watcher.removePaths(m_watcher.directories()); + + const QDir dir(folder()); + if (!dir.exists()) { + if (QFileInfo::exists(QETApp::dataDir())) { + m_watcher.addPath(QETApp::dataDir()); + } + return; + } + m_watcher.addPath(dir.path()); + QStringList files; + for (const Script &s : std::as_const(m_scripts)) files << s.path; + for (const QString &e : std::as_const(m_errors)) files << dir.filePath(e.section(QLatin1Char(':'), 0, 0)); + if (!files.isEmpty()) m_watcher.addPaths(files); +} + +void ScriptLibrary::rescan() +{ + QList