From 896c38435d53046b0c871d3b2792e7b672956f79 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 6 Oct 2026 08:17:16 +1300 Subject: [PATCH] Add icons, menu locations, key search and copy to the Shortcuts page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Shortcuts page now shows: - each command's icon, and a Menu column saying where it is ("Projet › Scripts"); the text filter matches that too - a category filter beside the existing Bound/Unbound/Conflicts one - "Rechercher par touche": press a key combination and only the commands using it are listed, however the key is spelled in the user's language. A key also finds the two-key shortcuts it starts. - "Copier la liste": the shown rows go on the clipboard as tab-separated text with a header, so they paste into a spreadsheet one cell per column ShortcutManager::ShortcutInfo carries the icon and the first live QAction so the page needs no second registry. New test tst_shortcutspage covers the menu path, the columns, both filters and the copied text. Second stage of discussion #1319. Co-Authored-By: Claude Opus 5.5 --- sources/shortcutmanager.cpp | 7 + sources/shortcutmanager.h | 5 + sources/ui/configpage/shortcutsconfigpage.cpp | 163 ++++++++++++++++-- sources/ui/configpage/shortcutsconfigpage.h | 8 + tests/qttest/CMakeLists.txt | 17 ++ tests/qttest/tst_shortcutspage.cpp | 145 ++++++++++++++++ 6 files changed, 331 insertions(+), 14 deletions(-) create mode 100644 tests/qttest/tst_shortcutspage.cpp diff --git a/sources/shortcutmanager.cpp b/sources/shortcutmanager.cpp index 503d77993..fffcd9720 100644 --- a/sources/shortcutmanager.cpp +++ b/sources/shortcutmanager.cpp @@ -137,6 +137,13 @@ QList ShortcutManager::allShortcuts() const info.description = entry.description; info.default_sequence = entry.default_sequence; info.current_sequence = savedSequence(id, entry.default_sequence); + for (const QPointer &target : entry.targets) { + if (target) { + info.icon = target->property("icon").value(); + info.action = qobject_cast(target.data()); + break; + } + } list << info; } return list; diff --git a/sources/shortcutmanager.h b/sources/shortcutmanager.h index 5da92e332..fecaca6df 100644 --- a/sources/shortcutmanager.h +++ b/sources/shortcutmanager.h @@ -19,6 +19,7 @@ #define SHORTCUTMANAGER_H #include +#include #include #include #include @@ -60,6 +61,10 @@ class ShortcutManager QString description; QKeySequence default_sequence; QKeySequence current_sequence; + /// The command's icon, from its first live target; may be null + QIcon icon; + /// The first live target if it is a QAction, else nullptr + QAction *action = nullptr; }; void registerAction(QObject *target, const QString &id, diff --git a/sources/ui/configpage/shortcutsconfigpage.cpp b/sources/ui/configpage/shortcutsconfigpage.cpp index 242958fcd..c7aa25666 100644 --- a/sources/ui/configpage/shortcutsconfigpage.cpp +++ b/sources/ui/configpage/shortcutsconfigpage.cpp @@ -20,6 +20,9 @@ #include "../../qeticons.h" #include "../../shortcutmanager.h" +#include +#include +#include #include #include #include @@ -27,6 +30,8 @@ #include #include #include +#include +#include #include #include #include @@ -43,6 +48,10 @@ this, so "general" matches "Général" and "Ctrl+S" matches "ctrl+s" regardless of the keyboard layout the query was typed on. */ +namespace { + enum Column { ActionColumn, MenuColumn, SequenceColumn, ResetColumn }; +} + static QString normalizedForSearch(const QString &text) { const QString decomposed = text.normalized(QString::NormalizationForm_D); @@ -85,20 +94,51 @@ ShortcutsConfigPage::ShortcutsConfigPage(QWidget *parent) : connect(m_quick_filter, QOverload::of(&QComboBox::currentIndexChanged), this, &ShortcutsConfigPage::quickFilterChanged); + m_category_filter = new QComboBox(this); + m_category_filter->setObjectName(QStringLiteral("categoryFilterCombo")); + m_category_filter->addItem(tr("Toutes les catégories")); + connect(m_category_filter, QOverload::of(&QComboBox::currentIndexChanged), + this, &ShortcutsConfigPage::quickFilterChanged); + m_count_label = new QLabel(this); m_count_label->setObjectName(QStringLiteral("shortcutCountLabel")); auto *filter_layout = new QHBoxLayout(); filter_layout->addWidget(m_filter_edit, 1); + filter_layout->addWidget(m_category_filter); filter_layout->addWidget(m_quick_filter); filter_layout->addWidget(m_count_label); vlayout->addLayout(filter_layout); + //Press a key combination to list what uses it, without having to + //know how QElectroTech spells it ("Ctrl+Maj+S", "Ctrl+Shift+S"…) + m_key_search = new QKeySequenceEdit(this); + m_key_search->setObjectName(QStringLiteral("keySearchEdit")); + m_key_search->setToolTip(tr("Appuyez sur une combinaison de touches pour voir quelle commande l'utilise")); +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + m_key_search->setMaximumSequenceLength(1); +#endif + connect(m_key_search, &QKeySequenceEdit::keySequenceChanged, this, [this]() { applyFilter(); }); + + auto *clear_key_button = new QToolButton(this); + clear_key_button->setIcon(QET::Icons::EditClear); + clear_key_button->setToolTip(tr("Effacer la touche recherchée")); + clear_key_button->setAutoRaise(true); + connect(clear_key_button, &QToolButton::clicked, m_key_search, &QKeySequenceEdit::clear); + + auto *key_layout = new QHBoxLayout(); + key_layout->addWidget(new QLabel(tr("Rechercher par touche :"), this)); + key_layout->addWidget(m_key_search); + key_layout->addWidget(clear_key_button); + key_layout->addStretch(); + vlayout->addLayout(key_layout); + m_tree = new QTreeWidget(this); - m_tree->setHeaderLabels({tr("Action"), tr("Raccourci"), QString()}); - m_tree->header()->setSectionResizeMode(0, QHeaderView::Stretch); - m_tree->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents); - m_tree->header()->setSectionResizeMode(2, QHeaderView::ResizeToContents); + m_tree->setHeaderLabels({tr("Action"), tr("Menu"), tr("Raccourci"), QString()}); + m_tree->header()->setSectionResizeMode(ActionColumn, QHeaderView::Stretch); + m_tree->header()->setSectionResizeMode(MenuColumn, QHeaderView::ResizeToContents); + m_tree->header()->setSectionResizeMode(SequenceColumn, QHeaderView::ResizeToContents); + m_tree->header()->setSectionResizeMode(ResetColumn, QHeaderView::ResizeToContents); m_tree->setEditTriggers(QAbstractItemView::NoEditTriggers); m_tree->setSelectionMode(QAbstractItemView::NoSelection); vlayout->addWidget(m_tree); @@ -106,7 +146,12 @@ ShortcutsConfigPage::ShortcutsConfigPage(QWidget *parent) : auto *reset_all_button = new QPushButton(tr("Tout réinitialiser"), this); connect(reset_all_button, &QPushButton::clicked, this, &ShortcutsConfigPage::resetAllRows); + auto *copy_button = new QPushButton(QET::Icons::EditCopy, tr("Copier la liste"), this); + copy_button->setToolTip(tr("Copie les raccourcis affichés, à coller dans un tableur ou un document")); + connect(copy_button, &QPushButton::clicked, this, &ShortcutsConfigPage::copyList); + auto *bottom_layout = new QHBoxLayout(); + bottom_layout->addWidget(copy_button); bottom_layout->addStretch(); bottom_layout->addWidget(reset_all_button); vlayout->addLayout(bottom_layout); @@ -139,6 +184,9 @@ void ShortcutsConfigPage::populateTable() m_tree->clear(); m_rows.clear(); + while (m_category_filter->count() > 1) { + m_category_filter->removeItem(1); + } m_rows.reserve(shortcuts.size()); QHash category_nodes; @@ -147,18 +195,22 @@ void ShortcutsConfigPage::populateTable() QTreeWidgetItem *category_item = category_nodes.value(info.category, nullptr); if (!category_item) { category_item = new QTreeWidgetItem(m_tree); - category_item->setText(0, info.category); + category_item->setText(ActionColumn, info.category); category_item->setFlags(category_item->flags() & ~Qt::ItemIsEditable); category_nodes.insert(info.category, category_item); + m_category_filter->addItem(info.category); } auto *child = new QTreeWidgetItem(category_item); - child->setText(0, info.description); + const QString menu_path = menuPath(info.action); + child->setText(ActionColumn, info.description); + child->setIcon(ActionColumn, info.icon); + child->setText(MenuColumn, menu_path); child->setFlags(child->flags() & ~Qt::ItemIsEditable); auto *edit = new QKeySequenceEdit(info.current_sequence, m_tree); connect(edit, &QKeySequenceEdit::editingFinished, this, &ShortcutsConfigPage::checkConflicts); - m_tree->setItemWidget(child, 1, edit); + m_tree->setItemWidget(child, SequenceColumn, edit); auto *reset_button = new QToolButton(m_tree); reset_button->setIcon(QET::Icons::EditUndo); @@ -166,9 +218,10 @@ void ShortcutsConfigPage::populateTable() reset_button->setAutoRaise(true); const int row_index = m_rows.size(); connect(reset_button, &QToolButton::clicked, this, [this, row_index]() { resetRow(row_index); }); - m_tree->setItemWidget(child, 2, reset_button); + m_tree->setItemWidget(child, ResetColumn, reset_button); - m_rows << Row{info.id, info.category, info.description, info.default_sequence, edit, child, false}; + m_rows << Row{info.id, info.category, info.description, menu_path, + info.default_sequence, edit, child, false}; } checkConflicts(); @@ -210,6 +263,9 @@ void ShortcutsConfigPage::applyFilter() : needle.split(QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); const int quick_filter = m_quick_filter->currentIndex(); + const QString category = m_category_filter->currentIndex() > 0 + ? m_category_filter->currentText() : QString(); + const QKeySequence key = m_key_search->keySequence(); int visible_actions = 0; for (const Row &row : std::as_const(m_rows)) { @@ -218,7 +274,8 @@ void ShortcutsConfigPage::applyFilter() // of "Shift"), which is precisely the kind of false positive that hides // the one binding the user is looking for. const QString text_haystack = normalizedForSearch( - row.category + QLatin1Char(' ') + row.description); + row.category + QLatin1Char(' ') + row.description + + QLatin1Char(' ') + row.menu_path); const QString sequence_text = normalizedForSearch(row.edit->keySequence().toString()); bool matches = true; @@ -230,6 +287,16 @@ void ShortcutsConfigPage::applyFilter() } } + if (matches && !category.isEmpty()) { + matches = row.category == category; + } + if (matches && !key.isEmpty()) { + //A pressed key also finds the sequences it starts, such + //as a two-key shortcut whose first key it is + const QKeySequence sequence = row.edit->keySequence(); + matches = !sequence.isEmpty() && key.matches(sequence) != QKeySequence::NoMatch; + } + if (matches) { switch (quick_filter) { case BoundOnly: @@ -252,7 +319,8 @@ void ShortcutsConfigPage::applyFilter() } } - const bool filtering = !needle.isEmpty() || quick_filter != ShowAll; + const bool filtering = !needle.isEmpty() || quick_filter != ShowAll + || !category.isEmpty() || !key.isEmpty(); for (int i = 0; i < m_tree->topLevelItemCount(); ++i) { QTreeWidgetItem *top = m_tree->topLevelItem(i); bool any_visible = false; @@ -322,11 +390,11 @@ void ShortcutsConfigPage::checkConflicts() other_descriptions << m_rows.at(other_row).description; } } - current_row.item->setBackground(0, QColor(255, 205, 205)); + current_row.item->setBackground(ActionColumn, QColor(255, 205, 205)); current_row.edit->setToolTip( tr("Ce raccourci est aussi utilisé par : %1").arg(other_descriptions.join(QStringLiteral(", ")))); } else { - current_row.item->setBackground(0, QBrush()); + current_row.item->setBackground(ActionColumn, QBrush()); current_row.edit->setToolTip(QString()); } } @@ -335,7 +403,9 @@ void ShortcutsConfigPage::checkConflicts() // conflicts-only quick filter); refresh the visible set so the list doesn't // show stale results. const bool filtering = !m_filter_edit->text().trimmed().isEmpty() - || m_quick_filter->currentIndex() != ShowAll; + || m_quick_filter->currentIndex() != ShowAll + || m_category_filter->currentIndex() > 0 + || !m_key_search->keySequence().isEmpty(); if (filtering) { applyFilter(); } @@ -374,6 +444,71 @@ void ShortcutsConfigPage::applyConf() } } +/** + @brief ShortcutsConfigPage::menuPath + @param action + @return where \a action is in the menu bar, such as "Projet › Scripts", + or an empty string for a command that is in no menu of the menu bar + (a toolbar or shortcut bar only command, or one with no live action) +*/ +QString ShortcutsConfigPage::menuPath(const QAction *action) +{ + if (!action) { + return QString(); + } + for (QObject *object : action->associatedObjects()) { + QStringList titles; + for (auto *menu = qobject_cast(object); menu; ) { + titles.prepend(menu->title().remove(QLatin1Char('&'))); + QMenu *parent_menu = nullptr; + bool in_menu_bar = false; + for (QObject *owner : menu->menuAction()->associatedObjects()) { + if (qobject_cast(owner)) { + in_menu_bar = true; + } else if (!parent_menu) { + parent_menu = qobject_cast(owner); + } + } + if (in_menu_bar) { + return titles.join(QStringLiteral(" › ")); + } + menu = parent_menu; + } + } + return QString(); +} + +/** + @brief ShortcutsConfigPage::listAsText + @return the shortcuts shown, as the edits currently hold them, one per + line with tab-separated columns and a header line: pasted into a + spreadsheet it fills one cell per column. +*/ +QString ShortcutsConfigPage::listAsText() const +{ + QStringList lines; + lines << QStringList{tr("Catégorie"), tr("Menu"), tr("Action"), tr("Raccourci")} + .join(QLatin1Char('\t')); + for (const Row &row : std::as_const(m_rows)) { + if (row.item->isHidden()) { + continue; + } + lines << QStringList{row.category, row.menu_path, row.description, + row.edit->keySequence().toString(QKeySequence::NativeText)} + .join(QLatin1Char('\t')); + } + return lines.join(QLatin1Char('\n')) + QLatin1Char('\n'); +} + +/** + @brief ShortcutsConfigPage::copyList + Put listAsText() on the clipboard. +*/ +void ShortcutsConfigPage::copyList() +{ + QApplication::clipboard()->setText(listAsText()); +} + QString ShortcutsConfigPage::title() const { return tr("Raccourcis", "configuration page title"); diff --git a/sources/ui/configpage/shortcutsconfigpage.h b/sources/ui/configpage/shortcutsconfigpage.h index 87772bcf8..446964be5 100644 --- a/sources/ui/configpage/shortcutsconfigpage.h +++ b/sources/ui/configpage/shortcutsconfigpage.h @@ -22,6 +22,7 @@ #include +class QAction; class QComboBox; class QKeySequenceEdit; class QLabel; @@ -48,11 +49,15 @@ class ShortcutsConfigPage : public ConfigPage QString title() const override; QIcon icon() const override; + static QString menuPath(const QAction *action); + QString listAsText() const; + private slots: void filterRows(const QString &filter_text); void quickFilterChanged(int index); void checkConflicts(); void resetAllRows(); + void copyList(); private: enum QuickFilter { @@ -66,6 +71,7 @@ class ShortcutsConfigPage : public ConfigPage QString id; QString category; QString description; + QString menu_path; QKeySequence default_sequence; QKeySequenceEdit *edit; QTreeWidgetItem *item; @@ -78,6 +84,8 @@ class ShortcutsConfigPage : public ConfigPage QLineEdit *m_filter_edit; QComboBox *m_quick_filter; + QComboBox *m_category_filter; + QKeySequenceEdit *m_key_search; QLabel *m_count_label; QTreeWidget *m_tree; QList m_rows; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 3b7e8c2b5..25a41b809 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -227,6 +227,23 @@ set_tests_properties(tst_commandsearch PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=o target_include_directories(tst_commandsearch PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_commandsearch PRIVATE Qt::Test Qt::Widgets) +# ShortcutsConfigPage: icon and menu columns, category filter, finding a +# command by pressing its key, and the copied list. +add_executable( + tst_shortcutspage + tst_shortcutspage.cpp + ${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.h + ${QET_DIR}/sources/ui/configpage/configpage.h + ${QET_DIR}/sources/shortcutmanager.cpp + ${QET_DIR}/sources/qeticons.cpp + ${QET_DIR}/sources/qetpalette.cpp + ${QET_DIR}/sources/qetstyle.cpp) +add_test(NAME tst_shortcutspage COMMAND tst_shortcutspage) +set_tests_properties(tst_shortcutspage PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_shortcutspage PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_shortcutspage PRIVATE Qt::Test Qt::Widgets Qt::Svg) + # scriptheader.h is header-only: the // ==QETScript== block that turns a # stored script into a button; and ShortcutManager::unregisterAction(), which # takes a deleted script's command out of the lists again. diff --git a/tests/qttest/tst_shortcutspage.cpp b/tests/qttest/tst_shortcutspage.cpp new file mode 100644 index 000000000..f150a452e --- /dev/null +++ b/tests/qttest/tst_shortcutspage.cpp @@ -0,0 +1,145 @@ +/* + 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 "shortcutmanager.h" +#include "ui/configpage/shortcutsconfigpage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + The Shortcuts configuration page: the icon and menu columns, the + category filter, finding a command by pressing its key, and the + copied list. +*/ +class tst_shortcutspage : public QObject +{ + Q_OBJECT + + QMainWindow m_window; + QAction *m_save = nullptr; + QAction *m_run = nullptr; + QAction *m_tool = nullptr; + + QTreeWidgetItem *row(QTreeWidget *tree, const QString &text) + { + const auto found = tree->findItems(text, Qt::MatchExactly | Qt::MatchRecursive, 0); + return found.isEmpty() ? nullptr : found.first(); + } + + QStringList visible(QTreeWidget *tree) + { + QStringList texts; + for (const QString &text : {QStringLiteral("Enregistrer"), QStringLiteral("Exécuter"), QStringLiteral("Outil")}) { + if (QTreeWidgetItem *item = row(tree, text); item && !item->isHidden()) { + texts << text; + } + } + return texts; + } + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + QSettings().remove(QStringLiteral("shortcuts")); + + QPixmap pixmap(16, 16); + pixmap.fill(Qt::red); + + QMenu *file = m_window.menuBar()->addMenu(QStringLiteral("&Fichier")); + QMenu *scripts = file->addMenu(QStringLiteral("&Scripts")); + m_save = file->addAction(QIcon(pixmap), QStringLiteral("&Enregistrer")); + m_run = scripts->addAction(QStringLiteral("Exécuter")); + //On a toolbar only, in no menu + m_tool = new QAction(QStringLiteral("Outil"), &m_window); + + ShortcutManager &manager = ShortcutManager::instance(); + manager.registerAction(m_save, QStringLiteral("t.save"), QStringLiteral("Catégorie A"), QKeySequence(Qt::CTRL | Qt::Key_S)); + manager.registerAction(m_run, QStringLiteral("t.run"), QStringLiteral("Catégorie A"), QKeySequence(Qt::CTRL | Qt::Key_R)); + manager.registerAction(m_tool, QStringLiteral("t.tool"), QStringLiteral("Catégorie B"), QKeySequence()); + } + + void menuPath() + { + QCOMPARE(ShortcutsConfigPage::menuPath(m_save), QStringLiteral("Fichier")); + QCOMPARE(ShortcutsConfigPage::menuPath(m_run), QStringLiteral("Fichier › Scripts")); + QCOMPARE(ShortcutsConfigPage::menuPath(m_tool), QString()); + QCOMPARE(ShortcutsConfigPage::menuPath(nullptr), QString()); + } + + void iconAndMenuColumns() + { + ShortcutsConfigPage page(nullptr); + auto *tree = page.findChild(); + QVERIFY(row(tree, QStringLiteral("Enregistrer"))); + QVERIFY(!row(tree, QStringLiteral("Enregistrer"))->icon(0).isNull()); + QVERIFY(row(tree, QStringLiteral("Outil"))->icon(0).isNull()); + QCOMPARE(row(tree, QStringLiteral("Exécuter"))->text(1), QStringLiteral("Fichier › Scripts")); + QCOMPARE(row(tree, QStringLiteral("Outil"))->text(1), QString()); + } + + void categoryFilter() + { + ShortcutsConfigPage page(nullptr); + auto *tree = page.findChild(); + auto *combo = page.findChild(QStringLiteral("categoryFilterCombo")); + QCOMPARE(visible(tree).size(), 3); + combo->setCurrentIndex(combo->findText(QStringLiteral("Catégorie B"))); + QCOMPARE(visible(tree), QStringList{QStringLiteral("Outil")}); + combo->setCurrentIndex(0); + QCOMPARE(visible(tree).size(), 3); + } + + void findByKey() + { + ShortcutsConfigPage page(nullptr); + auto *tree = page.findChild(); + auto *key = page.findChild(QStringLiteral("keySearchEdit")); + key->setKeySequence(QKeySequence(Qt::CTRL | Qt::Key_S)); + QCOMPARE(visible(tree), QStringList{QStringLiteral("Enregistrer")}); + key->setKeySequence(QKeySequence(Qt::CTRL | Qt::Key_J)); + QVERIFY(visible(tree).isEmpty()); + key->clear(); + QCOMPARE(visible(tree).size(), 3); + } + + void copiedList() + { + ShortcutsConfigPage page(nullptr); + auto *combo = page.findChild(QStringLiteral("categoryFilterCombo")); + combo->setCurrentIndex(combo->findText(QStringLiteral("Catégorie A"))); + const QStringList lines = page.listAsText().split(QLatin1Char('\n'), Qt::SkipEmptyParts); + QCOMPARE(lines.size(), 3); + QCOMPARE(lines.at(0).count(QLatin1Char('\t')), 3); + QVERIFY(lines.contains(QStringLiteral("Catégorie A\tFichier\tEnregistrer\t") + + QKeySequence(Qt::CTRL | Qt::Key_S).toString(QKeySequence::NativeText))); + QVERIFY(lines.contains(QStringLiteral("Catégorie A\tFichier › Scripts\tExécuter\t") + + QKeySequence(Qt::CTRL | Qt::Key_R).toString(QKeySequence::NativeText))); + } +}; + +QTEST_MAIN(tst_shortcutspage) +#include "tst_shortcutspage.moc"