[api] Add framework to handle common actions in shortcuts manager

Common actions allow for shortcuts to be registered for actions
which do not yet exist, or which are not associated with a single
global QAction object attached to at the application level. For
example, code editor actions which will be created as children
of individual code editor widgets, but which should have
shortcuts available for user configuration via the shortcuts
manager dialog.
This commit is contained in:
Nyall Dawson 2025-09-30 09:14:58 +10:00
parent 62fbbe6392
commit 5ed5f51121
7 changed files with 278 additions and 20 deletions

View File

@ -0,0 +1,14 @@
# The following has been generated automatically from src/gui/qgsshortcutsmanager.h
# monkey patching scoped based enum
QgsShortcutsManager.CommonAction.CodeToggleComment.__doc__ = "Toggle code comments"
QgsShortcutsManager.CommonAction.CodeReformat.__doc__ = "Reformat code"
QgsShortcutsManager.CommonAction.__doc__ = """Contains common actions which are used across a variety of classes.
.. versionadded:: 4.0
* ``CodeToggleComment``: Toggle code comments
* ``CodeReformat``: Reformat code
"""
# --
QgsShortcutsManager.CommonAction.baseClass = QgsShortcutsManager

View File

@ -23,6 +23,12 @@ rather accessed through :py:func:`QgsGui.shortcutsManager()`.
#include "qgsshortcutsmanager.h"
%End
public:
enum class CommonAction
{
CodeToggleComment,
CodeReformat,
};
QgsShortcutsManager( QObject *parent /TransferThis/ = 0, const QString &settingsRoot = "/shortcuts/" );
%Docstring
Constructor for QgsShortcutsManager.
@ -36,6 +42,8 @@ Constructor for QgsShortcutsManager.
QGIS actions.
%End
~QgsShortcutsManager();
void registerAllChildren( QObject *object, bool recursive = false, const QString &section = QString() );
%Docstring
Automatically registers all QActions and QShortcuts which are children
@ -105,6 +113,16 @@ in GUI.
.. seealso:: :py:func:`unregisterAction`
.. seealso:: :py:func:`registerAllChildActions`
%End
void initializeCommonAction( QAction *action, CommonAction commonAction );
%Docstring
Initializes an ``action`` as a common action.
This automatically configures the ``action`` to use the properties for
the common action, such as setting the action's tooltip and shortcut.
.. versionadded:: 4.0
%End
bool registerShortcut( QShortcut *shortcut, const QString &defaultSequence = QString(), const QString &section = QString() );

View File

@ -0,0 +1,14 @@
# The following has been generated automatically from src/gui/qgsshortcutsmanager.h
# monkey patching scoped based enum
QgsShortcutsManager.CommonAction.CodeToggleComment.__doc__ = "Toggle code comments"
QgsShortcutsManager.CommonAction.CodeReformat.__doc__ = "Reformat code"
QgsShortcutsManager.CommonAction.__doc__ = """Contains common actions which are used across a variety of classes.
.. versionadded:: 4.0
* ``CodeToggleComment``: Toggle code comments
* ``CodeReformat``: Reformat code
"""
# --
QgsShortcutsManager.CommonAction.baseClass = QgsShortcutsManager

View File

@ -23,6 +23,12 @@ rather accessed through :py:func:`QgsGui.shortcutsManager()`.
#include "qgsshortcutsmanager.h"
%End
public:
enum class CommonAction
{
CodeToggleComment,
CodeReformat,
};
QgsShortcutsManager( QObject *parent /TransferThis/ = 0, const QString &settingsRoot = "/shortcuts/" );
%Docstring
Constructor for QgsShortcutsManager.
@ -36,6 +42,8 @@ Constructor for QgsShortcutsManager.
QGIS actions.
%End
~QgsShortcutsManager();
void registerAllChildren( QObject *object, bool recursive = false, const QString &section = QString() );
%Docstring
Automatically registers all QActions and QShortcuts which are children
@ -105,6 +113,16 @@ in GUI.
.. seealso:: :py:func:`unregisterAction`
.. seealso:: :py:func:`registerAllChildActions`
%End
void initializeCommonAction( QAction *action, CommonAction commonAction );
%Docstring
Initializes an ``action`` as a common action.
This automatically configures the ``action`` to use the properties for
the common action, such as setting the action's tooltip and shortcut.
.. versionadded:: 4.0
%End
bool registerShortcut( QShortcut *shortcut, const QString &defaultSequence = QString(), const QString &section = QString() );

