3
votes

I want to modify a QAction if the user holds down a specific key. You can see similar behavior on the Mac, for example, if you pull down the Apple menu and press/release the option key - "About This Mac" changes to "System Information...", among other things. This is the behavior I want to emulate in my application.

To that end, I tried overriding the keyPressEvent on both the QMenuBar and the QMenu containing the QAction. However, my debugging indicated that neither of these keyPressEvent functions were called when pressing any key with the relevant QMenu displayed.

I'm sort of wondering if this is perhaps due to the event being handled at a lower level in order to provide "type ahead" type functionality in the menu? I did notice that different menu entries are selected when typing various keys, which I believe to be normal behavior.

How can I respond to a keyPress type event in a QMenuBar or QMenu?

EDIT: this is what I tried:

class MainMenu: public QMenuBar
{
    Q_OBJECT
protected:
    void keyPressEvent(QKeyEvent *event) override
    {qDebug("Got Key Press Event in Menu Bar: %i",event->key());}
}

class FileMenu: public QMenu
{
    Q_OBJECT
protected:
    void keyPressEvent(QKeyEvent *event) override
    {qDebug("Got Key Press Event in Menu: %i",event->key());}
}

I then instantiated the menu bar as normal, populated a FileMenu object with a number of QActions, and added it to the menu bar. This all worked, but the qDebug lines are never printed, and manually debugging the app shows the event is never called.

EDIT 2: For more information, I am using Qt 5.9.6, on MacOS X 10.14.0.

EDIT 3: As a further test, I tried installing an eventFilter, using the code from http://doc.qt.io/qt-5/qobject.html#installEventFilter to install the KeyPressEater object as an event filter on two objects: one, my menu bar object, and two the QApplication object, as per the documentation at http://doc.qt.io/qt-5/eventsandfilters.html#event-filters:

It is also possible to filter all events for the entire application, by installing an event filter on the QApplication or QCoreApplication object.

What I saw with that approach is that the KeyPressEater eventFilter DID get called for keyPresses - but ONLY if the menu was not activated. As soon as I activate a menu (any menu, including the apple menu), the eventFilter function stops getting called.

The impression I'm getting here is that the system takes over the menu handling, thereby preventing any events from getting through to the application. Of course, that's just based on observed behavior.

