Search code examples
pyqt5qpushbuttonqdialog

PyQt5: Why does QPushButton.setDefault() ignore spacebar but work for enter/return?


I have a modal with two buttons, one Accept and one Cancel. I set the cancel button to be the default with .setDefault() and .setAutoDefault()

Pressing return activates the cancel-button, but when I press spacebar the accept-button is activated.

Why is the application/accept-button ignoring the defaultness-configuration and activates on spacebar presses rather than the cancel button? It seems like the accept-button has focus or something despite there being a different default. Why would the default not have focus?

If I call cancel_button.setFocus() just before showing the modal (but not earlier than that), even the spacebar will activate the Cancel-button instead of the Acccept-button, so that solves the underlying problem.

The question is why spacebar and enter do not both activate the default button.

Minimal example:

The modal shows up when the program is run, as well as when the user presses X. Press ctrl+Q to close the application.

import sys
from PyQt5.QtCore import QSize, Qt
from PyQt5.QtGui import QKeySequence
from PyQt5.QtWidgets import QApplication, QMainWindow, QGroupBox, QHBoxLayout, QVBoxLayout, \
                            QWidget, QShortcut, QDialog, QPushButton

class Modal(QDialog):
    def __init__(self, parent):
        super().__init__(parent)
        self.resize(QSize(600, 300))
        self.setParent(parent)
        self.setWindowModality(True)

        layout = QVBoxLayout()
        self.setLayout(layout)

        buttons = self.create_buttons()
        layout.addWidget(buttons)
        # This sets focus (when pressing spacebar), and makes the modal work as expected.
        # The question is why is this needed to make spacebar default to activating Cancel?
        # Why is spacebar activating Accept by default without this line?:
        #self.cancel_button.setFocus()

    def create_buttons(self):
        button_groupbox = QGroupBox()
        button_box_layout = QHBoxLayout()
        button_groupbox.setLayout(button_box_layout)

        # Despite setting the defaultness, pressing spacebar still activates the accept-button.
        # Pressing return activates the cancel-button, however, and is expected behaviour.
        # Why is the Accept-button being activated when space is pressed?
        accept_button = QPushButton("Accept")
        accept_button.clicked.connect(self.accept)
        accept_button.setDefault(False)
        accept_button.setAutoDefault(False)
        self.accept_button = accept_button

        cancel_button = QPushButton("Cancel")
        cancel_button.clicked.connect(self.reject)
        cancel_button.setDefault(True)
        cancel_button.setAutoDefault(True)
        self.cancel_button = cancel_button

        # This does not set focus (when pressing spacebar), maybe because it has not been added yet?
        #cancel_button.setFocus()

        button_box_layout.addWidget(accept_button)
        button_box_layout.addWidget(cancel_button)
        return button_groupbox

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        shortcut = QShortcut(QKeySequence("Ctrl+Q"), self)
        shortcut.activated.connect(app.quit)

        shortcut = QShortcut(QKeySequence("X"), self)
        shortcut.activated.connect(self.run_modal)

        self.resize(QSize(800, 600))
        self.show()

    def showEvent(self, event):
        self.run_modal()

    def run_modal(self):
        self.modal = Modal(self)
        self.modal.finished.connect(self.modal_finished)
        self.modal.show()

    def modal_finished(self, result):
        if result == 0:
            print("CANCEL")
        elif result == 1:
            print("ACCEPT")
        else:
            raise Exception("BAD RESULT")

if __name__ == '__main__':
    app = QApplication(sys.argv)
    mainwindow = MainWindow()
    sys.exit(app.exec_())


Solution

  • By default, widgets receive focus based on the order in which they are added to a parent. When the top level window is shown, the first widget that accepts focus, following the order above, will receive input focus, meaning that any keyboard event will be sent to that widget first.

    Note that when widgets are added to a layout, but were not created with the parent used for that layout, then the order follows that of the layout insertion.

    The default property of QPushButtons, instead will "press" the button whenever the top level widget receives the Return or Enter keys are pressed, no matter of the current focused widget, and as long as the focused widget does not handle those keys.

    In your case, the currently focused widget is the "Accept" button (since it's the first that has been added to the window), which results in the counter-intuitive behavior you're seeing.

    If you want the cancel button to react to both Return/Enter keys (no matter what is the focused widget) and the space bar upon showing, then you have to explicitly call setFocus(). But there's a catch: since setFocus() sets the focus on a widget in the active window, it can only work as long as that widget already belongs to that window.

    In your case, the cancel_button.setFocus() call done within create_buttons won't work because, at that point, the button doesn't belong to the top level window yet.
    It does work when you do that after layout.addWidget(buttons), because then the button is part of the window.

    So, considering the above:

    • if you want to set the focus on a widget, that widget must already belong to the top level widget before calling setFocus();
    • the default button will always be triggered upon Return/Enter keypress even if another button has focus;

    With your current code, you either do what you already found out (using setFocus() on the instance attribute after adding the widget), or use a basic QTimer in the create_buttons function:

    QTimer.singleShot(0, cancel_button.setFocus)
    

    Note that:

    • while creating separate functions can help you to better organize your code, having a separate function that is just called once is often unnecessary (other than misleading and forcing the creation of instance attributes where they're not actually necessary); just separate code blocks with empty lines, unless those functions can be overridden by further subclasses;
    • setting a "Cancel" button that can be activated by Return/Enter is not a very good idea, as those keys are generally used for "Accept/Apply/Commit/Write/etc." purposes;
    • if you want to show a dialog as soon as its parent is shown, you shall only use a QTimer: QTimer.singleShot(0, self.run_modal); the paint event is certainly not a viable option (paint events occur very, very often, and in some systems even when the widget loses focus, which can cause recursion), nor is the showEvent() since that could happen when switching virtual desktops or unminimizing the window;