From be914b320808d579530896832fb6a5e709e7348c Mon Sep 17 00:00:00 2001 From: Hugues Delorme Date: Thu, 27 Jun 2024 19:13:03 +0200 Subject: [PATCH] App: introduce DialogExecScript to let user control script execution --- src/app/commands_tools.cpp | 76 ++++------ src/app/commands_tools.h | 14 +- src/app/dialog_exec_script.cpp | 210 +++++++++++++++++++++++++++ src/app/dialog_exec_script.h | 65 +++++++++ src/app/dialog_exec_script.ui | 112 ++++++++++++++ src/app/mainwindow.cpp | 7 +- src/base/cpp_utils.h | 4 +- src/qtcommon/log_message_handler.cpp | 29 +++- src/qtcommon/log_message_handler.h | 23 ++- 9 files changed, 470 insertions(+), 70 deletions(-) create mode 100644 src/app/dialog_exec_script.cpp create mode 100644 src/app/dialog_exec_script.h create mode 100644 src/app/dialog_exec_script.ui diff --git a/src/app/commands_tools.cpp b/src/app/commands_tools.cpp index 55afb297..466d4283 100644 --- a/src/app/commands_tools.cpp +++ b/src/app/commands_tools.cpp @@ -11,21 +11,19 @@ #include "../gui/gui_application.h" #include "../gui/gui_document.h" #include "../qtcommon/filepath_conv.h" +#include "../qtscripting/script_global.h" #include "app_module.h" +#include "dialog_exec_script.h" #include "dialog_inspect_xde.h" #include "dialog_options.h" #include "dialog_save_image_view.h" #include "qtwidgets_utils.h" #include "theme.h" -#include -#include - #include -#include #include -#include #include +#include namespace Mayo { @@ -106,38 +104,11 @@ void CommandEditOptions::execute() QtWidgetsUtils::asyncDialogExec(dlg); } -namespace { - -void evaluateScript(QJSEngine* jsEngine, const FilePath& filepathScript) -{ - if (!jsEngine) - return; - - auto fnJsEvaluate = [](QJSEngine* jsEngine, const QString& program) { - auto jsVal = jsEngine->evaluate(program); - if (jsVal.isError()) { - qCritical() << "Error at line" - << jsVal.property("lineNumber").toInt() - << ":" << jsVal.toString(); - } - - return jsVal; - }; - - QFile jsFile(filepathTo(filepathScript)); - if (jsFile.open(QIODevice::ReadOnly)) - fnJsEvaluate(jsEngine, jsFile.readAll()); -} - -} // namespace - -CommandExecScript::CommandExecScript(IAppContext* context, QJSEngine* jsEngine) - : Command(context), - m_jsEngine(jsEngine) +CommandExecScript::CommandExecScript(IAppContext* context) + : Command(context) { auto action = new QAction(this); action->setText(Command::tr("Execute Script...")); - //action->setToolTip(Command::tr("Options")); this->setAction(action); } @@ -146,31 +117,39 @@ void CommandExecScript::execute() auto strFilePath = QFileDialog::getOpenFileName( this->widgetMain(), Command::tr("Choose JavaScript file"), - QString(/*dir*/), + QString{}/*dir*/, Command::tr("Script files(*.js)") ); - if (strFilePath.isEmpty()) - return; + if (!strFilePath.isEmpty()) + CommandExecScript::runScript(this->context(), filepathFrom(strFilePath)); +} - evaluateScript(m_jsEngine, filepathFrom(strFilePath)); - AppModule::get()->prependRecentScript(filepathFrom(strFilePath)); +void CommandExecScript::runScript(IAppContext* context, const FilePath& scriptFilePath) +{ + auto dlg = new DialogExecScript(context->widgetMain()); + dlg->setScriptEngineCreator([=](QObject* parent) { + return createScriptEngine(context->guiApp()->application(), parent); + }); + dlg->setScriptFilePath(scriptFilePath); + QtWidgetsUtils::asyncDialogExec(dlg); + dlg->startScript(); + AppModule::get()->prependRecentScript(scriptFilePath); } -CommandExecRecentScript::CommandExecRecentScript(IAppContext* context, QJSEngine* jsEngine) - : Command(context), - m_jsEngine(jsEngine) +CommandExecRecentScript::CommandExecRecentScript(IAppContext* context) + : Command(context) { auto action = new QAction(this); action->setText(Command::tr("Execute Recent Script")); this->setAction(action); } -CommandExecRecentScript::CommandExecRecentScript( - IAppContext* context, QMenu* containerMenu, QJSEngine* jsEngine - ) - : CommandExecRecentScript(context, jsEngine) +CommandExecRecentScript::CommandExecRecentScript(IAppContext* context, QMenu* containerMenu) + : CommandExecRecentScript(context) { - QObject::connect(containerMenu, &QMenu::aboutToShow, this, &CommandExecRecentScript::recreateEntries); + QObject::connect( + containerMenu, &QMenu::aboutToShow, this, &CommandExecRecentScript::recreateEntries + ); } void CommandExecRecentScript::execute() @@ -192,8 +171,7 @@ void CommandExecRecentScript::recreateEntries() const QString strFileName = filepathTo(recentScript.filepath.filename()); const QString strEntryRecentScript = Command::tr("%1 | %2").arg(++idFile).arg(strFileName); auto action = menu->addAction(strEntryRecentScript, this, [=]{ - evaluateScript(m_jsEngine, recentScript.filepath); - AppModule::get()->prependRecentScript(recentScript.filepath); + CommandExecScript::runScript(this->context(), recentScript.filepath); }); QDateTime dateTimeLastExec; dateTimeLastExec.setTimeSpec(Qt::UTC); diff --git a/src/app/commands_tools.h b/src/app/commands_tools.h index 23124124..a167af7c 100644 --- a/src/app/commands_tools.h +++ b/src/app/commands_tools.h @@ -40,26 +40,22 @@ class CommandEditOptions : public Command { class CommandExecScript : public Command { public: - CommandExecScript(IAppContext* context, QJSEngine* jsEngine); + CommandExecScript(IAppContext* context); void execute() override; - static constexpr std::string_view Name = "exec-script"; + static void runScript(IAppContext* context, const FilePath& scriptFilePath); -private: - QJSEngine* m_jsEngine = nullptr; + static constexpr std::string_view Name = "exec-script"; }; class CommandExecRecentScript : public Command { public: - CommandExecRecentScript(IAppContext* context, QJSEngine* jsEngine); - CommandExecRecentScript(IAppContext* context, QMenu* containerMenu, QJSEngine* jsEngine); + CommandExecRecentScript(IAppContext* context); + CommandExecRecentScript(IAppContext* context, QMenu* containerMenu); void execute() override; void recreateEntries(); static constexpr std::string_view Name = "exec-script-recent"; - -private: - QJSEngine* m_jsEngine = nullptr; }; } // namespace Mayo diff --git a/src/app/dialog_exec_script.cpp b/src/app/dialog_exec_script.cpp new file mode 100644 index 00000000..2a7f3a9d --- /dev/null +++ b/src/app/dialog_exec_script.cpp @@ -0,0 +1,210 @@ +/**************************************************************************** +** Copyright (c) 2024, Fougue Ltd. +** All rights reserved. +** See license at https://github.com/fougue/mayo/blob/master/LICENSE.txt +****************************************************************************/ + +#include "dialog_exec_script.h" + +#include "qtwidgets_utils.h" +#include "../qtcommon/filepath_conv.h" +#include "../qtcommon/log_message_handler.h" +#include "../qtcommon/qtcore_utils.h" +#include "../qtscripting/script_global.h" +#include "ui_dialog_exec_script.h" + +#include +#include +#include +#include +#include + +namespace Mayo { + +namespace { + +QString scriptProgram(const QString& strFilepath) +{ + QFile file(strFilepath); + if (file.open(QIODevice::ReadOnly)) + return file.readAll(); + + return {}; +} + +} // namespace + +DialogExecScript::DialogExecScript(QWidget* parent) + : QDialog(parent), + m_ui(new Ui_DialogExecScript) +{ + m_ui->setupUi(this); + // TODO Don't forget to disconnect those slots in destructor(maybe not needed as m_taskMgr will be out of scope) + m_taskMgr.signalStarted.connectSlot(&DialogExecScript::onTaskStarted, this); + m_taskMgr.signalEnded.connectSlot(&DialogExecScript::onTaskEnded, this); + QObject::connect( + m_ui->btn_restartStop, &QAbstractButton::clicked, + this, &DialogExecScript::restartOrStopScriptExec + ); + QObject::connect( + m_ui->buttonBox->button(QDialogButtonBox::Close), &QAbstractButton::clicked, + this, &DialogExecScript::tryCloseDialog + ); +} + +DialogExecScript::~DialogExecScript() +{ + delete m_ui; + if (m_jsEngine) + m_jsEngine->deleteLater(); +} + +void DialogExecScript::setScriptEngineCreator(ScriptEngineCreator fn) +{ + m_fnScriptEngineCreator = std::move(fn); +} + +void DialogExecScript::setScriptFilePath(const FilePath& scriptFilePath) +{ + m_scriptFilePath = QDir::toNativeSeparators(filepathTo(scriptFilePath)); +} + +void DialogExecScript::startScript() +{ + if (m_scriptExecIsRunning) + return; + + m_scriptExecTaskId = m_taskMgr.newTask([=](TaskProgress*) { + // Override the "console output" handler of LogMessageHandler, first keep the current handler + // so it can be restored before exiting + auto& logMsgHandler = LogMessageHandler::instance(); + auto onEntryJsConsoleOutputHandler = logMsgHandler.jsConsoleOutputHandler(); + auto _ = gsl::finally([&]{ + logMsgHandler.setJsConsoleOutputHandler(onEntryJsConsoleOutputHandler); + }); + auto fnAddConsoleOutput = [=](QtMsgType type, const QString& text, const QString& file, int line) { + const Message msg = { type, text, file, line }; + QtCoreUtils::runJobOnMainThread([=]{ this->addConsoleOutput(msg); }); + }; + logMsgHandler.setJsConsoleOutputHandler( + [=](QtMsgType type, const QMessageLogContext& context, const QString& text) { + const QString strFile = context.file ? context.file : ""; + fnAddConsoleOutput(type, text, strFile, context.line); + } + ); + + // Evaluate script program + this->recreateScriptEngine(); + auto jsVal = m_jsEngine->evaluate(scriptProgram(m_scriptFilePath), m_scriptFilePath); + if (jsVal.isError()) { + const QString name = jsVal.property("name").toString(); + const QString message = jsVal.property("message").toString(); + fnAddConsoleOutput( + QtCriticalMsg, + tr("%1: %2").arg(name, message), + jsVal.property("fileName").toString(), + jsVal.property("lineNumber").toInt() + ); + } + }); + m_taskMgr.run(m_scriptExecTaskId); +} + +void DialogExecScript::onTaskStarted(TaskId taskId) +{ + if (m_scriptExecTaskId != taskId) + return; + + m_scriptExecIsRunning = true; + m_wasScriptExecInterrupted = false; + m_ui->label_Status->setText(tr("Executing '%1'...").arg(m_scriptFilePath)); + m_ui->btn_restartStop->setText(tr("Stop")); + m_ui->progressBar_Execution->setRange(0, 0); + m_ui->progressBar_Execution->setValue(-1); + m_ui->treeWidget_Output->clear(); +} + +void DialogExecScript::onTaskEnded(TaskId taskId) +{ + if (m_scriptExecTaskId != taskId) + return; + + m_scriptExecIsRunning = false; + if (!m_wasScriptExecInterrupted) + m_ui->label_Status->setText(tr("Finished '%1'").arg(m_scriptFilePath)); + else + m_ui->label_Status->setText(tr("Stopped '%1'").arg(m_scriptFilePath)); + + m_ui->btn_restartStop->setText(tr("Restart")); + m_ui->progressBar_Execution->setRange(0, 100); + m_ui->progressBar_Execution->setValue(!m_wasScriptExecInterrupted ? 100 : 0); + m_jsEngine->setInterrupted(false); + + for (int col = 0; col < m_ui->treeWidget_Output->columnCount(); ++col) + m_ui->treeWidget_Output->resizeColumnToContents(col); +} + +void DialogExecScript::interruptScriptExec() +{ + m_wasScriptExecInterrupted = true; + m_jsEngine->setInterrupted(true); +} + +void DialogExecScript::restartOrStopScriptExec() +{ + if (m_scriptExecIsRunning) + this->interruptScriptExec(); + else + this->startScript(); +} + +void DialogExecScript::recreateScriptEngine() +{ + delete m_jsEngine; + m_jsEngine = m_fnScriptEngineCreator(nullptr); +} + +void DialogExecScript::addConsoleOutput(const Message& msg) +{ + auto fnStrMsgType = [](QtMsgType type) -> QString { + switch (type) { + case QtDebugMsg: return tr("debug"); + case QtWarningMsg: return tr("warning"); + case QtCriticalMsg: return tr("critical"); + case QtFatalMsg: return tr("fatal"); + case QtInfoMsg: return tr("info"); + default: return tr("?"); + } + }; + + auto item = new QTreeWidgetItem; + item->setText(0, fnStrMsgType(msg.type)); + item->setText(1, msg.text); + item->setText(2, QFileInfo(msg.contextFile).fileName()); + item->setText(3, QString::number(msg.contextLine)); + m_ui->treeWidget_Output->addTopLevelItem(item); +} + +void DialogExecScript::tryCloseDialog() +{ + if (m_scriptExecIsRunning) { + auto msgBox = QtWidgetsUtils::asyncMsgBoxWarning( + this, + tr("Warning"), + tr("Script execution isn't finished\n\nInterrupt and exit?"), + QMessageBox::Yes | QMessageBox::No + ); + QObject::connect(msgBox, &QMessageBox::buttonClicked, this, [=](QAbstractButton* btn) { + if (btn == msgBox->button(QMessageBox::Yes)) { + this->interruptScriptExec(); + m_taskMgr.waitForDone(m_scriptExecTaskId, 10000/*ms*/); + QtCoreUtils::runJobOnMainThread([=]{ this->tryCloseDialog(); }); + } + }); + } + else { + this->reject(); + } +} + +} // namespace Mayo diff --git a/src/app/dialog_exec_script.h b/src/app/dialog_exec_script.h new file mode 100644 index 00000000..d70bb3ff --- /dev/null +++ b/src/app/dialog_exec_script.h @@ -0,0 +1,65 @@ +/**************************************************************************** +** Copyright (c) 2024, Fougue Ltd. +** All rights reserved. +** See license at https://github.com/fougue/mayo/blob/master/LICENSE.txt +****************************************************************************/ + +#include "../base/filepath.h" +#include "../base/task_manager.h" + +#include +#include + +class QJSEngine; + +namespace Mayo { + +// Provides a dialog to control execution of a JS script +class DialogExecScript : public QDialog { + Q_OBJECT +public: + // Ctor & dtor + DialogExecScript(QWidget* parent = nullptr); + ~DialogExecScript(); + + // Set the function used to create a JS engine to evaluate scripts + using ScriptEngineCreator = std::function; + void setScriptEngineCreator(ScriptEngineCreator fn); + + // Path to the script file to be executed + void setScriptFilePath(const FilePath& scriptFilePath); + + // Asynchronous execution of script file defined with setScriptFilePath() + // Does nothing(returns) if script execution is currently running + void startScript(); + +private: + void onTaskStarted(TaskId taskId); + void onTaskEnded(TaskId taskId); + + void interruptScriptExec(); + void restartOrStopScriptExec(); + void recreateScriptEngine(); + + struct Message { + QtMsgType type = QtDebugMsg; + QString text; + QString contextFile; + int contextLine = -1; + }; + + void addConsoleOutput(const Message& msg); + + void tryCloseDialog(); + + class Ui_DialogExecScript* m_ui = nullptr; + ScriptEngineCreator m_fnScriptEngineCreator; + QJSEngine* m_jsEngine = nullptr; + QString m_scriptFilePath; + TaskManager m_taskMgr; + TaskId m_scriptExecTaskId = TaskId_null; + bool m_wasScriptExecInterrupted = false; + bool m_scriptExecIsRunning = false; +}; + +} // namespace Mayo diff --git a/src/app/dialog_exec_script.ui b/src/app/dialog_exec_script.ui new file mode 100644 index 00000000..056e4d73 --- /dev/null +++ b/src/app/dialog_exec_script.ui @@ -0,0 +1,112 @@ + + + Mayo::DialogExecScript + + + + 0 + 0 + 737 + 450 + + + + Script Execution + + + true + + + + + + + 0 + 0 + + + + Executing %1... + + + + + + + + 1 + 0 + + + + 0 + + + -1 + + + false + + + + + + + Stop + + + + + + + 0 + + + false + + + false + + + false + + + false + + + false + + + + Type + + + + + Message + + + + + File + + + + + Line + + + + + + + + QDialogButtonBox::Close + + + + + + + + diff --git a/src/app/mainwindow.cpp b/src/app/mainwindow.cpp index 5b357949..4295b20b 100644 --- a/src/app/mainwindow.cpp +++ b/src/app/mainwindow.cpp @@ -11,7 +11,6 @@ #include "../base/global.h" #include "../gui/gui_application.h" #include "../gui/gui_document.h" -#include "../qtscripting/script_global.h" #include "app_context.h" #include "app_module.h" #include "commands_file.h" @@ -99,8 +98,6 @@ void MainWindow::addPage(IAppContext::Page page, IWidgetMainPage* pageWidget) void MainWindow::createCommands() { - auto jsEngine = createScriptEngine(m_guiApp->application(), this); - // "File" commands this->addCommand(); this->addCommand(); @@ -126,8 +123,8 @@ void MainWindow::createCommands() this->addCommand(); this->addCommand(); this->addCommand(); - this->addCommand(jsEngine); - this->addCommand(m_ui->menu_Tools, jsEngine); + this->addCommand(); + this->addCommand(m_ui->menu_Tools); // "Window" commands this->addCommand(); diff --git a/src/base/cpp_utils.h b/src/base/cpp_utils.h index 6e47a491..04f78a3a 100644 --- a/src/base/cpp_utils.h +++ b/src/base/cpp_utils.h @@ -94,8 +94,8 @@ const std::string& nullString() template MappedType findValue( - const KeyType& key, const AssociativeContainer& container -) + const KeyType& key, const AssociativeContainer& container + ) { auto it = container.find(key); if (it != container.cend()) { diff --git a/src/qtcommon/log_message_handler.cpp b/src/qtcommon/log_message_handler.cpp index e34ad0e6..ba9df91b 100644 --- a/src/qtcommon/log_message_handler.cpp +++ b/src/qtcommon/log_message_handler.cpp @@ -7,6 +7,7 @@ #include "log_message_handler.h" #include "qstring_conv.h" +#include #include namespace Mayo { @@ -31,19 +32,41 @@ void LogMessageHandler::setOutputFilePath(const FilePath& fp) m_outputFile.close(); } -std::ostream& LogMessageHandler::outputStream(QtMsgType type) +std::ostream& LogMessageHandler::outputStream(QtMsgType msgType) { if (!m_outputFilePath.empty() && m_outputFile.is_open()) return m_outputFile; - if (type == QtDebugMsg || type == QtInfoMsg) + if (msgType == QtDebugMsg || msgType == QtInfoMsg) return std::cout; return std::cerr; } -void LogMessageHandler::qtHandler(QtMsgType type, const QMessageLogContext& /*context*/, const QString& msg) +const LogMessageHandler::JsConsoleOutputHandler& LogMessageHandler::jsConsoleOutputHandler() const { + return m_jsConsoleOutputHandler; +} + +void LogMessageHandler::setJsConsoleOutputHandler(JsConsoleOutputHandler fnHandler) +{ + m_jsConsoleOutputHandler = std::move(fnHandler); +} + +void LogMessageHandler::qtHandler(QtMsgType type, const QMessageLogContext& context, const QString& msg) +{ + // Use QMessageLogContext::category and check it's "qml" or "js" to maybe transfer message to + // another handler + const auto& jsConsoleOutputHandler = LogMessageHandler::instance().jsConsoleOutputHandler(); + if ( + jsConsoleOutputHandler + && (std::strcmp(context.category, "qml") == 0 || std::strcmp(context.category, "js") == 0) + ) + { + jsConsoleOutputHandler(type, context, msg); + return; + } + const std::string localMsg = consoleToPrintable(msg); std::ostream& outs = LogMessageHandler::instance().outputStream(type); switch (type) { diff --git a/src/qtcommon/log_message_handler.h b/src/qtcommon/log_message_handler.h index d23bc8c5..c2d97896 100644 --- a/src/qtcommon/log_message_handler.h +++ b/src/qtcommon/log_message_handler.h @@ -12,20 +12,37 @@ #include #include +#include namespace Mayo { -// Provides customization of Qt message handler +// Provides customization of Qt messages handling class LogMessageHandler { public: + // LogMessageHandler is a singleton static LogMessageHandler& instance(); + // Enable debug messages(QtDebugMsg type) to be logged to output + // If 'off' then debug messages are skipped('on' by default) void enableDebugLogs(bool on); + + // Log all messages to some output file + // If 'fp' is empty then default output is used(ie std::cout/cerr objects) void setOutputFilePath(const FilePath& fp); - std::ostream& outputStream(QtMsgType type); + // Return the output stream used to log messages of type 'msgType' + std::ostream& outputStream(QtMsgType msgType); + + // Override handling of JS console messages with a callback function + // JS console messages are identified by category "qml" and "js" + // When a callback function is specified then such messages are "transferred" to that function(no + // callback by default) + using JsConsoleOutputHandler = std::function; + const JsConsoleOutputHandler& jsConsoleOutputHandler() const; + void setJsConsoleOutputHandler(JsConsoleOutputHandler fnHandler); // Function called for Qt message handling + // Pointer to this function should be passed to qInstallMessageHandler() static void qtHandler(QtMsgType type, const QMessageLogContext& context, const QString& msg); private: @@ -34,5 +51,7 @@ class LogMessageHandler { FilePath m_outputFilePath; std::ofstream m_outputFile; bool m_enableDebugLogs = true; + JsConsoleOutputHandler m_jsConsoleOutputHandler; }; + } // namespace Mayo