You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
510 lines
17 KiB
C++
510 lines
17 KiB
C++
/*
|
|
* Copyright (c) Contributors to the Open 3D Engine Project. For complete copyright and license terms please see the LICENSE at the root of this distribution.
|
|
*
|
|
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
*
|
|
*/
|
|
|
|
#include "EditorDefs.h"
|
|
|
|
#include "ShortcutDispatcher.h"
|
|
|
|
#include <assert.h>
|
|
|
|
|
|
// Qt
|
|
#include <QScopedValueRollback>
|
|
#include <QAction>
|
|
#include <QDockWidget>
|
|
#include <QDebug>
|
|
#include <QMainWindow>
|
|
#include <QMenuBar>
|
|
|
|
// AzQtComponents
|
|
#include <AzQtComponents/Buses/ShortcutDispatch.h>
|
|
|
|
// Editor
|
|
#include "ActionManager.h"
|
|
#include "QtViewPaneManager.h"
|
|
|
|
const char* FOCUSED_VIEW_PANE_EVENT_NAME = "FocusedViewPaneEvent"; //Sent when view panes are focused
|
|
const char* FOCUSED_VIEW_PANE_ATTRIBUTE_NAME = "FocusedViewPaneName"; //Name of the current focused view pane
|
|
|
|
// Build sets _DEBUG when we're in debug mode; define SHOW_ACTION_INFO_IN_DEBUGGER so that when stepping through the debugger,
|
|
// we can see what the actions are and what their keyboard shortcuts are
|
|
#define SHOW_ACTION_INFO_IN_DEBUGGER _DEBUG
|
|
|
|
static QPointer<QWidget> s_lastFocus;
|
|
|
|
#if AZ_TRAIT_OS_PLATFORM_APPLE
|
|
class MacNativeShortcutFilter : public QObject
|
|
{
|
|
/* mac's native toolbar doesn't generate shortcut events, it calls the action directly.
|
|
* It doesn't even honour shortcut contexts.
|
|
*
|
|
* To remedy this, we catch the QMetaCallEvent that triggers the menu item activation
|
|
* and suppress it if it was triggered via key combination, and send a QShortcutEvent.
|
|
*
|
|
* The tricky part is to find out if the menu item was triggered via mouse or shortcut.
|
|
* If the previous event was a ShortcutOverride then it means key press.
|
|
*/
|
|
|
|
public:
|
|
explicit MacNativeShortcutFilter(QObject* parent)
|
|
: QObject(parent)
|
|
, m_lastShortcutOverride(QEvent::KeyPress, 0, Qt::NoModifier) // dummy init
|
|
{
|
|
qApp->installEventFilter(this);
|
|
}
|
|
|
|
bool eventFilter(QObject* watched, QEvent* event) override
|
|
{
|
|
|
|
switch (event->type())
|
|
{
|
|
case QEvent::ShortcutOverride:
|
|
{
|
|
auto ke = static_cast<QKeyEvent*>(event);
|
|
m_lastEventWasShortcutOverride = true;
|
|
m_lastShortcutOverride = QKeyEvent(*ke);
|
|
break;
|
|
}
|
|
case QEvent::MetaCall:
|
|
{
|
|
if (m_lastEventWasShortcutOverride)
|
|
{
|
|
m_lastEventWasShortcutOverride = false;
|
|
const QMetaObject* mo = watched->metaObject();
|
|
const bool isMenuItem = mo && qstrcmp(mo->className(), "QPlatformMenuItem") == 0;
|
|
if (isMenuItem)
|
|
{
|
|
QWidget* focusWidget = ShortcutDispatcher::focusWidget();
|
|
if (focusWidget)
|
|
{
|
|
QShortcutEvent se(QKeySequence(m_lastShortcutOverride.key() + m_lastShortcutOverride.modifiers()), /*ambiguous=*/false);
|
|
se.setAccepted(false);
|
|
QApplication::sendEvent(focusWidget, &se);
|
|
return se.isAccepted();
|
|
}
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
case QEvent::MouseButtonDblClick:
|
|
case QEvent::MouseButtonPress:
|
|
case QEvent::MouseButtonRelease:
|
|
case QEvent::KeyPress:
|
|
case QEvent::KeyRelease:
|
|
m_lastEventWasShortcutOverride = false;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool m_lastEventWasShortcutOverride = false;
|
|
QKeyEvent m_lastShortcutOverride;
|
|
};
|
|
#endif
|
|
|
|
// Returns either a top-level or a dock widget (regardless of floating)
|
|
// This way when docking a main window Qt::WindowShortcut still works
|
|
QWidget* ShortcutDispatcher::FindParentScopeRoot(QWidget* widget)
|
|
{
|
|
QWidget* w = widget;
|
|
// if the current scope root is a QDockWidget, we want to bubble out
|
|
// so we move to the parent immediately
|
|
if (qobject_cast<QDockWidget*>(w) != nullptr || qobject_cast<QMainWindow*>(w) != nullptr)
|
|
{
|
|
w = w->parentWidget();
|
|
}
|
|
|
|
QWidget* newScopeRoot = w;
|
|
while (newScopeRoot && newScopeRoot->parent() && !qobject_cast<QDockWidget*>(newScopeRoot) && !qobject_cast<QMainWindow*>(newScopeRoot))
|
|
{
|
|
newScopeRoot = newScopeRoot->parentWidget();
|
|
}
|
|
|
|
// This method should always return a parent scope root; if it can't find one
|
|
// it returns null
|
|
if (newScopeRoot == widget)
|
|
{
|
|
newScopeRoot = nullptr;
|
|
|
|
if (w != nullptr)
|
|
{
|
|
// we couldn't find a valid parent; broadcast a message to see if something else wants to tell us about one
|
|
AzQtComponents::ShortcutDispatchBus::EventResult(newScopeRoot, w, &AzQtComponents::ShortcutDispatchBus::Events::GetShortcutDispatchScopeRoot, w);
|
|
}
|
|
}
|
|
|
|
return newScopeRoot;
|
|
}
|
|
|
|
// Returns true if a widget is ancestor of another widget
|
|
bool ShortcutDispatcher::IsAContainerForB(QWidget* a, QWidget* b)
|
|
{
|
|
if (!a || !b)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
while (b && (a != b))
|
|
{
|
|
b = b->parentWidget();
|
|
}
|
|
|
|
return (a == b);
|
|
}
|
|
|
|
// Returns the list of QActions which have this specific key shortcut
|
|
// Only QActions under scopeRoot are considered.
|
|
QList<QAction*> ShortcutDispatcher::FindCandidateActions(QObject* scopeRoot, const QKeySequence& sequence, QSet<QObject*>& previouslyVisited, bool checkVisibility)
|
|
{
|
|
QList<QAction*> actions;
|
|
if (!scopeRoot)
|
|
{
|
|
return actions;
|
|
}
|
|
|
|
if (previouslyVisited.contains(scopeRoot))
|
|
{
|
|
return actions;
|
|
}
|
|
previouslyVisited.insert(scopeRoot);
|
|
|
|
QWidget* scopeRootWidget = qobject_cast<QWidget*>(scopeRoot);
|
|
if (scopeRootWidget && ((checkVisibility && !scopeRootWidget->isVisible()) || !scopeRootWidget->isEnabled()))
|
|
{
|
|
return actions;
|
|
}
|
|
|
|
#ifdef SHOW_ACTION_INFO_IN_DEBUGGER
|
|
QString matchingAgainst = sequence.toString();
|
|
(void) matchingAgainst; // avoid an unused variable warning; want this for debugging
|
|
#endif
|
|
|
|
// Don't just call scopeRoot->actions()! It doesn't always return the proper list, especially with the dock widgets
|
|
for (QAction* action : scopeRoot->findChildren<QAction*>(QString(), Qt::FindDirectChildrenOnly)) // Width first
|
|
{
|
|
#ifdef SHOW_ACTION_INFO_IN_DEBUGGER
|
|
QString actionName = action->text();
|
|
(void)actionName; // avoid an unused variable warning; want this for debugging
|
|
QString shortcut = action->shortcut().toString();
|
|
(void)shortcut; // avoid an unused variable warning; want this for debugging
|
|
#endif
|
|
|
|
if (action->shortcut() == sequence)
|
|
{
|
|
actions << action;
|
|
}
|
|
}
|
|
|
|
// also have to check the actions on the object directly, without looking at children
|
|
// specifically for the base Editor MainWindow
|
|
if (scopeRootWidget)
|
|
{
|
|
for (QAction* action : scopeRootWidget->actions())
|
|
{
|
|
#ifdef SHOW_ACTION_INFO_IN_DEBUGGER
|
|
QString actionName = action->text();
|
|
(void)actionName; // avoid an unused variable warning; want this for debugging
|
|
QString shortcut = action->shortcut().toString();
|
|
(void)shortcut; // avoid an unused variable warning; want this for debugging
|
|
#endif
|
|
|
|
if (action->shortcut() == sequence)
|
|
{
|
|
actions << action;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Menubars have child widgets that have actions
|
|
// But menu bar child widgets (menu items) are only visible when they've been clicked on
|
|
// so we don't want to test visibility for child widgets of menubars
|
|
if (qobject_cast<QMenuBar*>(scopeRoot))
|
|
{
|
|
checkVisibility = false;
|
|
}
|
|
|
|
// check the dock's central widget and the main window's
|
|
// In some cases, they aren't in the scopeRoot's children, despite having the scopeRoot as their parent
|
|
QDockWidget* dockWidget = qobject_cast<QDockWidget*>(scopeRoot);
|
|
if (dockWidget)
|
|
{
|
|
actions << FindCandidateActions(dockWidget->widget(), sequence, previouslyVisited, checkVisibility);
|
|
}
|
|
|
|
QMainWindow* mainWindow = qobject_cast<QMainWindow*>(scopeRoot);
|
|
if (mainWindow)
|
|
{
|
|
actions << FindCandidateActions(mainWindow->centralWidget(), sequence, previouslyVisited, checkVisibility);
|
|
}
|
|
|
|
for (QWidget* child : scopeRoot->findChildren<QWidget*>(QString(), Qt::FindDirectChildrenOnly))
|
|
{
|
|
bool isMenu = (qobject_cast<QMenu*>(child) != nullptr);
|
|
|
|
if ((child->windowFlags() & Qt::Window) && !isMenu)
|
|
{
|
|
// When going down the hierarchy stop at window boundaries, to not accidentally trigger
|
|
// shortcuts from unfocused windows. Windows might be parented to this scope for purposes
|
|
// "centering within parent" or lifetime.
|
|
// Don't stop at menus though, as they are flagged as Qt::Window but are often the only thing that
|
|
// actions are attached to.
|
|
continue;
|
|
}
|
|
|
|
bool isDockWidget = (qobject_cast<QDockWidget*>(child) != nullptr);
|
|
if ((isDockWidget && !actions.isEmpty()) || IsShortcutSearchBreak(child))
|
|
{
|
|
// If we already found a candidate, don't go into dock widgets, they have lower priority
|
|
// since they are not focused.
|
|
// Also never go into viewpanes; viewpanes are their own separate shortcut context and they never take shortcuts from the main window
|
|
continue;
|
|
}
|
|
|
|
actions << FindCandidateActions(child, sequence, previouslyVisited, checkVisibility);
|
|
}
|
|
|
|
return actions;
|
|
}
|
|
|
|
bool ShortcutDispatcher::FindCandidateActionAndFire(QObject* focusWidget, QShortcutEvent* shortcutEvent, QList<QAction*>& candidates, QSet<QObject*>& previouslyVisited)
|
|
{
|
|
candidates = FindCandidateActions(focusWidget, shortcutEvent->key(), previouslyVisited);
|
|
QSet<QAction*> candidateSet = QSet<QAction*>(candidates.begin(), candidates.end());
|
|
QAction* chosenAction = nullptr;
|
|
int numCandidates = candidateSet.size();
|
|
if (numCandidates == 1)
|
|
{
|
|
chosenAction = candidates.first();
|
|
}
|
|
else if (numCandidates > 1)
|
|
{
|
|
// If there are multiple candidates, choose the one that is parented to the ActionManager
|
|
// since there are cases where panes with their own menu shortcuts that are docked
|
|
// in the main window can be found in the same parent scope
|
|
for (QAction* action : candidateSet)
|
|
{
|
|
if (qobject_cast<ActionManager*>(action->parent()))
|
|
{
|
|
chosenAction = action;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (chosenAction)
|
|
{
|
|
if (chosenAction->isEnabled())
|
|
{
|
|
// has to be send, not post, or the dispatcher will get the event again and won't know that it was the one that queued it
|
|
bool isAmbiguous = false;
|
|
QShortcutEvent newEvent(shortcutEvent->key(), isAmbiguous);
|
|
QApplication::sendEvent(chosenAction, &newEvent);
|
|
}
|
|
shortcutEvent->accept();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
ShortcutDispatcher::ShortcutDispatcher(QObject* parent)
|
|
: QObject(parent)
|
|
, m_currentlyHandlingShortcut(false)
|
|
{
|
|
qApp->installEventFilter(this);
|
|
#if AZ_TRAIT_OS_PLATFORM_APPLE
|
|
new MacNativeShortcutFilter(this);
|
|
#endif
|
|
}
|
|
|
|
ShortcutDispatcher::~ShortcutDispatcher()
|
|
{
|
|
}
|
|
|
|
bool ShortcutDispatcher::eventFilter(QObject* obj, QEvent* ev)
|
|
{
|
|
switch (ev->type())
|
|
{
|
|
case QEvent::ShortcutOverride:
|
|
// QActions default "autoRepeat" to true, which is not an ideal user experience
|
|
// We globally disable that behavior here - in the unlikely event a shortcut needs to
|
|
// replicate it, its owner can instead implement a keyEvent handler
|
|
if (static_cast<QKeyEvent*>(ev)->isAutoRepeat())
|
|
{
|
|
ev->accept();
|
|
return true;
|
|
}
|
|
break;
|
|
|
|
case QEvent::Shortcut:
|
|
return shortcutFilter(obj, static_cast<QShortcutEvent*>(ev));
|
|
break;
|
|
|
|
case QEvent::MouseButtonPress:
|
|
if (!s_lastFocus || !IsAContainerForB(qobject_cast<QWidget*>(obj), s_lastFocus))
|
|
{
|
|
setNewFocus(obj);
|
|
}
|
|
break;
|
|
|
|
case QEvent::FocusIn:
|
|
setNewFocus(obj);
|
|
break;
|
|
|
|
// we don't really care about focus out, because something should always have the focus
|
|
// but I'm leaving this here, so that it's clear that this is intentional
|
|
//case QEvent::FocusOut:
|
|
// break;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
QWidget* ShortcutDispatcher::focusWidget()
|
|
{
|
|
QWidget* focusWidget = s_lastFocus; // check the widget we tracked last
|
|
if (!focusWidget)
|
|
{
|
|
// we don't have anything, so fall back to using the focus object
|
|
focusWidget = qobject_cast<QWidget*>(qApp->focusObject()); // QApplication::focusWidget() doesn't always work
|
|
}
|
|
|
|
return focusWidget;
|
|
}
|
|
|
|
bool ShortcutDispatcher::shortcutFilter(QObject* obj, QShortcutEvent* shortcutEvent)
|
|
{
|
|
if (m_currentlyHandlingShortcut)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
QScopedValueRollback<bool> recursiveCheck(m_currentlyHandlingShortcut, true);
|
|
|
|
// prioritize m_actionOverrideObject if active
|
|
if (m_actionOverrideObject != nullptr)
|
|
{
|
|
QList<QAction*> childActions =
|
|
m_actionOverrideObject->findChildren<QAction*>(QString(), Qt::FindDirectChildrenOnly);
|
|
|
|
// attempt to find shortcut in override
|
|
const auto childActionIt = AZStd::find_if(
|
|
childActions.begin(), childActions.end(), [shortcutEvent](QAction* child)
|
|
{
|
|
return child->shortcut() == shortcutEvent->key();
|
|
});
|
|
|
|
// trigger shortcut
|
|
if (childActionIt != childActions.end())
|
|
{
|
|
// has to be send, not post, or the dispatcher will get the event again
|
|
// and won't know that it was the one that queued it
|
|
const bool isAmbiguous = false;
|
|
QShortcutEvent newEvent(shortcutEvent->key(), isAmbiguous);
|
|
|
|
QAction* action = *childActionIt;
|
|
QApplication::sendEvent(action, &newEvent);
|
|
|
|
shortcutEvent->accept();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
QWidget* currentFocusWidget = focusWidget(); // check the widget we tracked last
|
|
if (!currentFocusWidget)
|
|
{
|
|
qWarning() << Q_FUNC_INFO << "No focus widget"; // Defensive. Doesn't happen.
|
|
return false;
|
|
}
|
|
|
|
// Shortcut is ambiguous, lets resolve ambiguity and give preference to QActions in the most inner scope
|
|
|
|
// Try below the focusWidget first:
|
|
QSet<QObject*> previouslyVisited;
|
|
QList<QAction*> candidates;
|
|
if (FindCandidateActionAndFire(currentFocusWidget, shortcutEvent, candidates, previouslyVisited)) {
|
|
return true;
|
|
}
|
|
|
|
// Now incrementally try bigger scopes. This handles complex cases several levels docking nesting
|
|
|
|
QWidget* correctedTopLevel = nullptr;
|
|
QWidget* p = currentFocusWidget;
|
|
correctedTopLevel = FindParentScopeRoot(p);
|
|
while (correctedTopLevel)
|
|
{
|
|
if (FindCandidateActionAndFire(correctedTopLevel, shortcutEvent, candidates, previouslyVisited))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
p = correctedTopLevel;
|
|
correctedTopLevel = FindParentScopeRoot(p);
|
|
}
|
|
|
|
|
|
// Nothing else to do... shortcut is really ambiguous, or there's no actions, something for the developer to fix.
|
|
// Here's some debug info :
|
|
|
|
if (candidates.isEmpty())
|
|
{
|
|
qWarning() << Q_FUNC_INFO << "No candidate QActions found";
|
|
}
|
|
else
|
|
{
|
|
qWarning() << Q_FUNC_INFO << "Ambiguous shortcut:" << shortcutEvent->key() << "; focusWidget="
|
|
<< qApp->focusWidget() << "Candidates=" << candidates << "; obj = " << obj
|
|
<< "Focused top-level=" << currentFocusWidget;
|
|
for (auto ambiguousAction : candidates)
|
|
{
|
|
qWarning() << "action=" << ambiguousAction << "; action->parentWidget=" << ambiguousAction->parentWidget()
|
|
<< "; associatedWidgets=" << ambiguousAction->associatedWidgets()
|
|
<< "; shortcut=" << ambiguousAction->shortcut();
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
void ShortcutDispatcher::setNewFocus(QObject* obj)
|
|
{
|
|
// Unless every widget has strong focus, mouse clicks don't change the current focus widget
|
|
// which is a little unintuitive, compared to how we expect focus to work, right now.
|
|
// So instead of putting strong focus on everything, we detect focus change and mouse clicks
|
|
|
|
QWidget* widget = qobject_cast<QWidget*>(obj);
|
|
|
|
// we only watch widgets
|
|
if (widget == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// track it for later
|
|
s_lastFocus = widget;
|
|
}
|
|
|
|
bool ShortcutDispatcher::IsShortcutSearchBreak(QWidget* widget)
|
|
{
|
|
return widget->property(AzQtComponents::SHORTCUT_DISPATCHER_CONTEXT_BREAK_PROPERTY).toBool();
|
|
}
|
|
|
|
void ShortcutDispatcher::AttachOverride(QWidget* object)
|
|
{
|
|
m_actionOverrideObject = object;
|
|
}
|
|
|
|
void ShortcutDispatcher::DetachOverride()
|
|
{
|
|
m_actionOverrideObject = nullptr;
|
|
}
|
|
|
|
#include <moc_ShortcutDispatcher.cpp>
|