EDIT 4: Some more digging turned up this: (doc.qt.io/qt-5/osx-issues.html#limitations):

"QMenu objects used in the native menu bar are not able to handle Qt events via the normal event handlers. Install a delegate on the menu itself to be notified of these changes

So I guess the "answer" is that I need to "install a delegate on the menu itself" that "notifies me of these changes". Can someone help with that?

1

1 Answers

2
votes

I made an MCVE for OP's issue.

I believe I cannot reproduce the issue of OP. May be, OP had other expectations than me. Of course, the menu bar will not receive key events as long as it's not activated. (I ignore the subject of short-cuts for now.)

In my tests, I always first clicked into menu bar to activate menu and then received key events. I tested this with menu bar as well as with sub-menu.

I observed that as soon as a sub-menu is opened, even both (menu bar and menu) received the (same) key event. (This seems reasonable considering that and effect on menu bar but and on active menu instead.)

This is my sample code testQMenuKeyEvent.cc:

#include <QtWidgets>

class FileMenu: public QMenu {
  private:
    QAction qCmdNew, qCmdOpen, qCmdQuit;
    bool alt;

  public:
    FileMenu(): QMenu(),
      qCmdNew(QString::fromUtf8("New")),
      qCmdOpen(QString::fromUtf8("Open")),
      qCmdQuit(QString::fromUtf8("Quit")),
      alt(false)
    {
      addAction(&qCmdNew);
      addAction(&qCmdOpen);
      addAction(&qCmdQuit);
      // install signal handlers
      connect(&qCmdNew, &QAction::triggered,
        [&]() {
          qDebug() << (alt ? "Reset" : "New") << "triggered";
        });
      connect(&qCmdOpen, &QAction::triggered,
        [&]() {
          qDebug() << (alt ? "Save" : "Open") << "triggered";
        });

    }
  protected:
    virtual void showEvent(QShowEvent *pQEvent) override
    {
      qDebug() << "FileMenu::showEvent";
      update();
      QMenu::showEvent(pQEvent);
    }

    virtual void keyPressEvent(QKeyEvent *pQEvent) override
    {
      qDebug() << "FileMenu::keyPressEvent";
      update(pQEvent->modifiers());
      QMenu::keyPressEvent(pQEvent);
    }
    virtual void keyReleaseEvent(QKeyEvent *pQEvent) override
    {
      qDebug() << "FileMenu::keyReleaseEvent";
      update(pQEvent->modifiers());
      QMenu::keyReleaseEvent(pQEvent);
    }

  private:
    void update()
    {
      update(
        (QApplication::keyboardModifiers()
          & Qt::ControlModifier)
        != 0);
    }
    void update(bool alt)
    {
      qDebug() << "alt:" << alt;
      if (!alt != !this->alt) {
        qCmdNew.setText(QString::fromUtf8(alt ? "Reset" : "New"));
        qCmdOpen.setText(QString::fromUtf8(alt ? "Save" : "Open"));
      }
      this->alt = alt;
    }
};

int main(int argc, char **argv)
{
  qDebug() << "Qt Version:" << QT_VERSION_STR;
  QApplication app(argc, argv);
  QMainWindow qWin;
  QMenuBar qMenuMain;
  QAction qCmdFile(QString::fromUtf8("File"));
  FileMenu qMenuFile;
  qCmdFile.setMenu(&qMenuFile);
  qMenuMain.addAction(&qCmdFile);
  QAction qCmdEdit(QString::fromUtf8("Edit"));
  qMenuMain.addAction(&qCmdEdit);
  QAction qCmdHelp(QString::fromUtf8("Help"));
  qMenuMain.addAction(&qCmdHelp);
  qWin.setMenuBar(&qMenuMain);
  qWin.show();
  return app.exec();
}

The Qt project testQMenuKeyEvent.pro I used to build:

SOURCES = testQMenuKeyEvent.cc

QT = widgets

Compiled and tested in cygwin64 on Windows 10:

$ qmake-qt5 testQMenuKeyEvent.pro

$ make && ./testQMenuKeyEvent
Qt Version: 5.9.4
FileMenu::showEvent
alt: false
New triggered
FileMenu::showEvent
alt: false
FileMenu::keyPressEvent
alt: true
Reset triggered

Snapshot of testQMenuKeyEvent in cygwin/X11 Snapshot of testQMenuKeyEvent in cygwin/X11 (Ctrl key pressed)

Afterwards, I built again in VS2013 with Qt bound to win32 API:

Snapshot of testQMenuKeyEvent in Windows 10 Snapshot of testQMenuKeyEvent in Windows 10 (Ctrl key pressed)

Despite of the slightly different look, it behaved identical.

Notes:

  1. When I tested the code initially, I noticed that key navigation was broken. Hence, I find it worth to be mentioned that override-ing should call the overridden methods of base class to ensure the original behavior as well.

  2. The Ctrl key which I used for switching the menu items may be pressed before the menu is activated. To consider this, I overloaded showEvent() as well.

  3. For triggered action, the Ctrl is checked again to the latest possible moment. This is done using lambdas as signal handlers for the QActions. Moving it to the handler function itself would ensure that this becomes effective for other occurrences of theses actions as well. (I mean, these actions could be "re-used" in a toolbar.)

  4. When QApplication::keyboardModifiers() is called inside keyPressEvent() or keyReleaseEvent() it returned wrong values but using the QKeyEvent::modifiers() instead worked fine. This let me think, that update of global states is done after processing these events.

  5. It becomes a bit more complicated if the shown behavior shall be achieved for the menu bar itself. In this case, keyPressEvent() doesn't help much. (It's not called as long as menu bar is not active (focused)). In this case, an event filter can be used to catch any key press and update the menu bar actions in case.


OP mentioned that the above solution didn't work on his MacBook.

I looked into Qt. doc. of QMenu. All I found was:

QMenu on macOS with Qt Build Against Cocoa

QMenu can be inserted only once in a menu/menubar. Subsequent insertions will have no effect or will result in a disabled menu item.

This doesn't seem to be related directly. Though, it gave me the feeling that things might be a bit different on Mac...

So, I followed the idea with the event filter and changed the sample code respectively:

#include <functional>
#include <vector>

