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"