mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-03 01:04:13 +02:00
Script buttons: stored scripts become commands with an icon
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.<file name>, 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
+166
-23
@@ -18,6 +18,7 @@
|
||||
#include "qetdiagrameditor.h"
|
||||
#ifdef QET_HAS_SCRIPTING
|
||||
#include "scripting/qetscripting.h"
|
||||
#include "scripting/scriptlibrary.h"
|
||||
#endif
|
||||
#include <QCoreApplication>
|
||||
#include <QToolButton>
|
||||
@@ -81,7 +82,9 @@
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QDesktopServices>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
#include <algorithm>
|
||||
#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.<file name>: 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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<QAction *> 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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef SCRIPTHEADER_H
|
||||
#define SCRIPTHEADER_H
|
||||
|
||||
#include <QRegularExpression>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
/**
|
||||
@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:<theme icon>)
|
||||
// @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.<id>
|
||||
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
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "scriptlibrary.h"
|
||||
|
||||
#include "../qetapp.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
|
||||
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<Script> scripts;
|
||||
QStringList errors;
|
||||
const QDir dir(folder());
|
||||
const QFileInfoList infos = dir.entryInfoList({QStringLiteral("*.js")},
|
||||
QDir::Files, QDir::Name);
|
||||
for (const QFileInfo &info : infos) {
|
||||
QFile file(info.filePath());
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) continue;
|
||||
const ScriptHeader header = ScriptHeader::parse(
|
||||
QString::fromUtf8(file.readAll()), info.completeBaseName());
|
||||
if (header.isValid()) {
|
||||
scripts << Script{header, info.filePath()};
|
||||
} else {
|
||||
errors << info.fileName() + QStringLiteral(": ") + header.error;
|
||||
}
|
||||
}
|
||||
|
||||
auto signature = [](const QList<Script> &list) {
|
||||
QStringList parts;
|
||||
for (const Script &s : list) {
|
||||
const ScriptHeader &h = s.header;
|
||||
parts << QStringList{s.path, h.name, h.icon, h.tooltip, h.shortcut,
|
||||
h.context}.join(QLatin1Char('\x1f'));
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
const bool same = signature(scripts) == signature(m_scripts) && errors == m_errors;
|
||||
m_scripts = scripts;
|
||||
m_errors = errors;
|
||||
watch();
|
||||
//The script's own text is read again on every run, so only a
|
||||
//change to what its button shows needs the actions rebuilt --
|
||||
//except an icon file edited in place, which this cannot see; the
|
||||
//next change to the folder picks it up.
|
||||
if (!same) emit changed();
|
||||
}
|
||||
|
||||
QList<ScriptLibrary::Script> ScriptLibrary::scripts() const
|
||||
{
|
||||
return m_scripts;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ScriptLibrary::errors
|
||||
One "file.js: reason" per script whose header could not be used, so
|
||||
whoever wrote it can be told why its button is missing.
|
||||
*/
|
||||
QStringList ScriptLibrary::errors() const
|
||||
{
|
||||
return m_errors;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ScriptLibrary::icon
|
||||
The icon a script's header names -- "builtin:<name>" from the icon
|
||||
theme, or an image file, relative to the script -- or, failing that, a
|
||||
tile with the script's initials, so every button can be told apart.
|
||||
*/
|
||||
QIcon ScriptLibrary::icon(const Script &script)
|
||||
{
|
||||
const QString spec = script.header.icon;
|
||||
if (spec.startsWith(QLatin1String("builtin:"))) {
|
||||
const QIcon themed = QIcon::fromTheme(spec.mid(8));
|
||||
if (!themed.isNull()) return themed;
|
||||
} else if (!spec.isEmpty()) {
|
||||
const QString path = QFileInfo(script.path).dir().absoluteFilePath(spec);
|
||||
if (QFileInfo::exists(path)) {
|
||||
const QIcon file_icon(path);
|
||||
if (!file_icon.availableSizes().isEmpty()
|
||||
|| !file_icon.pixmap(32).isNull()) {
|
||||
return file_icon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QString initials;
|
||||
for (const QString &word : script.header.name.split(QLatin1Char(' '), Qt::SkipEmptyParts)) {
|
||||
if (word.at(0).isLetterOrNumber()) initials += word.at(0).toUpper();
|
||||
if (initials.size() == 2) break;
|
||||
}
|
||||
if (initials.isEmpty()) initials = QStringLiteral("JS");
|
||||
|
||||
const QPalette palette = QGuiApplication::palette();
|
||||
QPixmap pixmap(64, 64);
|
||||
pixmap.fill(Qt::transparent);
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(palette.color(QPalette::Highlight));
|
||||
painter.drawRoundedRect(QRectF(4, 4, 56, 56), 10, 10);
|
||||
QFont font = painter.font();
|
||||
font.setBold(true);
|
||||
font.setPixelSize(initials.size() == 1 ? 34 : 26);
|
||||
painter.setFont(font);
|
||||
painter.setPen(palette.color(QPalette::HighlightedText));
|
||||
painter.drawText(QRectF(4, 4, 56, 56), Qt::AlignCenter, initials);
|
||||
painter.end();
|
||||
return QIcon(pixmap);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef SCRIPTLIBRARY_H
|
||||
#define SCRIPTLIBRARY_H
|
||||
|
||||
#include "scriptheader.h"
|
||||
|
||||
#include <QFileSystemWatcher>
|
||||
#include <QIcon>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QTimer>
|
||||
|
||||
/**
|
||||
@brief The ScriptLibrary class
|
||||
The user's stored scripts: every .js file with a ScriptHeader in
|
||||
folder(). The folder is the whole interface. A script written by hand
|
||||
in any editor, one saved by the script manager and one installed by an
|
||||
assistant through the qet MCP server all arrive the same way, as a file,
|
||||
and the library notices it with a QFileSystemWatcher and emits changed().
|
||||
|
||||
One instance for the application, shared by every editor window.
|
||||
*/
|
||||
class ScriptLibrary : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
struct Script
|
||||
{
|
||||
ScriptHeader header;
|
||||
QString path;
|
||||
};
|
||||
|
||||
static ScriptLibrary &instance();
|
||||
static QString folder();
|
||||
static QString actionId(const QString &script_id);
|
||||
static QIcon icon(const Script &script);
|
||||
|
||||
QList<Script> scripts() const;
|
||||
QStringList errors() const;
|
||||
|
||||
signals:
|
||||
void changed();
|
||||
|
||||
private:
|
||||
ScriptLibrary();
|
||||
void rescan();
|
||||
void watch();
|
||||
|
||||
QFileSystemWatcher m_watcher;
|
||||
QTimer m_rescan_timer;
|
||||
QList<Script> m_scripts;
|
||||
QStringList m_errors;
|
||||
};
|
||||
|
||||
#endif // SCRIPTLIBRARY_H
|
||||
@@ -95,6 +95,33 @@ void ShortcutManager::registerAction(QObject *target, const QString &id,
|
||||
target->setProperty("shortcut", QVariant::fromValue(savedSequence(id, it->default_sequence)));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ShortcutManager::unregisterAction
|
||||
Forget \a target under \a id, and the id itself once no live target is
|
||||
left. For commands that come and go while the application runs, such
|
||||
as a stored script deleted from its folder: without this its id stays
|
||||
listed in the shortcut settings, the shortcut bar and command search,
|
||||
and its first description stays fixed even after it is renamed. The
|
||||
user's saved shortcut for the id is kept, so the command picks it up
|
||||
again if it comes back.
|
||||
*/
|
||||
void ShortcutManager::unregisterAction(QObject *target, const QString &id)
|
||||
{
|
||||
auto it = m_entries.find(id);
|
||||
if (it == m_entries.end()) {
|
||||
return;
|
||||
}
|
||||
QList<QPointer<QObject>> &targets = it->targets;
|
||||
targets.erase(std::remove_if(targets.begin(), targets.end(),
|
||||
[target](const QPointer<QObject> &t) {
|
||||
return t.isNull() || t.data() == target; }),
|
||||
targets.end());
|
||||
if (targets.isEmpty()) {
|
||||
m_entries.erase(it);
|
||||
m_order.removeAll(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@return every registered shortcut, in registration order, along with its
|
||||
currently effective sequence -- for display in the Shortcuts config page.
|
||||
|
||||
@@ -66,6 +66,8 @@ class ShortcutManager
|
||||
const QString &category,
|
||||
const QKeySequence &default_sequence);
|
||||
|
||||
void unregisterAction(QObject *target, const QString &id);
|
||||
|
||||
QList<ShortcutInfo> allShortcuts() const;
|
||||
QKeySequence currentSequence(const QString &id) const;
|
||||
void setSequence(const QString &id, const QKeySequence &sequence);
|
||||
|
||||
@@ -212,6 +212,18 @@ set_tests_properties(tst_commandsearch PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=o
|
||||
target_include_directories(tst_commandsearch PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_commandsearch PRIVATE Qt::Test Qt::Widgets)
|
||||
|
||||
# scriptheader.h is header-only: the // ==QETScript== block that turns a
|
||||
# stored script into a button; and ShortcutManager::unregisterAction(), which
|
||||
# takes a deleted script's command out of the lists again.
|
||||
add_executable(
|
||||
tst_scriptheader
|
||||
tst_scriptheader.cpp
|
||||
${QET_DIR}/sources/shortcutmanager.cpp)
|
||||
add_test(NAME tst_scriptheader COMMAND tst_scriptheader)
|
||||
set_tests_properties(tst_scriptheader PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||
target_include_directories(tst_scriptheader PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_scriptheader PRIVATE Qt::Test Qt::Widgets)
|
||||
|
||||
# QETSql::execReadOnly() -- read-only enforcement for every project-database
|
||||
# query, including the ones a .qet file carries. Compiles sqlreadonly.cpp
|
||||
# alone against its own in-memory QSQLITE connection, so the security
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#include <QtTest>
|
||||
|
||||
#include <QAction>
|
||||
|
||||
#include "scripting/scriptheader.h"
|
||||
#include "shortcutmanager.h"
|
||||
|
||||
// The header that turns a stored script into a button, and taking a
|
||||
// command out of ShortcutManager again when its script is deleted.
|
||||
class tst_scriptheader : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
static QString script(const QString &lines)
|
||||
{
|
||||
return QStringLiteral("// ==QETScript==\n") + lines
|
||||
+ QStringLiteral("// ==/QETScript==\nqet.log('x');\n");
|
||||
}
|
||||
|
||||
private slots:
|
||||
void fullHeader()
|
||||
{
|
||||
const ScriptHeader h = ScriptHeader::parse(script(QStringLiteral(
|
||||
"// @name Add revision note\n"
|
||||
"// @icon note.svg\n"
|
||||
"// @tooltip Puts a note on the folio\n"
|
||||
"// @shortcut Ctrl+Alt+R\n"
|
||||
"// @context selection\n"
|
||||
"// @api 1\n")), QStringLiteral("add-note"));
|
||||
QVERIFY2(h.isValid(), qPrintable(h.error));
|
||||
QCOMPARE(h.id, QStringLiteral("add-note"));
|
||||
QCOMPARE(h.name, QStringLiteral("Add revision note"));
|
||||
QCOMPARE(h.icon, QStringLiteral("note.svg"));
|
||||
QCOMPARE(h.tooltip, QStringLiteral("Puts a note on the folio"));
|
||||
QCOMPARE(h.shortcut, QStringLiteral("Ctrl+Alt+R"));
|
||||
QCOMPARE(h.context, QStringLiteral("selection"));
|
||||
QCOMPARE(h.api, 1);
|
||||
}
|
||||
|
||||
void nameAloneIsEnough()
|
||||
{
|
||||
const ScriptHeader h = ScriptHeader::parse(
|
||||
script(QStringLiteral("// @name Just a name\n")), QStringLiteral("a"));
|
||||
QVERIFY2(h.isValid(), qPrintable(h.error));
|
||||
QCOMPARE(h.context, QStringLiteral("canvas"));
|
||||
QVERIFY(h.icon.isEmpty());
|
||||
}
|
||||
|
||||
void refused_data()
|
||||
{
|
||||
QTest::addColumn<QString>("text");
|
||||
QTest::addColumn<QString>("error");
|
||||
QTest::newRow("no header") << QStringLiteral("qet.log('x');\n")
|
||||
<< QStringLiteral("no // ==QETScript== header");
|
||||
QTest::newRow("no name") << script(QStringLiteral("// @icon x.svg\n"))
|
||||
<< QStringLiteral("@name is required");
|
||||
QTest::newRow("typo") << script(QStringLiteral("// @name A\n// @shortcutt Ctrl+K\n"))
|
||||
<< QStringLiteral("unknown header key @shortcutt");
|
||||
QTest::newRow("context") << script(QStringLiteral("// @name A\n// @context wires\n"))
|
||||
<< QStringLiteral("@context must be one of: canvas, selection, conductor");
|
||||
QTest::newRow("api") << script(QStringLiteral("// @name A\n// @api 2\n"))
|
||||
<< QStringLiteral("@api 2 is not supported by this version (1 is)");
|
||||
}
|
||||
|
||||
void refused()
|
||||
{
|
||||
QFETCH(QString, text);
|
||||
QFETCH(QString, error);
|
||||
const ScriptHeader h = ScriptHeader::parse(text, QStringLiteral("a"));
|
||||
QVERIFY(!h.isValid());
|
||||
QCOMPARE(h.error, error);
|
||||
}
|
||||
|
||||
// Only the header block counts: a "// @name" further down is code.
|
||||
void onlyTheBlock()
|
||||
{
|
||||
const ScriptHeader h = ScriptHeader::parse(
|
||||
script(QStringLiteral("// @name Real\n"))
|
||||
+ QStringLiteral("// @name Not this one\n// @bogus key\n"),
|
||||
QStringLiteral("a"));
|
||||
QVERIFY2(h.isValid(), qPrintable(h.error));
|
||||
QCOMPARE(h.name, QStringLiteral("Real"));
|
||||
}
|
||||
|
||||
// Two windows register one id; the id stays until both are gone, and a
|
||||
// script that comes back under a new name is listed by that name.
|
||||
void unregisterAction()
|
||||
{
|
||||
ShortcutManager &m = ShortcutManager::instance();
|
||||
const QString id = QStringLiteral("diagrameditor.script.tst");
|
||||
auto listed = [&m, &id]() -> QString {
|
||||
for (const ShortcutManager::ShortcutInfo &info : m.allShortcuts())
|
||||
if (info.id == id) return info.description;
|
||||
return QString();
|
||||
};
|
||||
|
||||
QAction a(QStringLiteral("Old name")), b(QStringLiteral("Old name"));
|
||||
m.registerAction(&a, id, QStringLiteral("Scripts"), QKeySequence());
|
||||
m.registerAction(&b, id, QStringLiteral("Scripts"), QKeySequence());
|
||||
QCOMPARE(listed(), QStringLiteral("Old name"));
|
||||
|
||||
m.unregisterAction(&a, id);
|
||||
QCOMPARE(listed(), QStringLiteral("Old name"));
|
||||
QCOMPARE(m.action(id, nullptr), &b);
|
||||
|
||||
m.unregisterAction(&b, id);
|
||||
QVERIFY(listed().isEmpty());
|
||||
QCOMPARE(m.action(id, nullptr), nullptr);
|
||||
|
||||
QAction c(QStringLiteral("New name"));
|
||||
m.registerAction(&c, id, QStringLiteral("Scripts"), QKeySequence());
|
||||
QCOMPARE(listed(), QStringLiteral("New name"));
|
||||
m.unregisterAction(&c, id);
|
||||
|
||||
// unknown id: nothing to do, nothing breaks
|
||||
m.unregisterAction(&c, QStringLiteral("diagrameditor.script.none"));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(tst_scriptheader)
|
||||
#include "tst_scriptheader.moc"
|
||||
Reference in New Issue
Block a user