View File

@ -17,6 +17,7 @@
#include "moc_qgsshortcutsmanager.cpp"
#include "qgslogger.h"
#include "qgssettings.h"
#include "qgsapplication.h"
#include <QShortcut>
#include <QRegularExpression>
@ -26,6 +27,31 @@ QgsShortcutsManager::QgsShortcutsManager( QObject *parent, const QString &settin
: QObject( parent )
, mSettingsPath( settingsRoot )
{
// Register common actions
auto registerCommonAction = [this]( CommonAction commonAction, const QIcon &icon, const QString &text, const QString &toolTip, const QString &sequence, const QString &objectName, const QString &section ) {
QAction *action = new QAction( icon, text, this );
action->setToolTip( toolTip );
setObjectName( objectName );
// We do not want these actions to be enabled, they are just there to be able to change
// the shortcuts in the Shortcuts Manager.
action->setEnabled( false );
action->setProperty( "commonAction", static_cast< int >( commonAction ) );
registerAction( action, sequence, section );
mCommonActions.insert( static_cast< int >( commonAction ), action );
};
registerCommonAction( CommonAction::CodeToggleComment, QgsApplication::getThemeIcon( QStringLiteral( "console/iconCommentEditorConsole.svg" ), QgsApplication::palette().color( QPalette::ColorRole::WindowText ) ), tr( "Toggle Comment" ), tr( "Toggle comment" ), QStringLiteral( "Ctrl+/" ), QStringLiteral( "mEditorToggleComment" ), QStringLiteral( "Editor" ) );
registerCommonAction( CommonAction::CodeReformat, QgsApplication::getThemeIcon( QStringLiteral( "console/iconFormatCode.svg" ) ), tr( "Reformat Code" ), tr( "Reformat code" ), QStringLiteral( "Ctrl+Alt+F" ), QStringLiteral( "mEditorReformatCode" ), QStringLiteral( "Editor" ) );
}
QgsShortcutsManager::~QgsShortcutsManager()
{
// delete all common actions BEFORE this object is destroyed -- they have a lambda connection which
// we do NOT want to be triggered during the qt child object cleanup which will occur after this destructor
const QHash< int, QAction * > commonActionsToCleanup = std::move( mCommonActions );
for ( auto it = commonActionsToCleanup.constBegin(); it != commonActionsToCleanup.constEnd(); ++it )
{
delete it.value();
}
}
void QgsShortcutsManager::registerAllChildren( QObject *object, bool recursive, const QString &section )
@ -112,6 +138,21 @@ bool QgsShortcutsManager::registerAction( QAction *action, const QString &defaul
return true;
}
void QgsShortcutsManager::initializeCommonAction( QAction *action, CommonAction commonAction )
{
const auto it = mCommonActions.constFind( static_cast< int >( commonAction ) );
if ( it == mCommonActions.constEnd() )
return;
// copy properties from common action
action->setText( it.value()->text() );
action->setToolTip( it.value()->toolTip() );
action->setShortcut( it.value()->shortcut() );
mLinkedCommonActions.insert( action, commonAction );
connect( action, &QObject::destroyed, this, [action, this]() { actionDestroyed( action ); } );
}
bool QgsShortcutsManager::registerShortcut( QShortcut *shortcut, const QString &defaultSequence, const QString &section )
{
#ifdef QGISDEBUG
@ -229,6 +270,21 @@ bool QgsShortcutsManager::setKeySequence( QAction *action, const QString &sequen
action->setShortcut( sequence );
this->updateActionToolTip( action, sequence );
if ( action->property( "commonAction" ).isValid() )
{
// if the key sequence for a common action is changed, update all QActions currently linked
// to that common action
const CommonAction commonAction = static_cast< CommonAction >( action->property( "commonAction" ).toInt() );
for ( auto it = mLinkedCommonActions.constBegin(); it != mLinkedCommonActions.constEnd(); ++it )
{
if ( it.value() == commonAction )
{
it.key()->setShortcut( action->shortcut() );
it.key()->setToolTip( action->toolTip() );
}
}
}
const QString settingKey = mActions[action].second;
// save to settings
@ -323,6 +379,7 @@ QShortcut *QgsShortcutsManager::shortcutByName( const QString &name ) const
void QgsShortcutsManager::actionDestroyed( QAction *action )
{
mActions.remove( action );
mLinkedCommonActions.remove( action );
}
QString QgsShortcutsManager::objectSettingKey( QObject *object ) const
@ -377,7 +434,7 @@ QString QgsShortcutsManager::formatActionToolTip( const QString &toolTip )
void QgsShortcutsManager::updateActionToolTip( QAction *action, const QString &sequence )
{
QString current = action->toolTip();
const thread_local QRegularExpression rx( QStringLiteral( "\\((.*)\\)" ) );
const thread_local QRegularExpression rx( QStringLiteral( "\\s*\\((.*)\\)" ) );
// Look for the last occurrence of text inside parentheses
QRegularExpressionMatch match;
if ( current.lastIndexOf( rx, -1, &match ) != -1 )

View File

@ -38,6 +38,17 @@ class GUI_EXPORT QgsShortcutsManager : public QObject
Q_OBJECT
public:
/**
* Contains common actions which are used across a variety of classes.
* \since QGIS 4.0
*/
enum class CommonAction
{
CodeToggleComment, //!< Toggle code comments
CodeReformat, //!< Reformat code
};
Q_ENUM( CommonAction )
/**
* Constructor for QgsShortcutsManager.
* \param parent parent object
@ -47,6 +58,8 @@ class GUI_EXPORT QgsShortcutsManager : public QObject
*/
QgsShortcutsManager( QObject *parent SIP_TRANSFERTHIS = nullptr, const QString &settingsRoot = "/shortcuts/" );
~QgsShortcutsManager() override;
/**
* Automatically registers all QActions and QShortcuts which are children of the
* passed object.
@ -93,6 +106,16 @@ class GUI_EXPORT QgsShortcutsManager : public QObject
*/
bool registerAction( QAction *action, const QString &defaultShortcut = QString(), const QString &section = QString() );
/**
* Initializes an \a action as a common action.
*
* This automatically configures the \a action to use the properties for the common action, such
* as setting the action's tooltip and shortcut.
*
* \since QGIS 4.0
*/
void initializeCommonAction( QAction *action, CommonAction commonAction );
/**
* Registers a QShortcut with the manager so the shortcut can be configured in GUI.
* \param shortcut QShortcut to register. The shortcut must have a unique QObject::objectName() for
@ -273,6 +296,8 @@ class GUI_EXPORT QgsShortcutsManager : public QObject
ActionsHash mActions;
ShortcutsHash mShortcuts;
QString mSettingsPath;
QHash< int, QAction * > mCommonActions;
QHash< QAction *, CommonAction > mLinkedCommonActions;
static QString formatActionToolTip( const QString &toolTip );

View File

@ -1,4 +1,4 @@
"""QGIS Unit tests for QgsActionManager.
"""QGIS Unit tests for QgsShortcutsManager.
.. note:: This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -10,7 +10,9 @@ __author__ = "Nyall Dawson"
__date__ = "28/05/2016"
__copyright__ = "Copyright 2016, The QGIS Project"
from qgis.PyQt.QtCore import QCoreApplication
from typing import List
from qgis.PyQt.QtCore import QCoreApplication, QObject, QEvent
from qgis.PyQt.QtWidgets import QAction, QShortcut, QWidget
from qgis.core import QgsSettings
from qgis.gui import QgsGui, QgsShortcutsManager
@ -25,11 +27,15 @@ class TestQgsShortcutsManager(QgisTestCase):
"""Run before all tests"""
super().setUpClass()
QCoreApplication.setOrganizationName("QGIS_Test")
QCoreApplication.setOrganizationDomain("QGIS_TestPyQgsWFSProviderGUI.com")
QCoreApplication.setApplicationName("QGIS_TestPyQgsWFSProviderGUI")
QCoreApplication.setOrganizationDomain("QGIS_TestPyQgsShortcutsManager.com")
QCoreApplication.setApplicationName("QGIS_TestPyQgsShortcutsManager")
QgsSettings().clear()
start_app()
@staticmethod
def filter_common_actions(actions: list[QObject]) -> list[QObject]:
return [a for a in actions if a.property("commonAction") is None]
def testInstance(self):
"""test retrieving global instance"""
self.assertTrue(QgsGui.shortcutsManager())
@ -38,9 +44,12 @@ class TestQgsShortcutsManager(QgisTestCase):
action = QAction("test", None)
QgsGui.shortcutsManager().registerAction(action)
# check that the same instance is returned
self.assertEqual(QgsGui.shortcutsManager().listActions(), [action])
self.assertEqual(
self.filter_common_actions(QgsGui.shortcutsManager().listActions()),
[action],
)
s2 = QgsShortcutsManager()
self.assertEqual(s2.listActions(), [])
self.assertEqual(self.filter_common_actions(s2.listActions()), [])
def testConstructor(self):
"""test constructing managers"""
@ -87,11 +96,15 @@ class TestQgsShortcutsManager(QgisTestCase):
action2 = QAction("action2", None)
action2.setShortcut("y")
self.assertTrue(s.registerAction(action2, "B"))
self.assertCountEqual(s.listActions(), [action1, action2])
self.assertCountEqual(
self.filter_common_actions(s.listActions()), [action1, action2]
)
# try re-registering an existing action - should fail, but leave action registered
self.assertFalse(s.registerAction(action2, "B"))
self.assertCountEqual(s.listActions(), [action1, action2])
self.assertCountEqual(
self.filter_common_actions(s.listActions()), [action1, action2]
)
# actions should have been set to default sequences
self.assertEqual(action1.shortcut().toString(), "A")
@ -161,27 +174,31 @@ class TestQgsShortcutsManager(QgisTestCase):
# recursive
s = QgsShortcutsManager()
s.registerAllChildActions(w, True)
self.assertEqual(set(s.listActions()), {action1, action2})
self.assertEqual(
set(self.filter_common_actions(s.listActions())), {action1, action2}
)
s.registerAllChildShortcuts(w, True)
self.assertEqual(set(s.listShortcuts()), {shortcut1, shortcut2})
# non recursive
s = QgsShortcutsManager()
s.registerAllChildActions(w, False)
self.assertEqual(set(s.listActions()), {action1})
self.assertEqual(set(self.filter_common_actions(s.listActions())), {action1})
s.registerAllChildShortcuts(w, False)
self.assertEqual(set(s.listShortcuts()), {shortcut1})
# recursive
s = QgsShortcutsManager()
s.registerAllChildren(w, True)
self.assertEqual(set(s.listActions()), {action1, action2})
self.assertEqual(
set(self.filter_common_actions(s.listActions())), {action1, action2}
)
self.assertEqual(set(s.listShortcuts()), {shortcut1, shortcut2})
# non recursive
s = QgsShortcutsManager()
s.registerAllChildren(w, False)
self.assertEqual(set(s.listActions()), {action1})
self.assertEqual(set(self.filter_common_actions(s.listActions())), {action1})
self.assertEqual(set(s.listShortcuts()), {shortcut1})
def testUnregister(self):
@ -213,13 +230,15 @@ class TestQgsShortcutsManager(QgisTestCase):
s.registerAction(action1)
s.registerAction(action2)
self.assertEqual(set(s.listActions()), {action1, action2})
self.assertEqual(
set(self.filter_common_actions(s.listActions())), {action1, action2}
)
self.assertEqual(set(s.listShortcuts()), {shortcut1, shortcut2})
self.assertTrue(s.unregisterAction(action1))
self.assertTrue(s.unregisterShortcut(shortcut1))
self.assertEqual(set(s.listActions()), {action2})
self.assertEqual(set(self.filter_common_actions(s.listActions())), {action2})
self.assertEqual(set(s.listShortcuts()), {shortcut2})
self.assertTrue(s.unregisterAction(action2))
@ -232,9 +251,9 @@ class TestQgsShortcutsManager(QgisTestCase):
s = QgsShortcutsManager(None)
self.assertEqual(s.listActions(), [])
self.assertEqual(self.filter_common_actions(s.listActions()), [])
self.assertEqual(s.listShortcuts(), [])
self.assertEqual(s.listAll(), [])
self.assertEqual(self.filter_common_actions(s.listAll()), [])
shortcut1 = QShortcut(None)
shortcut2 = QShortcut(None)
@ -245,9 +264,14 @@ class TestQgsShortcutsManager(QgisTestCase):
s.registerAction(action1)
s.registerAction(action2)
self.assertEqual(set(s.listActions()), {action1, action2})
self.assertEqual(
set(self.filter_common_actions(s.listActions())), {action1, action2}
)
self.assertEqual(set(s.listShortcuts()), {shortcut1, shortcut2})
self.assertEqual(set(s.listAll()), {action1, action2, shortcut1, shortcut2})
self.assertEqual(
set(self.filter_common_actions(s.listAll())),
{action1, action2, shortcut1, shortcut2},
)
def testDefault(self):
"""test retrieving default sequences"""
@ -462,7 +486,95 @@ class TestQgsShortcutsManager(QgisTestCase):
self.assertEqual(action1.toolTip(), "<b>my tooltip</b>")
self.assertEqual(action2.toolTip(), "<b>my multiline</b><p>tooltip</p>")
self.assertEqual(action3.toolTip(), "<b>my tooltip </b> (Ctrl+S)")
self.assertEqual(action3.toolTip(), "<b>my tooltip</b> (Ctrl+S)")
def test_common_actions(self):
s = QgsShortcutsManager(None)
reformat_code_action = [
a
for a in s.listActions()
if a.property("commonAction")
== QgsShortcutsManager.CommonAction.CodeReformat.value
][0]
self.assertEqual(reformat_code_action.text(), "Reformat Code")
self.assertEqual(reformat_code_action.shortcut().toString(), "Ctrl+Alt+F")
self.assertEqual(
reformat_code_action.toolTip(), "<b>Reformat code</b> (Ctrl+Alt+F)"
)
toggle_code_comment_action = [
a
for a in s.listActions()
if a.property("commonAction")
== QgsShortcutsManager.CommonAction.CodeToggleComment.value
][0]
self.assertEqual(toggle_code_comment_action.text(), "Toggle Comment")
self.assertEqual(toggle_code_comment_action.shortcut().toString(), "Ctrl+/")
self.assertEqual(
toggle_code_comment_action.toolTip(), "<b>Toggle comment</b> (Ctrl+/)"
)
# link an action to a common action
my_reformat_action1 = QAction()
s.initializeCommonAction(
my_reformat_action1, QgsShortcutsManager.CommonAction.CodeReformat
)
my_reformat_action2 = QAction()
s.initializeCommonAction(
my_reformat_action2, QgsShortcutsManager.CommonAction.CodeReformat
)
# default properties should be set
self.assertEqual(my_reformat_action1.text(), "Reformat Code")
self.assertEqual(my_reformat_action1.shortcut().toString(), "Ctrl+Alt+F")
self.assertEqual(
my_reformat_action1.toolTip(), "<b>Reformat code</b> (Ctrl+Alt+F)"
)
self.assertEqual(my_reformat_action2.text(), "Reformat Code")
self.assertEqual(my_reformat_action2.shortcut().toString(), "Ctrl+Alt+F")
self.assertEqual(
my_reformat_action2.toolTip(), "<b>Reformat code</b> (Ctrl+Alt+F)"
)
my_toggle_comment_action = QAction()
s.initializeCommonAction(
my_toggle_comment_action, QgsShortcutsManager.CommonAction.CodeToggleComment
)
self.assertEqual(my_toggle_comment_action.text(), "Toggle Comment")
self.assertEqual(my_toggle_comment_action.shortcut().toString(), "Ctrl+/")
self.assertEqual(
my_toggle_comment_action.toolTip(), "<b>Toggle comment</b> (Ctrl+/)"
)
# change shortcut
s.setKeySequence(reformat_code_action, "B")
self.assertEqual(my_reformat_action1.shortcut().toString(), "B")
self.assertEqual(my_reformat_action1.toolTip(), "<b>Reformat code</b> (B)")
self.assertEqual(my_reformat_action2.shortcut().toString(), "B")
self.assertEqual(my_reformat_action2.toolTip(), "<b>Reformat code</b> (B)")
self.assertEqual(my_toggle_comment_action.shortcut().toString(), "Ctrl+/")
s.setKeySequence(toggle_code_comment_action, "C")
self.assertEqual(my_reformat_action1.shortcut().toString(), "B")
self.assertEqual(my_reformat_action1.toolTip(), "<b>Reformat code</b> (B)")
self.assertEqual(my_reformat_action2.shortcut().toString(), "B")
self.assertEqual(my_reformat_action2.toolTip(), "<b>Reformat code</b> (B)")
self.assertEqual(my_toggle_comment_action.shortcut().toString(), "C")
self.assertEqual(
my_toggle_comment_action.toolTip(), "<b>Toggle comment</b> (C)"
)
# delete local action
my_reformat_action2.deleteLater()
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
my_reformat_action2 = None
# should be no crash
s.setKeySequence(reformat_code_action, "D")
self.assertEqual(my_reformat_action1.shortcut().toString(), "D")
self.assertEqual(my_reformat_action1.toolTip(), "<b>Reformat code</b> (D)")
self.assertEqual(my_toggle_comment_action.shortcut().toString(), "C")
self.assertEqual(
my_toggle_comment_action.toolTip(), "<b>Toggle comment</b> (C)"
)
if __name__ == "__main__":