3

Consider this little snippet:

import sys

from PyQt5 import QtWidgets
from PyQt5 import QtWidgets
from PyQt5.QtGui import QStandardItemModel
from PyQt5.QtGui import QStandardItem
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QGridLayout
from PyQt5.QtWidgets import QPushButton
from PyQt5.QtWidgets import QWidget
from PyQt5.QtWidgets import QTreeView
from PyQt5.QtWidgets import QAbstractItemView


packages = {
    'tree': {
        'parent1': ['child1', 'child2', 'child3'],
        'parent2': ['child4', 'child5'],
        'parent3': ['child6']
    },
    'metadata': {
        'child1': {'description': 'child1 description', 'enabled': True},
        'child2': {'description': 'child2 description', 'enabled': False},
        'child3': {'description': 'child3 description', 'enabled': True},
        'child4': {'description': 'child4 description', 'enabled': False},
        'child5': {'description': 'child5 description', 'enabled': True},
        'child6': {'description': 'child6 description', 'enabled': True}
    }
}


class McveDialog(QWidget):

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

        self.treeview = QTreeView()
        # self.treeview.setHeaderHidden(True)
        self.treeview.setUniformRowHeights(True)
        # self.treeview.setEditTriggers(QAbstractItemView.NoEditTriggers)
        # self.treeview.setSelectionMode(QAbstractItemView.ExtendedSelection)

        self.model = QStandardItemModel()
        self.model.setHorizontalHeaderLabels(['Package', 'Description'])

        metadata = packages['metadata']
        tree = packages['tree']
        for parent, childs in tree.items():
            parent_item = QStandardItem(f'{parent}')
            parent_item.setCheckState(True)
            parent_item.setCheckable(True)
            parent_item.setFlags(parent_item.flags() | Qt.ItemIsAutoTristate)
            # parent_item.setFlags(parent_item.flags() | Qt.ItemIsUserTristate)
            self.model.appendRow(parent_item)

            for child in childs:
                description = metadata[child]['description']
                checked = metadata[child]['enabled']
                child_item = QStandardItem(f'{child}')
                check = Qt.Checked if checked else Qt.Unchecked
                child_item.setCheckState(check)
                child_item.setCheckable(True)
                # child_item.setFlags(child_item.flags() |Qt.ItemIsAutoTristate)
                parent_item.appendRow(child_item)

        self.treeview.setModel(self.model)
        self.model.itemChanged.connect(self.on_itemChanged)

        layout = QGridLayout()
        row = 0
        layout.addWidget(self.treeview, row, 0, 1, 3)

        row += 1
        self.but_ok = QPushButton("OK")
        layout.addWidget(self.but_ok, row, 1)
        self.but_ok.clicked.connect(self.on_ok)

        self.but_cancel = QPushButton("Cancel")
        layout.addWidget(self.but_cancel, row, 2)
        self.but_cancel.clicked.connect(self.on_cancel)

        self.setLayout(layout)
        self.setGeometry(300, 200, 460, 350)

    def on_itemChanged(self, item):
        pass

    def on_ok(self):
        pass

    def on_cancel(self):
        self.close()


if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    dialog = McveDialog()
    dialog.setWindowTitle('Mcve dialog')
    dialog.show()
    sys.exit(app.exec_())

What I'm trying to achieve here, is that when a user selects all of a parent's children, the parent state becomes checked; if all the children are deselected, then the parent state becomes deselected; and finally if some of the children are selected, then the parent becomes partially checked. (And vice-versa, so if the user deselects a parent, all its children will be deselected, and if a user selects a parent, all its children will become selected).

In theory, this behaviour should be achieved by using Qt::ItemIsAutoTristate flag, which says:

The item's state depends on the state of its children. This enables automatic management of the state of parent items in QTreeWidget (checked if all children are checked, unchecked if all children are unchecked, or partially checked if only some children are checked).

But if you run the code above, you'll see the behaviour is not the one you'd expect after reading the docs. I've seen there is this bugreport, although i'm not sure if it's related to this, or if my snippet is just missing some stuff.

For instance, the above snippet allows you to do this:

enter image description here

Anyway, the question would be, how would you fix this widget so it'll behave as any package installer, where you can select/unselect/partially-select all subpackages at once having a common parent?

ekhumoro
  • 115,249
  • 20
  • 229
  • 336
BPL
  • 9,632
  • 9
  • 59
  • 117
  • The docs specifically refer to a QTreeWidget. Since you're not using one, the behaviour is exactly as expected. – ekhumoro Mar 07 '18 at 17:20
  • @ekhumoro Indeed, you're absolutely right, I hadn't read that part from the docs. Let's see then whether a QTreeWidget works out of the box. In any case, I guess to obtain a QTreeView behaving like that you'd need to implement such behaviour manually? – BPL Mar 07 '18 at 18:10
  • It looks like it. I'm just going to have a look at the QTreeWidget source code to see how complicated it is. – ekhumoro Mar 07 '18 at 18:12

2 Answers2

5

It seems that, currently, ItemIsAutoTristate is only implemented for the QTreeWidget class. The QStandardItem subclass below provides the same functionality for item-views that use a QStandardItemModel. This is a more or less faithful port of the QTreeWidget implementation. It seems to work fine with the example code, but I haven't tested it to death:

