Merge pull request #1326 from ispyisail/feature/customise-shortcuts-page

Add icons, menu locations, key search and copy to the Shortcuts page
This commit is contained in:
Laurent Trinques
2026-10-06 16:29:26 +02:00
committed by GitHub
6 changed files with 331 additions and 14 deletions
+7
View File
@@ -137,6 +137,13 @@ QList<ShortcutManager::ShortcutInfo> ShortcutManager::allShortcuts() const
info.description = entry.description;
info.default_sequence = entry.default_sequence;
info.current_sequence = savedSequence(id, entry.default_sequence);
for (const QPointer<QObject> &target : entry.targets) {
if (target) {
info.icon = target->property("icon").value<QIcon>();
info.action = qobject_cast<QAction *>(target.data());
break;
}
}
list << info;
}
return list;
+5
View File
@@ -19,6 +19,7 @@
#define SHORTCUTMANAGER_H
#include <QHash>
#include <QIcon>
#include <QKeySequence>
#include <QList>
#include <QPointer>
@@ -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,
+149 -14
View File
@@ -20,6 +20,9 @@
#include "../../qeticons.h"
#include "../../shortcutmanager.h"
#include <QAction>
#include <QApplication>
#include <QClipboard>
#include <QComboBox>
#include <QFrame>
#include <QHBoxLayout>
@@ -27,6 +30,8 @@
#include <QKeySequenceEdit>
#include <QLabel>
#include <QLineEdit>
#include <QMenu>
#include <QMenuBar>
#include <QPushButton>
#include <QRegularExpression>
#include <QToolButton>
@@ -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<int>::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<int>::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<QString, QTreeWidgetItem *> 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<QMenu *>(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<QMenuBar *>(owner)) {
in_menu_bar = true;
} else if (!parent_menu) {
parent_menu = qobject_cast<QMenu *>(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");
@@ -22,6 +22,7 @@
#include <QKeySequence>
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<Row> m_rows;
+17
View File
@@ -247,6 +247,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.
+145
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "shortcutmanager.h"
#include "ui/configpage/shortcutsconfigpage.h"
#include <QAction>
#include <QComboBox>
#include <QKeySequenceEdit>
#include <QMainWindow>
#include <QMenuBar>
#include <QPixmap>
#include <QSettings>
#include <QStandardPaths>
#include <QTest>
#include <QTreeWidget>
/**
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<QTreeWidget *>();
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<QTreeWidget *>();
auto *combo = page.findChild<QComboBox *>(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<QTreeWidget *>();
auto *key = page.findChild<QKeySequenceEdit *>(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<QComboBox *>(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"