#include <QtWidgets>

class CtrlNotifier: public QObject {
  private:
    bool ctrl;
  public:
    // to be notified
    std::vector<std::function<void(bool)> > sigNotify;
  public:
    CtrlNotifier():
      ctrl(
        (QApplication::keyboardModifiers() & Qt::ControlModifier)
        != 0)
    { }
    bool isCtrl() const { return ctrl; }
  protected:
    virtual bool eventFilter(QObject *pQObj, QEvent *pQEvent) override
    {
      if (pQEvent->type() == QEvent::KeyPress
        || pQEvent->type() == QEvent::KeyRelease) {
        const bool ctrl
          = (dynamic_cast<QKeyEvent*>(pQEvent)->modifiers()
            & Qt::ControlModifier)
          != 0;
        if (!this->ctrl != !ctrl) {
          qDebug() << "CtrlNotifier::eventFilter: Ctrl:" << ctrl;
          for (std::function<void(bool)> &func : sigNotify) {
            if (func) func(ctrl);
          }
          this->ctrl = ctrl;
        }
      }
      // standard event processing
      return QObject::eventFilter(pQObj, pQEvent);
    }
};

int main(int argc, char **argv)
{
  qDebug() << "Qt Version:" << QT_VERSION_STR;
  QApplication app(argc, argv);
  QMainWindow qWin;
  QMenuBar qMenuMain;
  QAction qCmdFile(QString::fromUtf8("File"));
  QMenu qMenuFile;
  QAction qCmdNew(QString::fromUtf8("New"));
  qMenuFile.addAction(&qCmdNew);
  QAction qCmdOpen(QString::fromUtf8("Open"));
  qMenuFile.addAction(&qCmdOpen);
  QAction qCmdQuit(QString::fromUtf8("Quit"));
  qMenuFile.addAction(&qCmdQuit);
  qCmdFile.setMenu(&qMenuFile);
  qMenuMain.addAction(&qCmdFile);
  QAction qCmdEdit(QString::fromUtf8("Edit"));
  qMenuMain.addAction(&qCmdEdit);
  QAction qCmdHelp(QString::fromUtf8("Help"));
  qMenuMain.addAction(&qCmdHelp);
  qWin.setMenuBar(&qMenuMain);
  qWin.show();
  // install event filter
  CtrlNotifier ctrlNotifier;
  app.installEventFilter(&ctrlNotifier);
  // install signal handlers
  ctrlNotifier.sigNotify.push_back(
    [&](bool ctrl) {
      qCmdNew.setText(QString::fromUtf8(ctrl ? "Reset" : "New"));
      qCmdOpen.setText(QString::fromUtf8(ctrl ? "Save" : "Open"));
    });
  // install signal handlers
  QObject::connect(&qCmdNew, &QAction::triggered,
    [&]() {
    qDebug() << (ctrlNotifier.isCtrl() ? "Reset" : "New") << "triggered";
  });
  QObject::connect(&qCmdOpen, &QAction::triggered,
    [&]() {
    qDebug() << (ctrlNotifier.isCtrl() ? "Save" : "Open") << "triggered";
  });
  // runtime-loop
  return app.exec();
}

I tested again on Windows 10 (Qt bound to win32) and cygwin (Qt bound to X11). It worked in both cases.

Note:

  1. When QApplication::keyboardModifiers() is called inside CtrlNotifier::eventFilter() it returned wrong values again but using the QKeyEvent::modifiers() instead worked fine.

  2. I tried accidentally to filter events of main window first. This worked as long as I activated the menu. For my luck, I realized the note in the doc. (last paragraph in Event Filters chapter):

    It is also possible to filter all events for the entire application, by installing an event filter on the QApplication or QCoreApplication object. Such global event filters are called before the object-specific filters. This is very powerful, but it also slows down event delivery of every single event in the entire application; the other techniques discussed should generally be used instead.

    Thus, installing it on the app instead brought the intended behavior.

  3. I apologize for the non-Qt-ish signal in CtrlNotifier. I'm using distinct build scripts for VS2013 (CMake) and cygwin (qmake-qt5). This makes correct MOC handling a bit trickier as usual. Hence, I try to prevent its necessity when possible. (I don't say it's impossible to integrate MOC for both cases. – I once managed it successfully.)