-2

I am trying to implement tri-state checkboxes into a QMenu. My menu hierarchy will be something like:

menuA
    |-- a101
    |-- a102
menuB
    |-- b101

Where the first tier (menuA, menuB) are of tri-state checkboxes while its sub items are normal checkboxes, implemented using QAction.

And so, with the use of QWidgetAction and QCheckBox, seemingly I am able to get the tristate working on the first tier level.

However as soon as I tried to use setMenu that contains the sub items into the first tier items, the options are no longer checkable even though it is able to display the sub items accordingly.

Initially I am using only QAction widgets but as I am iterating the sub items, the first tier item is always shown as a full check in which I would like to rectify it if possible and hence I am trying to make use of the tri-state.

Eg. If a101 is checked, menuA will be set with a partial state. If both a101 and a102 are checked, menuA will then be set with (full) check state.

class CustomCheckBox(QtGui.QCheckBox):
    def __init__(self, text="", parent=None):
        super(CustomCheckBox, self).__init__(text, parent=parent)

        self.setText(text)
        self.setTristate(True)


class QSubAction(QtGui.QAction):
    def __init__(self, text="", parent=None):
        super(QSubAction, self).__init__(text, parent)
        self.setCheckable(True)

        self.toggled.connect(self.checkbox_toggle)

    def checkbox_toggle(self, value):
        print value


class QCustomMenu(QtGui.QMenu):
    """Customized QMenu."""

    def __init__(self, title, parent=None):
        super(QCustomMenu, self).__init__(title=str(title), parent=parent)
        self.setup_menu()

    def mousePressEvent(self,event):
        action = self.activeAction()
        if not isinstance(action,QSubAction) and action is not None:
            action.trigger()
            return
        elif isinstance(action,QSubAction):
            action.toggle()
            return
        return QtGui.QMenu.mousePressEvent(self,event)

    def setup_menu(self):
        self.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu)

    def contextMenuEvent(self, event):
        no_right_click = [QAddAction]
        if any([isinstance(self.actionAt(event.pos()), instance) for instance in no_right_click]):
            return
        pos = event.pos()

    def addAction(self, action):
        super(QCustomMenu, self).addAction(action)


class MainApp(QtGui.QWidget):
    def __init__(self, parent=None):
        super(MainApp, self).__init__(parent)

        self.test_dict = {
            "testA" :{
                "menuA": ["a101", "a102"],
            },
            "testBC": {
                "menuC": ["c101", "c102", "c103"],
                "menuB": ["b101"]
            },
        }

        v_layout = QtGui.QVBoxLayout()
        self.btn1 = QtGui.QPushButton("TEST BTN1")
        v_layout.addWidget(self.btn1)

        self.setLayout(v_layout)

        self.setup_connections()

    def setup_connections(self):
        self.btn1.clicked.connect(self.button1_test)

    def button1_test(self):
        self.qmenu = QCustomMenu(title='', parent=self)

        for pk, pv in self.test_dict.items():
            base_qmenu = QCustomMenu(title=pk, parent=self)

            base_checkbox = CustomCheckBox(pk, base_qmenu)
            base_action = QtGui.QWidgetAction(base_checkbox)
            base_action.setMenu(base_qmenu) # This is causing the option un-checkable
            base_action.setDefaultWidget(base_checkbox)

            self.qmenu.addAction(base_action)

            for v in pv:
                action = QSubAction(v, self)
                base_qmenu.addAction(action)

        self.qmenu.exec_(QtGui.QCursor.pos())


if __name__ == "__main__":
    app = QtGui.QApplication(sys.argv)
    w = MainApp()
    w.show()
    sys.exit(app.exec_())
Teh Ki
  • 455
  • 1
  • 3
  • 14

1 Answers1

1

The reason for which you can't set the state of a sub menu is that QMenu automatically uses the click on a sub menu to open it, "consuming" the click event.

To get that you'll have to ensure where the user is clicking and, if it's one of your QWidgetActions trigger it, ensuring that the event is not being propagated furthermore.

Also, the tri state logic is added to the children state, using the toggled signal that checks all menu actions to decide the actual state.

Note that contextMenuEvent (along with the menu policy setting) has been removed.

Finally, consider that using a checkbox that does not trigger an action in a menu item is not suggested, as it's counterintuitive since it goes against the expected behavior of a menu item.

class CustomCheckBox(QtGui.QCheckBox):
    def __init__(self, text="", parent=None):
        super(CustomCheckBox, self).__init__(text, parent=parent)

        self.setText(text)
        self.setTristate(True)

    def mousePressEvent(self, event):
        # only react to left click buttons and toggle, do not cycle
        # through the three states (which wouldn't make much sense)
        if event.button() == QtCore.Qt.LeftButton:
            self.toggle()

    def toggle(self):
        super(CustomCheckBox, self).toggle()
        newState = self.isChecked()
        for action in self.actions():
            # block the signal to avoid recursion
            oldState = action.isChecked()
            action.blockSignals(True)
            action.setChecked(newState)
            action.blockSignals(False)
            if oldState != newState:
                # if you *really* need to trigger the action, do it
                # only if the action wasn't already checked
                action.triggered.emit(newState)


class QSubAction(QtGui.QAction):
    def __init__(self, text="", parent=None):
        super(QSubAction, self).__init__(text, parent)
        self.setCheckable(True)


class QCustomMenu(QtGui.QMenu):
    """Customized QMenu."""

    def __init__(self, title, parent=None):
        super(QCustomMenu, self).__init__(title=str(title), parent=parent)

    def mousePressEvent(self,event):
        actionAt = self.actionAt(event.pos())
        if isinstance(actionAt, QtGui.QWidgetAction):
            # the first mousePressEvent is sent from the parent menu, so the
            # QWidgetAction found is one of the sub menu actions
            actionAt.defaultWidget().toggle()
            return
        action = self.activeAction()
        if not isinstance(action,QSubAction) and action is not None:
            action.trigger()
            return
        elif isinstance(action,QSubAction):
            action.toggle()
            return
        QtGui.QMenu.mousePressEvent(self,event)

    def addAction(self, action):
        super(QCustomMenu, self).addAction(action)
        if isinstance(self.menuAction(), QtGui.QWidgetAction):
            # since this is a QWidgetAction menu, add the action
            # to the widget and connect the action toggled signal
            action.toggled.connect(self.checkChildrenState)
            self.menuAction().defaultWidget().addAction(action)

    def checkChildrenState(self):
        actionStates = [a.isChecked() for a in self.actions()]
        if all(actionStates):
            state = QtCore.Qt.Checked
        elif any(actionStates):
            state = QtCore.Qt.PartiallyChecked
        else:
            state = QtCore.Qt.Unchecked
        self.menuAction().defaultWidget().setCheckState(state)
musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Hi @musciamante, thank you for the reply. I came to notice that the base menu are missing the 'right-arrow', is that possibly due to the use of QWidgetAction? – Teh Ki Aug 29 '19 at 18:45
  • Yes, it is. I suppose that QMenu decides to paint an arrow only if it finds a QAction registered with a menu, meaning that it actually paints the QAction contents (therefore its features: icons, check/radios, and the sub menu arrow), while if there's a QWidgetAction it only paints the frame around it, and leave the defaultWidget() do the rest. If you want to draw the arrow, I'm afraid you'll need to do that by yourself, possibly by adding an arrow character or increasing the right component of contentMargin() and paint an arrow in paintEvent. – musicamante Aug 30 '19 at 12:24