Single-key menu shortcuts with Qt 5 on OS/X

Several of my Qt-based applications, including Sonic Visualiser and Tony, have some menu actions attached to single-key shortcuts without a modifier key. Examples include the Space bar to start and stop playback, or the “f” key (without Ctrl, Alt or any other modifier) for zoom-to-fit.

While testing the update from Qt 4 to Qt 5.1 we found that some of these shortcuts were no longer working on the Mac, though they still worked on other platforms. Hoping this would be fixed in a Qt update, I decided to stick with Qt 4 for the official Mac builds of Sonic Visualiser for the time being. As of Qt 5.3.0, though, the problem still wasn’t fixed and I decided I couldn’t avoid it any longer.

After some digging (documented in this issue tracker) I think I understand the cause and have a workaround, although I don’t know how to fix it properly in Qt. Here’s the summary as I understand it:

  • Qt on OS/X does not (in general) handle menu shortcuts itself. It creates a native Cocoa menu and lets Cocoa’s Key Equivalents mechanism handle them.
  • Key Equivalents apparently do not work reliably for shortcuts without modifiers.
  • Before Qt 5.1, any menu shortcuts that Cocoa did not handle would drop through to Qt’s cross-platform layer and be handled as window-level shortcuts bound to a QAction instead. So the single-key shortcuts continued to work even though Cocoa didn’t handle them itself.
  • This broke in Qt 5.1 because of this commit which was applied to fix this crashing bug. The problem was that some menus that should have been inactive (because a modal dialog was overriding them) could still be activated erroneously from the keyboard through this fallback shortcut mechanism.
  • There is an open Qt bug about the single-key shortcut problem and it contains a partial workaround.

So there’s a workaround in that last bug tracker, which binds each of the menu actions to a separate, global, application-level shortcut instead:

foreach (QAction *a, menu->actions()) {
    QObject::connect(new QShortcut(a->shortcut(), a->parentWidget()),
                     SIGNAL(activated()), a, SLOT(trigger()));
}

This works as far as it goes, but it doesn’t check the action’s enabled status (and nor does the action’s trigger slot) so it’s possible to use this to invoke an action that is supposed to be disabled.

I ended up using a more complicated workaround, which you can find here. The code is basically as follows:

void MainWindow::finaliseMenu(QMenu *menu)
{
    QSignalMapper *mapper = new QSignalMapper(this);

    connect(mapper, SIGNAL(mapped(QObject *)),
            this, SLOT(menuActionMapperInvoked(QObject *)));

    foreach (QAction *a, menu->actions()) {
        QKeySequence sc = a->shortcut();
        if (sc.count() == 1 && !(sc[0] & Qt::KeyboardModifierMask)) {
            QShortcut *newSc = new QShortcut(sc, a->parentWidget());
            QObject::connect(newSc, SIGNAL(activated()), mapper, SLOT(map()));
            mapper->setMapping(newSc, a);
            a->setShortcut(QKeySequence());
        }
    }
}

void MainWindow::menuActionMapperInvoked(QObject *o)
{
    QAction *a = qobject_cast<QAction *>(o);
    if (a && a->isEnabled()) {
        a->trigger();
    }
}

I then call finaliseMenu on each of the menus returned by findChildren<QMenu *> from the main window’s menuBar() object.

There may be a simpler way: let me know if you can see one.