I've observed this rather annoying behavior several times (at least on Windows 10), and I just can't figure out a workaround.
I have come up with a very simple test case:
def pba():
mainWindow.pushButtonA.setEnabled(False)
mainWindow.pushButtonB.setEnabled(True)
def pbb():
mainWindow.pushButtonB.setEnabled(False)
mainWindow.pushButtonA.setEnabled(True)
app = QtWidgets.QApplication(sys.argv)
loader = QUiLoader()
mainWindow = loader.load("Derp.ui", None)
mainWindow.pushButtonA.clicked.connect(pba)
mainWindow.pushButtonB.clicked.connect(pbb)
mainWindow.show()
app.exec()
It can be readily seen that there is a brief delay before the pushbutton's clicked signal's connected function can disable the clicked button. Also, there is no such delay if the connected function is configured to disable the other button.
I understand that the function connected to the clicked signal is blocking the GUI loop, but in this case the connected functions are extremely brief and short in duration. (Anyway, starting a new thread to execute setEnabled(False)
changes nothing.)
If my eyes are not misleading me, it seems that the disabled change is placed in a queue, and before it can be visually displayed the pushbutton first has to visually change states in response to its clicked signal. But I'm not super clear on exactly what is happening behind the scenes when I click a QPushButton.
So, how exactly can I get around this behavior? I simply want the button to be disabled immediately upon being clicked.
What you are describing is exactly the expected behavior, and is not caused by the blocking of the GUI: on the contrary, it's exactly done to prevent what would happen in case the GUI becomes blocked when the signal is emitted.
A clicked
signal is emitted when the mouse button is released (specifically, when released inside the button area), meaning that the button has become unpressed, which automatically triggers an immediate repainting that will reflect that state: show the button as unpressed.
That is important in the logic of events, and that's exactly to prevent the blocking of the GUI: the repainting must happen before emitting the signal, so that the button is properly displaying the current state; by default, signals are directly connected when transmitter and receiver belong to the same thread, meaning that the control is not returned until the connected function is completed: so, with this approach, the button will properly show its new (unpressed) state, even if the signal is connected to something that would potentially block the event loop.
The sequence is:
isDown()
would return False
;repaint()
instead of update()
(which would schedule a repaint by queuing a Paint
event), in order to properly display the new state;paintEvent()
is called, which will init a QStyleOptionButton using various information about the button (including its isDown()
state) and use the current style to paint the button with that;released
signal;clicked
signal;Note that, while your solution may be fine for your case, it's not completely valid or acceptable, at least for the following reasons:
mousePressEvent()
and you call the base implementation after that, the event will become not accepted, meaning that, potentially, any parent above it would receive the event and eventually handle it;Also note that disabling a button automatically sets it as unpressed (automatically emitting the released
signal).
A proper and more comprehensive implementation would look like the following:
class QDisablingPushButton(QPushButton):
def mouseReleaseEvent(self, event):
if (
event.button() == Qt.LeftButton
and self.rect().contains(event.pos())
):
self.setEnabled(False) # release is implicit
self.repaint()
self.clicked.emit()
else:
super().mouseReleaseEvent(event)
def keyReleaseEvent(self, event):
if (
event.key() in (Qt.Key_Select, Qt.Key_Space)
and not event.isAutoRepeat()
and self.isDown()
):
self.setEnabled(False)
self.repaint()
self.clicked.emit()
else:
super().keyReleaseEvent(event)
Finally, there's a catch.
Emitting the clicked
signal on your own prevents any QButtonGroup to properly emit the related idClicked
and buttonClicked
signals.
If you need to use button groups, you should consider a slightly different implementation:
class Button(QPushButton):
def disableAndClick(self):
self.setEnabled(False) # release signal is implicit
self.repaint()
self.clicked.emit()
group = self.group()
if group:
try: # for Qt < 5.15
group.idClicked.emit(group.id(self))
except AttributeError:
group.buttonClicked[int].emit(group.id(self))
# to connect to this signal, you must use:
# group.buttonClicked[int].connect(function)
group.buttonClicked.emit(self)
def mouseReleaseEvent(self, event):
if (
event.button() == Qt.LeftButton
and self.rect().contains(event.pos())
):
self.disableAndClick()
else:
super().mouseReleaseEvent(event)
def keyReleaseEvent(self, event):
if (
event.key() in (Qt.Key_Select, Qt.Key_Space)
and not event.isAutoRepeat()
and self.isDown()
):
self.disableAndClick()
else:
super().keyReleaseEvent(event)