class StandardItem(QStandardItem):
    def data(self, role = Qt.UserRole + 1):
        if (role == Qt.CheckStateRole and self.hasChildren() and
            self.flags() & Qt.ItemIsAutoTristate):
            return self._childrenCheckState()
        return super().data(role)

    def setData(self, value, role=Qt.UserRole + 1):
        if role == Qt.CheckStateRole:
            if (self.flags() & Qt.ItemIsAutoTristate and
                value != Qt.PartiallyChecked):
                for row in range(self.rowCount()):
                    for column in range(self.columnCount()):
                        child = self.child(row, column)
                        if child.data(role) is not None:
                            flags = self.flags()
                            self.setFlags(flags & ~Qt.ItemIsAutoTristate)
                            child.setData(value, role)
                            self.setFlags(flags)
            model = self.model()
            if model is not None:
                parent = self
                while True:
                    parent = parent.parent()
                    if (parent is not None and
                        parent.flags() & Qt.ItemIsAutoTristate):
                        model.dataChanged.emit(
                            parent.index(), parent.index(),
                            [Qt.CheckStateRole])
                    else:
                        break
        super().setData(value, role)

    def _childrenCheckState(self):
        checked = unchecked = False
        for row in range(self.rowCount()):
            for column in range(self.columnCount()):
                child = self.child(row, column)
                value = child.data(Qt.CheckStateRole)
                if value is None:
                    return
                elif value == Qt.Unchecked:
                    unchecked = True
                elif value == Qt.Checked:
                    checked = True
                else:
                    return Qt.PartiallyChecked
                if unchecked and checked:
                    return Qt.PartiallyChecked
        if unchecked:
            return Qt.Unchecked
        elif checked:
            return Qt.Checked
ekhumoro
  • 115,249
  • 20
  • 229
  • 336
  • Very good answer, I can't think of any case where your code would fail, so thanks about it! It'd be nice if they added something like this in a future Qt/PyQt5 version, wonder why it's not already implemented though :/. Btw, added a little snippet showing how QTreeWidget would work out of the box – BPL Mar 12 '18 at 00:30
2

As explained in @ekhumoro answer and Qt docs, it seems ItemIsAutoTristate is only implemented for the QTreeWidget class, just for the sake of completeness here's a little snippet showing how using that flag on a QTreeWidget would work out of the box:

import sys

from PyQt5 import QtWidgets
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QGridLayout
from PyQt5.QtWidgets import QPushButton
from PyQt5.QtWidgets import QWidget
from PyQt5.QtWidgets import QTreeWidget
from PyQt5.QtWidgets import QTreeWidgetItem

packages = {
    'tree': {
        'parent1': ['child1', 'child2', 'child3'],
        'parent2': ['child4', 'child5'],
        'parent3': ['child6']
    },
    'metadata': {
        'child1': {'description': 'child1 description', 'enabled': True},
        'child2': {'description': 'child2 description', 'enabled': False},
        'child3': {'description': 'child3 description', 'enabled': True},
        'child4': {'description': 'child4 description', 'enabled': False},
        'child5': {'description': 'child5 description', 'enabled': True},
        'child6': {'description': 'child6 description', 'enabled': True}
    }
}


class McveDialog(QWidget):

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

        self.treewidget = QTreeWidget()
        self.treewidget.setHeaderLabels(['Package', 'Description'])

        metadata = packages['metadata']
        tree = packages['tree']
        for parent, childs in tree.items():
            parent_item = QTreeWidgetItem(self.treewidget)
            parent_item.setText(0, parent)
            parent_item.setFlags(parent_item.flags() |
                                 Qt.ItemIsAutoTristate | Qt.ItemIsUserCheckable)
            parent_item.setCheckState(0, Qt.Checked)

            for child in childs:
                description = metadata[child]['description']
                checked = metadata[child]['enabled']
                child_item = QTreeWidgetItem(parent_item)
                child_item.setText(0, child)
                child_item.setText(
                    1, packages['metadata'][child]['description'])
                check = Qt.Checked if checked else Qt.Unchecked
                child_item.setFlags(child_item.flags() |
                                    Qt.ItemIsUserCheckable)
                child_item.setCheckState(0, check)

        layout = QGridLayout()
        row = 0
        layout.addWidget(self.treewidget, row, 0, 1, 3)

        row += 1
        self.but_ok = QPushButton("OK")
        layout.addWidget(self.but_ok, row, 1)
        self.but_ok.clicked.connect(self.on_ok)

        self.but_cancel = QPushButton("Cancel")
        layout.addWidget(self.but_cancel, row, 2)
        self.but_cancel.clicked.connect(self.on_cancel)

        self.setLayout(layout)
        self.setGeometry(300, 200, 460, 350)

    def on_ok(self):
        self.close()

    def on_cancel(self):
        self.close()


if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    dialog = McveDialog()
    dialog.setWindowTitle('Mcve dialog')
    dialog.show()
    sys.exit(app.exec_())
BPL
  • 9,632
  • 9
  • 59
  • 117