diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 6d792124b..5b3e401a4 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -86,6 +86,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -3937,6 +3942,55 @@ void QETDiagramEditor::setUpLiveIndicator() }; connect(&LiveServer::instance(), &LiveServer::stateChanged, box, update); update(LiveServer::instance().state()); + + //The Assistant dock: every action, and whether to be asked first + auto *dock = new QDockWidget(tr("Assistant"), this); + dock->setObjectName(QStringLiteral("assistant_dock")); + auto *content = new QWidget(dock); + auto *dock_layout = new QVBoxLayout(content); + auto *ask = new QCheckBox(tr("Demander avant d'exécuter un script écrit par l'assistant"), content); + ask->setChecked(LiveServer::instance().askFirst()); + ask->setToolTip(tr("Pour cette session seulement : chaque démarrage redemande")); + auto *log = new QListWidget(content); + log->setWordWrap(true); + dock_layout->addWidget(ask); + dock_layout->addWidget(log); + dock->setWidget(content); + addDockWidget(Qt::RightDockWidgetArea, dock); + dock->hide(); + connect(ask, &QCheckBox::toggled, &LiveServer::instance(), &LiveServer::setAskFirst); + connect(&LiveServer::instance(), &LiveServer::askFirstChanged, ask, &QCheckBox::setChecked); + connect(&LiveServer::instance(), &LiveServer::stateChanged, dock, + [dock](LiveServer::State state) { + if (state == LiveServer::Waiting) dock->show(); + else if (state == LiveServer::Off) dock->hide(); + }); + connect(&LiveServer::instance(), &LiveServer::handled, log, + [log](const QJsonObject &request, const QJsonObject &answer) { + const QString cmd = request.value(QStringLiteral("cmd")).toString(); + if (cmd == QLatin1String("status")) return; + const bool ok = answer.value(QStringLiteral("ok")).toBool(); + QString what = answer.value(QStringLiteral("undo")).toString(); + for (const char *key : {"name", "script", "action"}) + if (what.isEmpty()) what = request.value(QLatin1String(key)).toString(); + if (what.isEmpty()) what = cmd; + auto *item = new QListWidgetItem(QStringLiteral("%1 %2 %3") + .arg(QTime::currentTime().toString(QStringLiteral("HH:mm:ss")), + ok ? QStringLiteral("✓") : QStringLiteral("✗"), what)); + QStringList details; + if (!ok) details << answer.value(QStringLiteral("error")).toString(); + const QJsonArray lines = answer.value(QStringLiteral("log")).toArray(); + for (const QJsonValue &l : lines) details << l.toString(); + const QString source = request.value(QStringLiteral("source")).toString(); + if (!source.isEmpty()) details << QString() << source; + item->setToolTip(details.join(QLatin1Char('\n'))); + item->setData(Qt::UserRole, details.join(QLatin1Char('\n'))); + log->insertItem(0, item); + }); + connect(log, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) { + QET::QetMessageBox::information(this, tr("Assistant"), + item->data(Qt::UserRole).toString()); + }); //What the assistant just did, where the user is already looking connect(&LiveServer::instance(), &LiveServer::handled, box, [label](const QJsonObject &request, const QJsonObject &answer) { @@ -3947,6 +4001,10 @@ void QETDiagramEditor::setUpLiveIndicator() what = request.value(QStringLiteral("name")).toString(); if (what.isEmpty()) what = request.value(QStringLiteral("script")).toString(); + if (what.isEmpty()) + what = request.value(QStringLiteral("action")).toString(); + if (what.isEmpty()) + what = cmd; label->setText(tr("Mode direct : %1 %2 à %3") .arg(answer.value(QStringLiteral("ok")).toBool() ? QStringLiteral("✓") : QStringLiteral("✗"), diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index 42419c50c..76e292bd5 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -27,6 +27,7 @@ #include "../utils/qetsettings.h" #include +#include #include #include #include @@ -39,6 +40,16 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../projectview.h" +#include "../shortcutmanager.h" namespace { QString randomHex(int bytes) @@ -242,10 +253,25 @@ void LiveServer::handle(const QJsonObject &request) if (cmd == QLatin1String("status")) { answer = status(); } else if (cmd == QLatin1String("run_script")) { - answer = runScript(request.value(QStringLiteral("name")).toString(), - request.value(QStringLiteral("source")).toString()); + const QString name = request.value(QStringLiteral("name")).toString(); + const QString source = request.value(QStringLiteral("source")).toString(); + //A script the assistant just wrote: the user sees it first, + //unless they said "always" this session. A stored script + //is one the user already has, so it runs as a click would. + if (m_ask_first && !confirm(name, source)) + answer = failure(QStringLiteral("refused by the user")); + else + answer = runScript(name, source); } else if (cmd == QLatin1String("run_stored")) { answer = runStored(request.value(QStringLiteral("script")).toString()); + } else if (cmd == QLatin1String("command")) { + answer = command(request.value(QStringLiteral("action")).toString()); + } else if (cmd == QLatin1String("show_folio")) { + answer = showFolio(request.value(QStringLiteral("folio")).toInt(-1)); + } else if (cmd == QLatin1String("undo_last")) { + answer = undoLast(); + } else if (cmd == QLatin1String("screenshot")) { + answer = screenshot(); } else { answer = failure(QStringLiteral("unknown command: %1").arg(cmd)); } @@ -339,3 +365,140 @@ QJsonObject LiveServer::runStored(const QString &id) } return failure(QStringLiteral("no stored script with id %1").arg(id)); } + +void LiveServer::setAskFirst(bool ask) +{ + if (ask == m_ask_first) return; + m_ask_first = ask; + emit askFirstChanged(ask); +} + +/** + @brief LiveServer::allowedCommands + The editor commands an assistant may trigger: ones that open no dialog + (a dialog would wait for an answer nobody is there to give the + assistant) and change nothing that undo cannot take back. Saving, + printing, exporting, deleting, opening and closing are not in it: a + script can delete, as one undo step, and the rest stay the user's. +*/ +QStringList LiveServer::allowedCommands() +{ + static const QStringList ids{ + QStringLiteral("diagrameditor.select_all"), + QStringLiteral("diagrameditor.select_nothing"), + QStringLiteral("diagrameditor.select_invert"), + QStringLiteral("diagrameditor.select_all_conductors"), + QStringLiteral("diagrameditor.select_all_text_fields"), + QStringLiteral("diagrameditor.zoom_in"), + QStringLiteral("diagrameditor.zoom_out"), + QStringLiteral("diagrameditor.zoom_content"), + QStringLiteral("diagrameditor.zoom_fit"), + QStringLiteral("diagrameditor.zoom_reset"), + QStringLiteral("diagrameditor.rotate_selection"), + QStringLiteral("diagrameditor.rotate_texts"), + QStringLiteral("diagrameditor.snap_selection_to_grid"), + QStringLiteral("diagrameditor.group_selection"), + QStringLiteral("diagrameditor.ungroup_selection"), + QStringLiteral("diagrameditor.conductor_reset")}; + return ids; +} + +QJsonObject LiveServer::command(const QString &id) +{ + if (!allowedCommands().contains(id)) + return failure(QStringLiteral("%1 is not a command an assistant may use; allowed: %2") + .arg(id, allowedCommands().join(QStringLiteral(", ")))); + QETDiagramEditor *e = editor(); + QAction *action = e ? ShortcutManager::instance().action(id, e) : nullptr; + if (!action) return failure(QStringLiteral("no editor window has %1").arg(id)); + if (!action->isEnabled()) + return failure(QStringLiteral("%1 is not available right now (nothing " + "selected, or no project open)").arg(id)); + action->trigger(); + return {{QStringLiteral("ok"), true}, {QStringLiteral("action"), id}}; +} + +QJsonObject LiveServer::showFolio(int folio) +{ + QETDiagramEditor *e = editor(); + ProjectView *pv = e ? e->currentProjectView() : nullptr; + if (!pv) return failure(QStringLiteral("no project is open in QElectroTech")); + const QList diagrams = pv->project()->diagrams(); + if (folio < 0 || folio >= diagrams.count()) + return failure(QStringLiteral("no folio %1: the project has %2, counted from 0") + .arg(folio).arg(diagrams.count())); + pv->showDiagram(diagrams.at(folio)); + return {{QStringLiteral("ok"), true}, {QStringLiteral("folio"), folio}}; +} + +/** + @brief LiveServer::undoLast + Undo the newest step, only if the assistant made it: what the user did + by hand stays theirs to undo. +*/ +QJsonObject LiveServer::undoLast() +{ + QETDiagramEditor *e = editor(); + QETProject *project = e ? e->currentProject() : nullptr; + if (!project) return failure(QStringLiteral("no project is open in QElectroTech")); + QUndoStack *stack = project->undoStack(); + const QString text = stack->text(stack->index() - 1); + const QString prefix = tr("Assistant : %1").arg(QString()); + if (!stack->canUndo() || !text.startsWith(prefix)) + return failure(QStringLiteral("the last step is not the assistant's (\"%1\"); " + "only the user undoes their own").arg(text)); + stack->undo(); + return {{QStringLiteral("ok"), true}, {QStringLiteral("undone"), text}}; +} + +QJsonObject LiveServer::screenshot() +{ + QETDiagramEditor *e = editor(); + DiagramView *view = e ? e->currentDiagramView() : nullptr; + if (!view) return failure(QStringLiteral("no folio is shown in QElectroTech")); + const QPixmap pixmap = view->grab(); + QByteArray png; + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + pixmap.save(&buffer, "PNG"); + return {{QStringLiteral("ok"), true}, + {QStringLiteral("width"), pixmap.width()}, + {QStringLiteral("height"), pixmap.height()}, + {QStringLiteral("png_base64"), QString::fromLatin1(png.toBase64())}}; +} + +/** + @brief LiveServer::confirm + Show the user the script the assistant wants to run, and ask. + @return true to run it +*/ +bool LiveServer::confirm(const QString &name, const QString &source) +{ + QDialog dialog(editor()); + dialog.setWindowTitle(tr("L'assistant veut exécuter un script")); + auto *layout = new QVBoxLayout(&dialog); + layout->addWidget(new QLabel( + tr("« %1 » sur le projet ouvert. Une fois exécuté, Ctrl+Z l'annule.") + .arg(name.isEmpty() ? tr("script") : name), &dialog)); + auto *text = new QPlainTextEdit(source, &dialog); + text->setReadOnly(true); + text->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + text->setMinimumSize(520, 220); + layout->addWidget(text); + auto *buttons = new QDialogButtonBox(&dialog); + QPushButton *run = buttons->addButton(tr("&Exécuter"), QDialogButtonBox::AcceptRole); + buttons->addButton(tr("&Refuser"), QDialogButtonBox::RejectRole); + QPushButton *always = buttons->addButton(tr("&Toujours pour cette session"), + QDialogButtonBox::AcceptRole); + layout->addWidget(buttons); + QPushButton *clicked = nullptr; + connect(buttons, &QDialogButtonBox::clicked, &dialog, [&](QAbstractButton *b) { + clicked = qobject_cast(b); + }); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + run->setDefault(true); + dialog.exec(); + if (clicked == always) setAskFirst(false); + return clicked == run || clicked == always; +} diff --git a/sources/scripting/liveserver.h b/sources/scripting/liveserver.h index 7bf4db57d..dfc92dce9 100644 --- a/sources/scripting/liveserver.h +++ b/sources/scripting/liveserver.h @@ -61,11 +61,15 @@ class LiveServer : public QObject void askAndStart(QWidget *parent); void stop(); State state() const { return m_state; } + bool askFirst() const { return m_ask_first; } + void setAskFirst(bool ask); + static QStringList allowedCommands(); signals: void stateChanged(LiveServer::State state); /// A request was handled: what was asked, and the answer sent void handled(const QJsonObject &request, const QJsonObject &answer); + void askFirstChanged(bool ask); private: LiveServer(); @@ -79,6 +83,11 @@ class LiveServer : public QObject QJsonObject status(); QJsonObject runScript(const QString &name, const QString &source); QJsonObject runStored(const QString &id); + QJsonObject command(const QString &id); + QJsonObject showFolio(int folio); + QJsonObject undoLast(); + QJsonObject screenshot(); + bool confirm(const QString &name, const QString &source); QETDiagramEditor *editor() const; QLocalServer *m_server = nullptr; @@ -88,6 +97,7 @@ class LiveServer : public QObject State m_state = Off; bool m_asked = false; bool m_busy = false; + bool m_ask_first = true; ///< per session, never saved: every start asks again }; #endif // LIVESERVER_H