diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 1bf68b48d..b822eb726 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -285,6 +285,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/recentfiles.h ${QET_DIR}/sources/shortcutmanager.cpp ${QET_DIR}/sources/shortcutmanager.h + ${QET_DIR}/sources/commandsearchpopup.cpp + ${QET_DIR}/sources/commandsearchpopup.h ${QET_DIR}/sources/titleblockcell.cpp ${QET_DIR}/sources/titleblockcell.h ${QET_DIR}/sources/titleblockproperties.cpp diff --git a/sources/commandsearchpopup.cpp b/sources/commandsearchpopup.cpp new file mode 100644 index 000000000..57c1efbd1 --- /dev/null +++ b/sources/commandsearchpopup.cpp @@ -0,0 +1,236 @@ +/* + 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 "commandsearchpopup.h" + +#include "shortcutmanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +/** + @brief CommandSearchPopup::CommandSearchPopup + @param owner : the window whose commands are searched; also the parent +*/ +CommandSearchPopup::CommandSearchPopup(QWidget *owner) : + QFrame(owner, Qt::Popup) +{ + setFrameShape(QFrame::StyledPanel); + setFrameShadow(QFrame::Raised); + setMinimumWidth(380); + + m_search = new QLineEdit(this); + m_search->setPlaceholderText(tr("Rechercher une commande…")); + m_search->setClearButtonEnabled(true); + m_list = new QListWidget(this); + m_list->setIconSize(QSize(20, 20)); + m_list->setMinimumHeight(280); + m_list->setFocusPolicy(Qt::NoFocus); + + auto *hint = new QLabel(tr("Entrée pour lancer · Échap pour fermer"), this); + hint->setEnabled(false); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(6, 6, 6, 6); + layout->setSpacing(4); + layout->addWidget(m_search); + layout->addWidget(m_list); + layout->addWidget(hint); + + connect(m_search, &QLineEdit::textChanged, this, &CommandSearchPopup::filter); + connect(m_list, &QListWidget::itemClicked, this, [this]() { runCurrent(); }); +} + +/** + @brief CommandSearchPopup::fold + @return @a text lower-cased, without accents and without the "&" of + mnemonics, for matching +*/ +QString CommandSearchPopup::fold(const QString &text) +{ + QString out; + const QString decomposed = text.normalized(QString::NormalizationForm_D); + out.reserve(decomposed.size()); + for (const QChar c : decomposed) { + if (c.category() != QChar::Mark_NonSpacing && c != QLatin1Char('&')) { + out.append(c.toLower()); + } + } + return out; +} + +/** + @brief CommandSearchPopup::popUpAt + Rebuild the command list -- actions come and go with windows, and their + enabled state changes -- then show at @a global_pos, kept on screen. +*/ +void CommandSearchPopup::popUpAt(const QPoint &global_pos) +{ + collect(); + m_search->clear(); + filter(); + + adjustSize(); + QPoint pos = global_pos; + if (QScreen *screen = QGuiApplication::screenAt(global_pos)) { + const QRect avail = screen->availableGeometry(); + pos.setX(qBound(avail.left(), pos.x(), avail.right() - width())); + pos.setY(qBound(avail.top(), pos.y(), avail.bottom() - height())); + } + move(pos); + show(); + m_search->setFocus(); +} + +/** + @brief CommandSearchPopup::collect + Every command registered by the owning window, except this search. +*/ +void CommandSearchPopup::collect() +{ + m_commands.clear(); + for (const ShortcutManager::ShortcutInfo &info : + ShortcutManager::instance().allShortcuts()) + { + if (info.id == QLatin1String("diagrameditor.command_search")) { + continue; + } + QAction *action = ShortcutManager::instance().action(info.id, parentWidget()); + if (!action || !action->isVisible()) { + continue; + } + const QString text = action->text().remove(QLatin1Char('&')); + if (text.isEmpty()) { + continue; + } + m_commands.append({action, text, fold(text)}); + } +} + +/** + @brief CommandSearchPopup::filter + Show the commands matching the search text, best first: a name starting + with it, then a word starting with it, then containing it. An empty + search lists everything, alphabetically. +*/ +void CommandSearchPopup::filter() +{ + const QString needle = fold(m_search->text().trimmed()); + + QList> hits; + for (const Command &command : std::as_const(m_commands)) + { + int score = 0; + if (needle.isEmpty()) { + score = 1; + } else if (command.folded.startsWith(needle)) { + score = 3; + } else if (command.folded.contains(QLatin1Char(' ') + needle)) { + score = 2; + } else if (command.folded.contains(needle)) { + score = 1; + } + if (score) { + hits.append({score, &command}); + } + } + std::stable_sort(hits.begin(), hits.end(), [](const auto &a, const auto &b) { + if (a.first != b.first) { + return a.first > b.first; + } + return a.second->folded < b.second->folded; + }); + + m_list->clear(); + for (const auto &hit : std::as_const(hits)) + { + QAction *action = hit.second->action; + const QKeySequence key = action->shortcut(); + auto *item = new QListWidgetItem( + action->icon(), + key.isEmpty() ? hit.second->text + : QStringLiteral("%1 (%2)").arg(hit.second->text, + key.toString(QKeySequence::NativeText))); + item->setData(Qt::UserRole, QVariant::fromValue(static_cast(action))); + if (!action->isEnabled()) { + item->setFlags(item->flags() & ~Qt::ItemIsEnabled); + } + m_list->addItem(item); + } + + //Preselect the first command that can run, so Enter works at once + for (int i = 0 ; i < m_list->count() ; ++i) { + if (m_list->item(i)->flags() & Qt::ItemIsEnabled) { + m_list->setCurrentRow(i); + break; + } + } +} + +/** + @brief CommandSearchPopup::runCurrent + Close, then trigger the highlighted command: it may open a dialog or + start a tool on the folio, which need the focus the popup holds. +*/ +void CommandSearchPopup::runCurrent() +{ + QListWidgetItem *item = m_list->currentItem(); + if (!item || !(item->flags() & Qt::ItemIsEnabled)) { + return; + } + auto *action = static_cast(item->data(Qt::UserRole).value()); + hide(); + if (action) { + action->trigger(); + } +} + +/** + @brief CommandSearchPopup::keyPressEvent + Up and Down move through the list while typing goes on in the search + field; Enter runs, Esc closes. +*/ +void CommandSearchPopup::keyPressEvent(QKeyEvent *event) +{ + switch (event->key()) + { + case Qt::Key_Escape: + hide(); + return; + case Qt::Key_Return: + case Qt::Key_Enter: + runCurrent(); + return; + case Qt::Key_Down: + case Qt::Key_Up: + case Qt::Key_PageDown: + case Qt::Key_PageUp: + QCoreApplication::sendEvent(m_list, event); + return; + default: + break; + } + QFrame::keyPressEvent(event); +} diff --git a/sources/commandsearchpopup.h b/sources/commandsearchpopup.h new file mode 100644 index 000000000..ff71ea667 --- /dev/null +++ b/sources/commandsearchpopup.h @@ -0,0 +1,65 @@ +/* + 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 COMMANDSEARCHPOPUP_H +#define COMMANDSEARCHPOPUP_H + +#include +#include + +class QAction; +class QLineEdit; +class QListWidget; + +/** + @brief Type part of a command's name, press Enter to run it. + + Lists every command the owning window registered with ShortcutManager, + best match first, with its key if it has one. Matching ignores case and + accents, so "editer" finds "Éditer". Disabled commands are listed, + greyed, but cannot be run. +*/ +class CommandSearchPopup : public QFrame +{ + Q_OBJECT + + public: + explicit CommandSearchPopup(QWidget *owner); + + void popUpAt(const QPoint &global_pos); + static QString fold(const QString &text); + + protected: + void keyPressEvent(QKeyEvent *event) override; + + private: + void collect(); + void filter(); + void runCurrent(); + + struct Command { + QAction *action; + QString text; ///< as shown + QString folded; ///< for matching + }; + + QLineEdit *m_search = nullptr; + QListWidget *m_list = nullptr; + QList m_commands; +}; + +#endif // COMMANDSEARCHPOPUP_H diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index c97613ba7..cfd10a222 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -22,6 +22,7 @@ #include #include #include "ElementsCollection/elementscollectionwidget.h" +#include "commandsearchpopup.h" #include "QWidgetAnimation/qwidgetanimation.h" #include "autoNum/ui/autonumberingdockwidget.h" #include "conductornumexport.h" @@ -796,6 +797,27 @@ void QETDiagramEditor::setUpActions() ShortcutManager::instance().registerAction(m_rotate_texts, "diagrameditor.rotate_texts", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_Space); ShortcutManager::instance().registerAction(m_edit_selection, "diagrameditor.edit_selection", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_E); + //Type to find and run any command, as SolidWorks' "Search Commands" + //and the command palette of many editors. Ctrl+Shift+P, the key those + //editors use, is taken by the autonumbering dock; M for "menu". + m_command_search = new QAction(tr("Rechercher une commande…"), this); + m_command_search->setStatusTip( + tr("Tapez une partie du nom d'une commande et appuyez sur Entrée pour la lancer", + "status bar tip")); + ShortcutManager::instance().registerAction( + m_command_search, "diagrameditor.command_search", + tr("Éditeur de schémas"), Qt::CTRL | Qt::SHIFT | Qt::Key_M); + connect(m_command_search, &QAction::triggered, this, [this]() { + if (!m_command_search_popup) { + m_command_search_popup = new CommandSearchPopup(this); + } + const QRect area = geometry(); + m_command_search_popup->popUpAt( + area.contains(QCursor::pos()) ? QCursor::pos() + : area.center()); + }); + addAction(m_command_search); + m_delete_selection->setStatusTip( tr("Enlève les éléments sélectionnés du folio", "status bar tip")); m_rotate_selection->setStatusTip( tr("Pivote les éléments et textes sélectionnés", "status bar tip")); m_rotate_group_selection->setStatusTip( tr("Pivote la sélection comme un groupe autour de son centre, au lieu de chaque élément sur place", "status bar tip")); @@ -1080,6 +1102,7 @@ void QETDiagramEditor::setUpMenu() menu_edition -> addAction(m_paste); menu_edition -> addAction(m_duplicate); menu_edition -> addAction(m_configure_duplicate); + menu_edition -> addAction(m_command_search); menu_edition -> addSeparator(); //The same actions the "Ajouter" toolbar holds. They were toolbar-only, //which left them unreachable for anyone working without a mouse: a diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index c0bece505..fc8a324d8 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -45,6 +45,7 @@ class ElementsLocation; class RecentFiles; class DiagramPropertiesEditorDockWidget; class ElementsCollectionWidget; +class CommandSearchPopup; class AutoNumberingDockWidget; class TerminalNumberingDialog; @@ -268,6 +269,8 @@ class QETDiagramEditor : public QETMainWindow *m_qdw_elmt_collection, *qdw_undo; /// Dock for the undo list + QAction *m_command_search = nullptr; + CommandSearchPopup *m_command_search_popup = nullptr; ///< Built on first use ElementsCollectionWidget *m_element_collection_widget; DiagramPropertiesEditorDockWidget *m_selection_properties_editor; diff --git a/sources/shortcutmanager.cpp b/sources/shortcutmanager.cpp index 6f8e856a4..56d43b228 100644 --- a/sources/shortcutmanager.cpp +++ b/sources/shortcutmanager.cpp @@ -197,3 +197,36 @@ bool ShortcutManager::trigger(const QString &id) const } return false; } + +/** + @return the QAction registered under @a id that belongs to @a owner -- + that is, has @a owner among its ancestors -- or nullptr. Several windows + of the same kind each register their own action under one id, so a + window asking for "its" action has to say which window it is. + @param id + @param owner : the window, or nullptr for the first live action +*/ +QAction *ShortcutManager::action(const QString &id, const QObject *owner) const +{ + auto it = m_entries.find(id); + if (it == m_entries.end()) { + return nullptr; + } + + for (const QPointer &target : qAsConst(it->targets)) + { + auto *action = qobject_cast(target.data()); + if (!action) { + continue; + } + if (!owner) { + return action; + } + for (const QObject *o = action->parent(); o; o = o->parent()) { + if (o == owner) { + return action; + } + } + } + return nullptr; +} diff --git a/sources/shortcutmanager.h b/sources/shortcutmanager.h index d40448c52..b42cd0d44 100644 --- a/sources/shortcutmanager.h +++ b/sources/shortcutmanager.h @@ -26,6 +26,7 @@ #include class QObject; +class QAction; /** @brief The ShortcutManager class @@ -84,6 +85,7 @@ class ShortcutManager /// multi-window case, not a guaranteed-correct dispatch. /// @return whether a live target was found and triggered. bool trigger(const QString &id) const; + QAction *action(const QString &id, const QObject *owner) const; private: ShortcutManager() = default; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 3296bc016..98d3a17fa 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -156,6 +156,19 @@ add_test(NAME tst_qetstrings COMMAND tst_qetstrings) target_include_directories(tst_qetstrings PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_qetstrings PRIVATE Qt::Test Qt::Widgets Qt::Xml pugixml::pugixml) +# CommandSearchPopup: accent-blind matching, ranking, and that Enter runs +# only the owning window's highlighted, enabled command. +add_executable( + tst_commandsearch + tst_commandsearch.cpp + ${QET_DIR}/sources/commandsearchpopup.cpp + ${QET_DIR}/sources/commandsearchpopup.h + ${QET_DIR}/sources/shortcutmanager.cpp) +add_test(NAME tst_commandsearch COMMAND tst_commandsearch) +set_tests_properties(tst_commandsearch PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_commandsearch PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_commandsearch PRIVATE Qt::Test Qt::Widgets) + # QETSql::isSingleReadOnlyStatement() -- read-only enforcement for every # project-database query, including the ones a .qet file carries. Compiles # sqlreadonly.cpp alone against its own in-memory SQLite, so the security diff --git a/tests/qttest/tst_commandsearch.cpp b/tests/qttest/tst_commandsearch.cpp new file mode 100644 index 000000000..b4acca186 --- /dev/null +++ b/tests/qttest/tst_commandsearch.cpp @@ -0,0 +1,117 @@ +/* + 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 "commandsearchpopup.h" +#include "shortcutmanager.h" + +#include +#include +#include +#include +#include + +/** + CommandSearchPopup: accent- and case-blind matching, ranking, and that + Enter runs the highlighted command of the owning window only. +*/ +class tst_commandsearch : public QObject +{ + Q_OBJECT + +private: + QAction *add(QWidget *owner, const QString &id, const QString &text) + { + auto *action = new QAction(text, owner); + ShortcutManager::instance().registerAction(action, id, QStringLiteral("test"), QKeySequence()); + return action; + } + + QStringList shown(CommandSearchPopup &popup) + { + QStringList texts; + auto *list = popup.findChild(); + for (int i = 0 ; i < list->count() ; ++i) { + texts << list->item(i)->text(); + } + return texts; + } + +private slots: + void fold_data() + { + QTest::addColumn("input"); + QTest::addColumn("expected"); + QTest::newRow("accents") << QStringLiteral("Éditer l'élément") << QStringLiteral("editer l'element"); + QTest::newRow("mnemonic") << QStringLiteral("&Fichier") << QStringLiteral("fichier"); + QTest::newRow("cedilla") << QStringLiteral("Façade") << QStringLiteral("facade"); + QTest::newRow("plain") << QStringLiteral("zoom") << QStringLiteral("zoom"); + } + + void fold() + { + QFETCH(QString, input); + QFETCH(QString, expected); + QCOMPARE(CommandSearchPopup::fold(input), expected); + } + + void ranksAndRunsTheBestMatch() + { + QWidget owner; + QWidget other; + QAction *rotate = add(&owner, QStringLiteral("t.rotate"), QStringLiteral("Pivoter")); + add(&owner, QStringLiteral("t.rotate_texts"), QStringLiteral("Orienter les textes")); + QAction *edit = add(&owner, QStringLiteral("t.edit"), QStringLiteral("Éditer l'item sélectionné")); + add(&owner, QStringLiteral("t.text"), QStringLiteral("Ajouter un champ de texte")); + //Same id family, another window: must not be listed + add(&other, QStringLiteral("t.other"), QStringLiteral("Texte d'une autre fenêtre")); + + CommandSearchPopup popup(&owner); + popup.popUpAt(QPoint(0, 0)); + auto *search = popup.findChild(); + + search->setText(QStringLiteral("texte")); + //word start ("…textes") before a contained match; other window absent + QCOMPARE(shown(popup), (QStringList{QStringLiteral("Ajouter un champ de texte"), + QStringLiteral("Orienter les textes")})); + + search->setText(QStringLiteral("editer")); + QCOMPARE(shown(popup).value(0), QStringLiteral("Éditer l'item sélectionné")); + QSignalSpy edited(edit, &QAction::triggered); + QSignalSpy rotated(rotate, &QAction::triggered); + QTest::keyClick(&popup, Qt::Key_Return); + QCOMPARE(edited.count(), 1); + QCOMPARE(rotated.count(), 0); + QVERIFY(!popup.isVisible()); + } + + void disabledCommandsCannotRun() + { + QWidget owner; + QAction *paste = add(&owner, QStringLiteral("u.paste"), QStringLiteral("Coller")); + paste->setEnabled(false); + CommandSearchPopup popup(&owner); + popup.popUpAt(QPoint(0, 0)); + popup.findChild()->setText(QStringLiteral("coller")); + QCOMPARE(shown(popup), QStringList{QStringLiteral("Coller")}); + QSignalSpy pasted(paste, &QAction::triggered); + QTest::keyClick(&popup, Qt::Key_Return); + QCOMPARE(pasted.count(), 0); + } +}; + +QTEST_MAIN(tst_commandsearch) +#include "tst_commandsearch.moc"