Wiki

Dynamic Keyboard Shortcuts

by David Boddie
Many modern applications allow their user interfaces to be customized by letting the user assign new keyboard shortcuts, move menu items around, and fine-tune the appearance of toolbars. Using Qt's object model and action system together, we can provide some of these customization features in a simple action editor that can be integrated into existing Qt 3 applications.

[Download Source Code]

Qt's action system is based around the QAction class, which holds information about all the different ways the user can execute a particular command in an application. For example, a Print menu item can often be activated via a keyboard shortcut (Ctrl+P), a toolbar icon, and the menu item itself.

In Qt 3, each of these types of input action can be created separately but, by using the QAction class to collect them together, we can save a lot of time and effort. In a QAction-enabled application, we can easily ensure that the user interface is always self-consistent.

Qt's introspection features, available in QObject and its subclasses, let us search for instances of QAction in a running application. This means that we can access information about menu items, toolbar icons, and shortcuts at any time, even if the QAction objects themselves are not immediately accessible.

In this article, we create a dialog that lets the user customize the keyboard shortcuts used in an application, and we extend one of the standard Qt examples to take advantage of this feature. Custom actions are loaded and saved using the QSettings class.

Ready for Action

The main feature we want to introduce is the Edit Actions dialog. We have already decided to let users edit the keyboard shortcut associated with each action, but we also need to decide which other pieces of information will also be useful to show.

Each QAction provides a description, an icon, menu text, a shortcut, tool tip and "What's This?" information. To edit shortcuts, we just need the description and the shortcut, provided by the text() and accel() functions, and we will arrange this information in a table.

The user will be able to edit each of the shortcuts in the second column before either accepting the changes with the OK button or rejecting them with the Cancel button. Since we will allow any text to be entered in each field, we also need a way to check that it can be used to specify a valid shortcut.

The class definition of the ActionsDialog looks like this:

class ActionsDialog : public QDialog
{
    Q_OBJECT
 
public:
    ActionsDialog(QObjectList *actions,
                  QWidget *parent = 0);
protected slots:
    void accept();
 
private slots:
    void recordAction(int row, int column);
    void validateAction(int row, int column);
 
private:
    QString oldAccelText;
    QTable *actionsTable;
    QValueList<QAction*> actionsList;
};

The recordAction() and validateAction() functions are custom slots in the dialog that are used when shortcut text is being edited by the user. The accept() function is used if the dialog is accepted, and updates all the known actions with new shortcuts.

The constructor performs the task of setting up the user interface for the dialog. To minimize the impact on the application, the class is constructed with a QObjectList that actually contains a list of QActions, and we use a QTable to display information about each of them.

ActionsDialog::ActionsDialog(QObjectList *actions,
                             QWidget *parent)
    : QDialog(parent)
{
    actionsTable = new QTable(actions->count(), 2, this);
    actionsTable->horizontalHeader()->setLabel(0,
        tr("Description"));
    actionsTable->horizontalHeader()->setLabel(1,
        tr("Shortcut"));
    actionsTable->verticalHeader()->hide();
    actionsTable->setLeftMargin(0);
    actionsTable->setColumnReadOnly(0, true);

The table is customized to only allow one column to be edited, and we remove the unnecessary vertical header along the left hand side.

We rely on the application to pass valid QActions to the constructor, but we carefully iterate over the list to prevent any accidents, setting the text for the cells in the table.

Each action that we show is also added to a list that lets us look up the action corresponding to a given row in the table. We will use this later when modifying the actions.

  QAction *action =
            static_cast<QAction *>(actions->first());
    int row = 0;
 
    while (action) {
        actionsTable->setText(row, 0, action->text());
        actionsTable->setText(row, 1,
                              QString(action->accel()));
        actionsList.append(action);
        action = static_cast<QAction *>(actions->next());
        ++row;
    }

The dialog needs OK and Cancel buttons. We construct these and connect them to the standard accept() and reject() slots:

    QPushButton *okButton = new QPushButton(tr("&OK"), this);
    QPushButton *cancelButton = new QPushButton(tr("&Cancel"), this);
    connect(okButton, SIGNAL(clicked()),
            this, SLOT(accept()));
    connect(cancelButton, SIGNAL(clicked()),
            this, SLOT(reject()));

Two signals from the table are also connected to slots in the dialog; these handle the editing process:

    connect(actionsTable, SIGNAL(currentChanged(int, int)),
            this, SLOT(recordAction(int, int)));
    connect(actionsTable, SIGNAL(valueChanged(int, int)),
            this, SLOT(validateAction(int, int)));
    ...
    setCaption(tr("Edit Actions"));
}

After all the widgets are constructed and set up, they are arranged using layout classes in the usual way.

The Editing Process

When the user starts to edit a cell item, actionsTable emits the currentChanged() signal, and the dialog's recordAction() slot is called with the row and column of the cell:

void ActionsDialog::recordAction(int row, int col)
{
    oldAccelText = actionsTable->item(row, col)->text();
}

Before the user gets a chance to modify the contents, we record the cell's current text. Later, if the replacement text is not suitable, we can reset it to this value.

When the user has finished editing the cell item, actionsTable emits the valueChanged() signal, and the dialog's validateAction() slot is called with the cell's row and column, giving us the chance to ensure that the text in the cell is suitable for use as a shortcut:

