Files
create/src/Gui/ShortcutManager.cpp
timpieces 0e8dca9937 MacOS: Block shortcuts from overriding text input (#14869)
- It seems that on MacOS (vs other platforms), shortcuts for items in
  the application menu are given 'ultimate' priority, and will even take
  precedence over text inputs
- There is a mechanism in QT (I believe designed with mac in mind) to
  try to 'block' these shortcuts and send it to the focus instead. It's
  'Shortcut Override': https://wiki.qt.io/ShortcutOverride
- Initially I was going to only apply this check when it's a Command
  that overrides a known line-editing shortcut, but I figure it's
  simpler to just always apply it when editing text. I can't really
  imagine a user wanting to use an application shortcut while editing
  text, but if there's some compelling use-case for this then let me know
  and I'll add a further filter.

I'm quite optimistic that this won't have any ill-effects on other
platforms, but I'll need help from others to test this.
2025-12-22 21:39:00 -06:00

527 lines
17 KiB
C++

/****************************************************************************
* Copyright (c) 2022 Zheng Lei (realthunder) <realthunder.dev@gmail.com> *
* *
* This file is part of the FreeCAD CAx development system. *
* *
* This library is free software; you can redistribute it and/or *
* modify it under the terms of the GNU Library General Public *
* License as published by the Free Software Foundation; either *
* version 2 of the License, or (at your option) any later version. *
* *
* This library 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 Library General Public License for more details. *
* *
* You should have received a copy of the GNU Library General Public *
* License along with this library; see the file COPYING.LIB. If not, *
* write to the Free Software Foundation, Inc., 59 Temple Place, *
* Suite 330, Boston, MA 02111-1307, USA *
* *
****************************************************************************/
#include <limits>
#include <QShortcutEvent>
#include <QApplication>
#include <boost/algorithm/string/predicate.hpp>
#include <Base/Console.h>
#include <Base/Tools.h>
#include "ShortcutManager.h"
#include "Command.h"
#include "Window.h"
#include "Action.h"
using namespace Gui;
ShortcutManager::ShortcutManager()
{
hShortcuts = WindowParameter::getDefaultParameter()->GetGroup("Shortcut");
hShortcuts->Attach(this);
hPriorities = hShortcuts->GetGroup("Priorities");
hPriorities->Attach(this);
hSetting = hShortcuts->GetGroup("Settings");
hSetting->Attach(this);
timeout = hSetting->GetInt("ShortcutTimeout", 300);
timer.setSingleShot(true);
QObject::connect(&timer, &QTimer::timeout, [this]() { onTimer(); });
topPriority = 0;
for (const auto& v : hPriorities->GetIntMap()) {
priorities[v.first] = v.second;
if (topPriority < v.second) {
topPriority = v.second;
}
}
if (topPriority == 0) {
topPriority = 100;
}
QApplication::instance()->installEventFilter(this);
}
ShortcutManager::~ShortcutManager()
{
hShortcuts->Detach(this);
hSetting->Detach(this);
hPriorities->Detach(this);
}
static ShortcutManager* Instance;
ShortcutManager* ShortcutManager::instance()
{
if (!Instance) {
Instance = new ShortcutManager;
}
return Instance;
}
void ShortcutManager::destroy()
{
delete Instance;
Instance = nullptr;
}
void ShortcutManager::OnChange(Base::Subject<const char*>& src, const char* reason)
{
if (hSetting == &src) {
if (boost::equals(reason, "ShortcutTimeout")) {
timeout = hSetting->GetInt("ShortcutTimeout");
}
return;
}
if (busy) {
return;
}
if (hPriorities == &src) {
int p = hPriorities->GetInt(reason, 0);
if (p == 0) {
priorities.erase(reason);
}
else {
priorities[reason] = p;
}
if (topPriority < p) {
topPriority = p;
}
priorityChanged(reason, p);
return;
}
Base::StateLocker lock(busy);
auto cmd = Application::Instance->commandManager().getCommandByName(reason);
if (cmd) {
auto accel = cmd->getAccel();
if (!accel) {
accel = "";
}
QKeySequence oldShortcut = cmd->getShortcut();
QKeySequence newShortcut = getShortcut(reason, accel);
if (oldShortcut != newShortcut) {
cmd->setShortcut(newShortcut.toString());
shortcutChanged(reason, oldShortcut);
}
}
}
void ShortcutManager::reset(const char* cmd)
{
if (!Base::Tools::isNullOrEmpty(cmd)) {
QKeySequence oldShortcut = getShortcut(cmd);
hShortcuts->RemoveASCII(cmd);
if (oldShortcut != getShortcut(cmd)) {
shortcutChanged(cmd, oldShortcut);
}
int oldPriority = getPriority(cmd);
hPriorities->RemoveInt(cmd);
if (oldPriority != getPriority(cmd)) {
priorityChanged(cmd, oldPriority);
}
}
}
void ShortcutManager::resetAll()
{
{
Base::StateLocker lock(busy);
hShortcuts->Clear();
hPriorities->Clear();
for (auto cmd : Application::Instance->commandManager().getAllCommands()) {
if (cmd->getAction()) {
auto accel = cmd->getAccel();
if (!accel) {
accel = "";
}
cmd->setShortcut(getShortcut(nullptr, accel));
}
}
}
shortcutChanged("", QKeySequence());
priorityChanged("", 0);
}
QString ShortcutManager::getShortcut(const char* cmdName, const char* accel)
{
if (!accel) {
if (auto cmd = Application::Instance->commandManager().getCommandByName(cmdName)) {
accel = cmd->getAccel();
if (!accel) {
accel = "";
}
}
}
QString shortcut;
if (cmdName) {
shortcut = QString::fromLatin1(hShortcuts->GetASCII(cmdName, accel).c_str());
}
else {
shortcut = QString::fromLatin1(accel);
}
return QKeySequence(shortcut).toString(QKeySequence::NativeText);
}
void ShortcutManager::setShortcut(const char* cmdName, const char* accel)
{
if (!Base::Tools::isNullOrEmpty(cmdName)) {
setTopPriority(cmdName);
if (!accel) {
accel = "";
}
if (auto cmd = Application::Instance->commandManager().getCommandByName(cmdName)) {
auto defaultAccel = cmd->getAccel();
if (!defaultAccel) {
defaultAccel = "";
}
if (QKeySequence(QString::fromLatin1(accel))
== QKeySequence(QString::fromLatin1(defaultAccel))) {
hShortcuts->RemoveASCII(cmdName);
return;
}
}
hShortcuts->SetASCII(cmdName, accel);
}
}
bool ShortcutManager::checkShortcut(QObject* o, const QKeySequence& key)
{
auto focus = QApplication::focusWidget();
if (!focus) {
return false;
}
auto action = qobject_cast<QAction*>(o);
if (!action) {
return false;
}
const auto& index = actionMap.get<1>();
auto iter = index.lower_bound(ActionKey(key));
if (iter == index.end()) {
return false;
}
// disable and enqueue the action in order to try other alternativeslll
action->setEnabled(false);
pendingActions.emplace_back(action, key.count(), 0);
// check for potential partial match, i.e. longer key sequences
bool flush = true;
bool found = false;
for (auto it = iter; it != index.end(); ++it) {
if (key.matches(it->key.shortcut) == QKeySequence::NoMatch) {
break;
}
if (action == it->action) {
// There maybe more than one action with the exact same shortcut.
// However, we only disable and enqueue the triggered action.
// Because, QAction::isEnabled() does not check if the action is
// active under its current ShortcutContext. We would have to check
// its parent widgets visibility which may or may not be reliable.
// Instead, we rely on QEvent::Shortcut to be sure to enqueue only
// active shortcuts. We'll fake the current key sequence below,
// which will trigger all possible matches one by one.
pendingActions.back().priority = getPriority(it->key.name);
found = true;
}
else if (it->action && it->action->isEnabled()) {
flush = false;
if (found) {
break;
}
}
}
if (flush) {
// We'll flush now because there is no potential match with further
// keystrokes, so no need to wait for timer.
lastFocus = nullptr;
onTimer();
return true;
}
lastFocus = focus;
pendingSequence = key;
// Qt's shortcut state machine favors shortest match (which is ridiculous,
// unless I'm mistaken?). We'll do longest match. We've disabled all
// shortcuts that can match the current key sequence. Now replay the sequence
// and wait for the next keystroke.
for (int i = 0; i < key.count(); ++i) {
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
int k = key[i];
#else
int k = key[i].key();
#endif
Qt::KeyboardModifiers modifiers;
if ((k & Qt::SHIFT) == Qt::SHIFT) {
modifiers |= Qt::ShiftModifier;
}
if ((k & Qt::CTRL) == Qt::CTRL) {
modifiers |= Qt::ControlModifier;
}
if ((k & Qt::ALT) == Qt::ALT) {
modifiers |= Qt::AltModifier;
}
if ((k & Qt::META) == Qt::META) {
modifiers |= Qt::MetaModifier;
}
k &= ~(Qt::SHIFT | Qt::CTRL | Qt::ALT | Qt::META);
QKeyEvent* kev = new QKeyEvent(QEvent::KeyPress, k, modifiers, 0, 0, 0);
QApplication::postEvent(focus, kev);
kev = new QKeyEvent(QEvent::KeyRelease, k, modifiers, 0, 0, 0);
QApplication::postEvent(focus, kev);
}
timer.start(timeout);
return true;
}
bool ShortcutManager::eventFilter(QObject* o, QEvent* ev)
{
switch (ev->type()) {
case QEvent::KeyPress:
lastFocus = nullptr;
break;
case QEvent::ShortcutOverride: {
auto kev = static_cast<QKeyEvent*>(ev);
if (!kev) {
break;
}
// don't process application shortcuts if we are editing a text widget
if (auto* focus = QApplication::focusWidget()) {
auto* maybeProxy = focus->focusProxy();
auto* focusOrProxy = maybeProxy ? maybeProxy : focus;
bool isFocusedWidgetTextInput = focusOrProxy->inherits("QLineEdit")
|| focusOrProxy->inherits("QTextEdit")
|| focusOrProxy->inherits("QPlainTextEdit");
if (isFocusedWidgetTextInput) {
ev->accept();
return true;
}
}
break;
}
case QEvent::Shortcut:
if (timeout > 0) {
auto sev = static_cast<QShortcutEvent*>(ev);
if (checkShortcut(o, sev->key())) {
// shortcut event handled here, so filter out the event
return true;
}
else {
// Not handled. Clear any existing pending actions.
timer.stop();
for (const auto& info : pendingActions) {
if (info.action) {
info.action->setEnabled(true);
}
}
pendingActions.clear();
lastFocus = nullptr;
}
}
break;
case QEvent::ActionChanged:
if (auto action = qobject_cast<QAction*>(o)) {
auto& index = actionMap.get<0>();
auto it = index.find(reinterpret_cast<intptr_t>(action));
if (action->shortcut().isEmpty()) {
if (it != index.end()) {
QKeySequence oldShortcut = it->key.shortcut;
index.erase(it);
actionShortcutChanged(action, oldShortcut);
}
break;
}
QByteArray name;
if (auto fcAction = qobject_cast<Action*>(action->parent())) {
if (fcAction->command() && fcAction->command()->getName()) {
name = fcAction->command()->getName();
}
}
if (name.isEmpty()) {
name = action->objectName().size() ? action->objectName().toUtf8()
: action->text().toUtf8();
if (name.isEmpty()) {
name = "~";
}
else {
name = QByteArray("~ ") + name;
}
}
if (it != index.end()) {
if (it->key.shortcut == action->shortcut() && it->key.name == name) {
break;
}
QKeySequence oldShortcut = it->key.shortcut;
index.replace(it, ActionData {action, name});
actionShortcutChanged(action, oldShortcut);
}
else {
index.insert(ActionData {action, name});
actionShortcutChanged(action, QKeySequence());
}
}
break;
default:
break;
}
return false;
}
std::vector<std::pair<QByteArray, QAction*>> ShortcutManager::getActionsByShortcut(
const QKeySequence& shortcut
)
{
const auto& index = actionMap.get<1>();
std::vector<std::pair<QByteArray, QAction*>> res;
std::multimap<int, const ActionData*, std::greater<>> map;
for (auto it = index.lower_bound(ActionKey(shortcut)); it != index.end(); ++it) {
if (it->key.shortcut != shortcut) {
break;
}
if (it->key.name != "~" && it->action) {
map.emplace(getPriority(it->key.name), &(*it));
}
}
for (const auto& v : map) {
res.emplace_back(v.second->key.name, v.second->action);
}
return res;
}
void ShortcutManager::setPriorities(const std::vector<QByteArray>& actions)
{
if (actions.empty()) {
return;
}
// Keep the same top priority of the given action, and adjust the rest. Can
// go negative if necessary
int current = 0;
for (const auto& name : actions) {
current = std::max(current, getPriority(name));
}
if (current == 0) {
current = (int)actions.size();
}
setPriority(actions.front(), current);
++current;
for (const auto& name : actions) {
int p = getPriority(name);
if (p <= 0 || p >= current) {
if (--current == 0) {
--current;
}
setPriority(name, current);
}
else {
current = p;
}
}
}
int ShortcutManager::getPriority(const char* cmdName)
{
if (!cmdName) {
return 0;
}
auto it = priorities.find(cmdName);
if (it == priorities.end()) {
return 0;
}
return it->second;
}
void ShortcutManager::setPriority(const char* cmdName, int p)
{
if (p == 0) {
hPriorities->RemoveInt(cmdName);
}
else {
hPriorities->SetInt(cmdName, p);
}
}
void ShortcutManager::setTopPriority(const char* cmdName)
{
++topPriority;
hPriorities->SetInt(cmdName, topPriority);
}
void ShortcutManager::onTimer()
{
timer.stop();
QAction* found = nullptr;
int priority = -std::numeric_limits<int>::max();
int seq_length = 0;
for (const auto& info : pendingActions) {
if (info.action) {
info.action->setEnabled(true);
if (info.seq_length > seq_length
|| (info.seq_length == seq_length && info.priority > priority)) {
priority = info.priority;
seq_length = info.seq_length;
found = info.action;
}
}
}
if (found) {
found->activate(QAction::Trigger);
}
pendingActions.clear();
if (lastFocus && lastFocus == QApplication::focusWidget()) {
// We are here because we have withheld some previous triggered action.
// We then disabled the action, and faked the same key strokes in order
// to wait for more potential match of longer key sequence. We use
// a timer to end the wait and trigger the pending action.
//
// However, Qt's internal shorcutmap state machine is still armed with
// our fake key strokes. So we try to fake some more obscure symbol key
// stroke below, hoping to reset Qt's state machine.
const auto& index = actionMap.get<1>();
static const std::string symbols = "~!@#$%^&*()_+";
QString shortcut = pendingSequence.toString() + QStringLiteral(", Ctrl+");
for (int s : symbols) {
QKeySequence k(shortcut + QLatin1Char(s));
auto it = index.lower_bound(ActionKey(k));
if (it->key.shortcut != k) {
QKeyEvent* kev = new QKeyEvent(QEvent::KeyPress, s, Qt::ControlModifier, 0, 0, 0);
QApplication::postEvent(lastFocus, kev);
kev = new QKeyEvent(QEvent::KeyRelease, s, Qt::ControlModifier, 0, 0, 0);
QApplication::postEvent(lastFocus, kev);
break;
}
}
}
}
#include "moc_ShortcutManager.cpp"