From 86cb7e430e30883965f6d33ae206edb452c821b0 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 26 Sep 2026 13:37:51 +1200 Subject: [PATCH] Add a command search: type part of a command's name, press Enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+Shift+M (Édition → "Rechercher une commande…") opens a small search box at the cursor listing every command of the diagram editor window, as SolidWorks' "Search Commands" and the command palette of many editors do. Typing narrows it, best match first: name starting with the text, then a word starting with it, then containing it. Matching ignores case, accents and mnemonic "&", so "editer" finds "Éditer l'item sélectionné". Each row shows the command's key when it has one, which also teaches the keys. Disabled commands are listed, greyed, and cannot be run. Enter runs the highlighted one after closing the box; Esc closes. The list is ShortcutManager's registry, restricted to the actions this window owns (ShortcutManager::action(id, owner)), so a second editor window's commands never appear and nothing has to be listed by hand. Ctrl+Shift+P, the usual key for this, is already the autonumbering dock's. tst_commandsearch covers the folding, the ranking, that another window's commands are left out and that a disabled command does not run; both behaviours were checked to fail the test when broken. Discussion #1033. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG --- cmake/qet_compilation_vars.cmake | 2 + sources/commandsearchpopup.cpp | 236 +++++++++++++++++++++++++++++ sources/commandsearchpopup.h | 65 ++++++++ sources/qetdiagrameditor.cpp | 23 +++ sources/qetdiagrameditor.h | 3 + sources/shortcutmanager.cpp | 33 ++++ sources/shortcutmanager.h | 2 + tests/qttest/CMakeLists.txt | 13 ++ tests/qttest/tst_commandsearch.cpp | 117 ++++++++++++++ 9 files changed, 494 insertions(+) create mode 100644 sources/commandsearchpopup.cpp create mode 100644 sources/commandsearchpopup.h create mode 100644 tests/qttest/tst_commandsearch.cpp 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"