void ActionsDialog::validateAction(int row, int column)
{
    QTableItem *item = actionsTable->item(row, column);
    QString accelText = QString(QKeySequence(
                                item->text()));
 
    if (accelText.isEmpty() && !item->text().isEmpty()) {
        item->setText(oldAccelText);
    } else {
        item->setText(accelText);
    }
}

We use a QKeySequence to check the new text on our behalf. If the new text couldn't be used by the QKeySequence, the string we obtain in accelText will be empty, so we must write the old text in oldAccelText back to the cell.

Of course, the user may have intentionally left the field empty, so we only use the old text if the cell's text was not empty. If the new text can be used, we write it to the cell.

If the user clicks the OK button, it emits the clicked() signal and the dialog's accept() slot is called:

void ActionsDialog::accept()
{
    for (int row = 0; row < actionsList.size(); ++row) {
        QAction *action = actionsList[row];
        action->setAccel(QKeySequence(
                         actionsTable->text(row, 1)));
    }
 
    QDialog::accept();
}

Since we have already validated all the new shortcut text in the table, we simply retrieve an action for each row in the table and set its new shortcut text. Finally, we call QDialog's accept() function to close the dialog properly.

If the user closes the dialog with the Cancel button, we do not need to perform any actions; the changes to the shortcuts will be lost.

Taking Action

To enable support for the action editor in our application (the Qt 3 action example), we need to provide ways to open the dialog from within the user interface, load shortcut settings, and save them as required.

We add two private slots to the ApplicationWindow class to handle editing and saving, and a private function to load shortcuts when the application starts:

private slots:
    ...
    void editActions();
    void saveActions();
 
private:
    void loadActions();
    ...

The user interface is extended in the ApplicationWindow constructor with a new Settings menu for the main window's menu bar. We insert two new actions into it to allow shortcuts to be edited and saved:

ApplicationWindow::ApplicationWindow()
    : QMainWindow(0, "example application main window",
                  WDestructiveClose)
{
    ...
    QPopupMenu *settingsMenu = new QPopupMenu(this);
    menuBar()->insertItem(tr("&Settings"), settingsMenu);
 
    QAction *editActionsAction = new QAction(this);
    editActionsAction->setMenuText(tr(
                              "&Edit Actions..."));
    editActionsAction->setText(tr("Edit Actions"));
    connect(editActionsAction, SIGNAL(activated()),
            this, SLOT(editActions()));
    editActionsAction->addTo(settingsMenu);
 
    QAction *saveActionsAction = new QAction(this);
    saveActionsAction->setMenuText(tr("&Save Actions"));
    saveActionsAction->setText(tr("Save Actions"));
    connect(saveActionsAction, SIGNAL(activated()),
            this, SLOT(saveActions()));
    saveActionsAction->addTo(settingsMenu);
    ...

We also include the following code at the end of the constructor after all the actions have been set up. This lets us override the default shortcuts that are hard-coded into the application:

  ...
    loadActions();
    ...
}

The loadActions() function modifies the shortcut of each QAction whose name matches an entry in the settings:

void ApplicationWindow::loadActions()
{
    QSettings settings;
    settings.setPath("trolltech.com", "Action");
    settings.beginGroup("/Action");
 
    QObjectList *actions = queryList("QAction");
    QAction *action =
            static_cast<QAction *>(actions->first());
 
    while (action) {
        QString accelText = settings.readEntry(
                                        action->text());
        if (!accelText.isEmpty())
            action->setAccel(QKeySequence(accelText));
        action = static_cast<QAction *>(actions->next());
    }
}

This function relies on the default values from QSettings::readEntry() to ensure that each action is only changed if there is a suitable entry with a valid shortcut available.

The private editActions() slot performs the task of opening the dialog with a list of all the main window's actions:

void ApplicationWindow::editActions()
{
    ActionsDialog actionsDialog(queryList("QAction"),
                                this);
    actionsDialog.exec();
}

The QObjectList returned by queryList() contains all the QActions in the main window, including the new ones we added to the menu bar.

The saveActions() slot is called whenever the Save Actions menu item is activated:

void ApplicationWindow::saveActions()
{
    QSettings settings;
    settings.setPath("trolltech.com", "Action");
    settings.beginGroup("/Action");
 
    QObjectList *actions = queryList("QAction");
    QAction *action =
            static_cast<QAction *>(actions->first());
 
    while (action) {
        QString accelText = QString(action->accel());
        settings.writeEntry(action->text(), accelText);
        action = static_cast<QAction *>(actions->next());
    }
}

Note that we save information about all shortcuts in the application, even if they are undefined. This allows the user to disable custom shortcuts.

With these modifications in place, we can rebuild and run the action example to see the result. All the shortcuts used in the main window's menus can now be changed to match our personal favorites.

Possible Improvements

The example we have given can be extended in a number of ways to make it more useful in real-world applications:

  • The dialog only displays the actions for the main window, but we could include other actions in the application. Perhaps we could even look for groups of actions.
  • Shortcuts could be set with an editor that records keystrokes made by the user.
  • When a shortcut is cleared in the dialog, the default shortcut for that action becomes available again the next time the application is run. You can use a single space character for that shortcut to work around this, but there are better solutions.


Copyright © 2005 Trolltech Trademarks