1

It seems by default the Tab method for changing focus through widgets only stops once in a QButtonGroup and one is expected to move inside the group with the arrow keys (which only works with radio buttons, anyway). I want, however, to create a button group with QCheckBox inside, with the "exclusive" behaviour but allowing to uncheck all boxes, and where I can use the Tab key to move normally, as if they were not in a group.

The "clearable" part I could get it done with a subclass, but for the tabbing, it seems as soon as a button in the group gets the focus, it changes the focusPolicy for all other buttons such that they don't accept Tab (from 11 to 10), while the button that got the focus changes to 11. How can I disable/override this? Who is messing with the focus policies? I've tried defining a focusInEvent method for a QCheckBox, and I see that it changes "this button's" focusPolicy, but how can I know, from "this button" what the "other buttons" are (given that the final application may have many button groups)? Ideally, I would do something to the QButtonGroup subclass, but I don't know if it has any method that can respond to a focus change in its buttons.

Here is a small example. The "Tab order" button prints the current tab order and focus policies:

#!/usr/bin/env python3

import sys
from PyQt5 import QtCore, QtWidgets
from PyQt5.QtWidgets import *
from PyQt5.QtCore import QSize    


class Custom(QWidget):
    def __init__(self, text1, text2):
        QWidget.__init__(self)
        self.box = QCheckBox(text1)
        self.button = QPushButton(text2)
        layout = QHBoxLayout()
        layout.addWidget(self.box)
        layout.addWidget(self.button)
        self.setLayout(layout)
        self._text = f'{text1} {text2}'
        self.setFocusPolicy(QtCore.Qt.ClickFocus)

    def text(self):
        return self._text


class ClearableButtonGroup(QButtonGroup):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.button = None

    def addButton(self, button):
        try:
            super().addButton(button)
            button.pressed.connect(self.button_pressed)
            button.clicked.connect(self.button_clicked)
        except TypeError:
            pass

    def removeButton(self, button):
        try:
            button.pressed.disconnect(self.button_pressed)
            button.clicked.disconnect(self.button_clicked)
            super().removeButton(button)
        except AttributeError:
            pass

    def button_pressed(self):
        if (self.sender() is self.checkedButton()):
            self.button = self.sender()
        else:
            self.button = None

    def button_clicked(self):
        button = self.sender()
        if (button is self.button):
            exclusive = self.exclusive()
            self.setExclusive(False)
            button.setChecked(False)
            self.setExclusive(exclusive)


class HelloWindow(QMainWindow):
    def __init__(self):
        QMainWindow.__init__(self)

        centralWidget = QWidget(self)          
        self.setCentralWidget(centralWidget)   

        gridLayout = QGridLayout(centralWidget)

        self.box1 = Custom('Box 1', 'Button 1')
        self.box2 = Custom('Box 2', 'Button 2')
        self.box3 = Custom('Box 3', 'Button 3')
        self.box4 = Custom('Box 4', 'Button 4')
        gridLayout.addWidget(self.box1, 0, 0)
        gridLayout.addWidget(self.box2, 1, 0)
        gridLayout.addWidget(self.box3, 2, 0)
        gridLayout.addWidget(self.box4, 3, 0)

        button1 = QPushButton('Tab order')
        gridLayout.addWidget(button1, 4, 1)

        button1.clicked.connect(self.tab)

        group = ClearableButtonGroup(self)
        group.setExclusive(True)
        group.addButton(self.box1.box)
        group.addButton(self.box2.box)
        group.addButton(self.box3.box)
        group.addButton(self.box4.box)

    def tab(self):
        print_tab_order(self)


def print_tab_order(widget):
    w = widget
    while True:
        try:
            print('Text: {}; FocusPolicy: {}'.format(w.text(), w.focusPolicy()))
        except AttributeError:
            pass
        w = w.nextInFocusChain()
        if w == widget:
            break
    print('----')


if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    mainWin = HelloWindow()
    mainWin.show()
    sys.exit( app.exec_() )
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
Jellby
  • 2,360
  • 3
  • 27
  • 56

1 Answers1

1

It seems that this behavior is missing, if the QAbstractButton source code is revised it is implemented:

// https://code.qt.io/cgit/qt/qtbase.git/tree/src/widgets/widgets/qabstractbutton.cpp?h=5.14#n1088
void QAbstractButton::keyPressEvent(QKeyEvent *e)
{
    // ...
            d->moveFocus(e->key());
    // ...
// https://code.qt.io/cgit/qt/qtbase.git/tree/src/widgets/widgets/qabstractbutton.cpp?h=5.14#n247
void QAbstractButtonPrivate::moveFocus(int key)
{
    // ...
    if (candidate) {
        if (key == Qt::Key_Up || key == Qt::Key_Left)
            candidate->setFocus(Qt::BacktabFocusReason);
        else
            candidate->setFocus(Qt::TabFocusReason);
    }
}

As it is observed, all the buttons are considered as a unit and there is no way to disable it, so a workaround is to implement a class that has the same functionality, and in a previous post I implemented it which in your case is:

class ButtonManager(QtCore.QObject):
    def __init__(self, parent=None):
        super().__init__(parent)
        self._buttons = []

    @property
    def buttons(self):
        return self._buttons

    def add_button(self, button):
        if isinstance(button, QtWidgets.QAbstractButton):
            button.toggled.connect(self.on_toggled)
            self.buttons.append(button)

    @QtCore.pyqtSlot(bool)
    def on_toggled(self, state):
        button = self.sender()
        if state:
            for b in self.buttons:
                if b != button and b.isChecked():
                    b.blockSignals(True)
                    b.setChecked(False)
                    b.blockSignals(False)

        else:
            button.blockSignals(True)
            button.setChecked(False)
            button.blockSignals(False)
# ...
button1.clicked.connect(self.tab)

button = ButtonManager(self)
button.add_button(self.box1.box)
button.add_button(self.box2.box)
button.add_button(self.box3.box)
button.add_button(self.box4.box)
# ...
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
  • I'm not sure you are answering to my question. I don't care (or at least not as much) about moving with arrow keys, I want to move with Tab. But indeed, looking at the source code I could see a private method of `QAbstractButton` called `fixFocusPolicy`. Bypassing it (e.g. by using `QWidget.focusInEvent` instead of the `QAbstractButton` method, seems to work. – Jellby Apr 28 '20 at 17:13
  • @Jellby I proposed a workaround (have you tested my solution?) And that does not imply that it is the only solution, so if your new solution works then it is fine – eyllanesc Apr 28 '20 at 17:18
  • @eyllanesc Uhm, maybe I'm too tired and I'm missing something but... Is there a purpose of that `button.blockSignals(True)` directly followed by `button.blockSignals(False)`, or is it just a typo? – musicamante Apr 28 '20 at 21:26
  • @eyllanesc no problem, at least now I know I'm not *that* tired ;-) – musicamante Apr 28 '20 at 21:42
  • Right, it avoids the issue by not using `QButtonGroup` (and thus the buttons don't know they're in a "group" and `fixFocusPolicy` does nothing). I think your answer was missing this part of the explanation. More seriously, Qt documentation is missing (as far as I can see) any mention of the focus policy messing. – Jellby Apr 29 '20 at 08:56