Search code examples
pyqtpyqt5

How to Integrate a QTreeview with QComboBox?


How to integrate a QTreeView with QCombobox in Pyqt5 ? If I run "QTreeView" class individually its runs without problem. If i Click "Select All" then all items in QTreeView will Checked and so on. Next move is I want to integrate this QTreeview into QComboBox. If I run a "Mainwindow" class items add sucessfully, but if I am not able to click any items. Also how to get the checked item values ?

import sys
from PyQt5.QtWidgets import  *
from PyQt5.QtGui import  *
from PyQt5.QtCore import *
data = {"select all" : {'Group 1': ['item11', 'item12'], 'Group 2': ['item21', 'item22'],'Group 3': ['item31', 'item32']}}

class MyTreeView(QTreeView):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setModel(QStandardItemModel())
        self.setHeaderHidden(True)
        self.root_text, self.parent_text, self.child_text = [], [], []
        self.checked_texts = []
        self.create_model()


    def create_model(self):
        for  root_key,root_value in data.items():
            print(root_key,root_value)
            if root_key not in self.root_text: self.root_text.append(root_key)
            self.root_item = []
            self.root_item = QStandardItem()
            self.root_item.setData(root_key,role=Qt.DisplayRole)
            self.root_item.setCheckable(True)
            self.model().appendRow(self.root_item)

            for parent_key,parent_value in root_value.items():
                if parent_key not in self.parent_text: self.parent_text.append(parent_key)
                self.parent_item = []
                self.parent_item = QStandardItem()
                self.parent_item.setData(parent_key,role=Qt.DisplayRole)
                self.parent_item.setCheckable(True)
                self.root_item.appendRow(self.parent_item)

                for child_value in parent_value:
                    if child_value not in self.child_text: self.child_text.append(child_value)
                    self.child_item = []
                    self.child_item = QStandardItem()
                    self.child_item.setData(child_value,role=Qt.DisplayRole)
                    self.child_item.setCheckable(True)
                    self.parent_item.appendRow(self.child_item)

        self.model().itemChanged.connect(self.update_children)
        self.expandAll()

    def update_children(self, item):
        if item.text() == "Select All":
            for i in range(item.rowCount()):
                child = item.child(i)
                child.setCheckState(item.checkState())
                self.update_children(child)
        elif item.rowCount() > 0:
            for i in range(item.rowCount()):
                child = item.child(i)
                child.setCheckState(item.checkState())

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

        self.setWindowTitle("QCombobox")
        self.comboBox = QComboBox()
        self.comboBox.setEditable(False)

        self.treeView = MyTreeView()
        self.comboBox.setView(self.treeView)
        self.treeView.create_model()
        
        self.vbox = QVBoxLayout()
        self.setLayout(self.vbox)
        self.vbox.addWidget(self.comboBox)

def main():
    app = QApplication(sys.argv)
    # ex = MyTreeView()
    ex = MainWindow()
    ex.show()
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()

`

My expectitation is, Qcombobox dropdown looks like hirecrary QTreeView with checkable option and get the value of checked items.


Solution

  • Premise

    Considering the UX convention on combo boxes, checkable items and tree views are not really suited for this purpose.

    While I can understand your requirement, users normally expect a standard behavior, meaning that:

    • clicking on an item of a combo popup results in selecting that item and closing the popup;
    • a combo box is usually based on a mono-dimensional array with a unique selection behavior, and it's implemented in that way: the normal appearance of the combo should display the only currently selected index;
    • if the combo should display selected items, how should it show them? Assuming that you override the painting and show the selected elements, how should they be sorted? Based on the order of selection, or that of the parent?
    • if the model is sorted, should the display order respect that?
    • if lots of items are selected, you probably cannot display all of them when the popup is hidden, meaning that the user is forced to open the popup to check the selected items (possibly missing some if the model is too big); not to mention the fact that you could have items with similar (or not verbose enough) names;

    Considering the above (and ignoring furhter important UX related aspects), a combo box is rarely a good choice.

    You should consider other alternatives instead, for instance:

    • a plain QTreeView within the UI;
    • a basic button with a related menu, including sub menus;
    • a label or read-only QLineEdit showing the currently selected options, possibly with a nearby button that shows the tree as a popup;
    • the above, but using a modal dialog for the tree view, which is possibly the better choice;

    Still, we want the combo box

    The main problem is that QComboBox by default closes the view when a mouse button is released after being pressed on it.

    That happens internally, with a private container widget (a QFrame) that acts as a container for the view allowing separation and control without interfering with the view; that container also has an even filter installed on the view, intercepting user interaction: keyboard and mouse events.

    Due to this aspect, you cannot only bypass the default close behavior (which calls hidePopup()) based on the event type, because it would also prevent the actual interaction with the view: clicking on an item will do nothing.

    To avoid that, you need to install a further event filter, intercept those mouse events, manually implement the check state change (which normally happens on mouse release) and completely ignore the default behavior.

    Basic implementation

    Note that your code has various problems, starting with the fact that you're changing the model of the view: the view shouldn't have its own model, because it must use the model of the combo; using a different model can create issues and unexpected behavior. The parent check state is also inconsistent, and should be properly treated to consider possible recursion, which is fundamental when dealing with tree models. Finally, declaring attributes in a for loop (like you do with self.root_item, self.parent_item and self.child_item) is almost always pointless, since they will always be overwritten at each loop iteration; the only case for which that is acceptable is when you do need to keep a reference to the last created item (which is certainly not your case). The lists created for the above attributes (eg. self.parent_item = []) is also useless, since you're immediately overwriting them.

    The following code revision is based on these premises:

    • the tree view is not subclassed;
    • the model is subclassed, using an internal function to eventually set parent/child check states: doing this within the model instead of the view is a better choice from the OOP perspective (read about the concept of separation of concerns);
    • the combo is also subclassed in order to provide direct interaction with the event filter and mouse event handling;
    • the view is resized before being shown in order to ensure that all items are actually visible;
    from PyQt5.QtWidgets import  *
    from PyQt5.QtGui import  *
    from PyQt5.QtCore import *
    
    data = {
        "select all" : {
            'Group 1': ['item11', 'item12'], 
            'Group 2': ['item21', 'item22'], 
            'Group 3': ['item31', 'item32'], 
            'Group 4': ['item41', 'item42'], 
        }
    }
    
    
    class MyModel(QStandardItemModel):
        def __init__(self):
            super().__init__()
            self.root_text, self.parent_text, self.child_text = [], [], []
    
            for root_key,root_value in data.items():
                if root_key not in self.root_text:
                    self.root_text.append(root_key)
                root_item = QStandardItem()
                root_item.setData(root_key,role=Qt.DisplayRole)
                root_item.setCheckable(True)
                self.appendRow(root_item)
    
                for parent_key,parent_value in root_value.items():
                    if parent_key not in self.parent_text:
                        self.parent_text.append(parent_key)
                    parent_item = QStandardItem()
                    parent_item.setData(parent_key,role=Qt.DisplayRole)
                    parent_item.setCheckable(True)
                    root_item.appendRow(parent_item)
    
                    for child_value in parent_value:
                        if child_value not in self.child_text:
                            self.child_text.append(child_value)
                        child_item = []
                        child_item = QStandardItem()
                        child_item.setData(child_value,role=Qt.DisplayRole)
                        child_item.setCheckable(True)
                        parent_item.appendRow(child_item)
    
            self.itemChanged.connect(self.update_children)
    
        def update_children(self, item, fromUser=True):
            if fromUser:
                # temporarily disconnect to avoid recursion
                self.itemChanged.disconnect(self.update_children)
            for i in range(item.rowCount()):
                child = item.child(i)
                child.setCheckState(item.checkState())
                # explicitly call update_children
                self.update_children(child, False)
    
            if fromUser:
                root = self.invisibleRootItem()
                parent = item.parent() or root
                while True:
                    count = parent.rowCount()
                    checked = 0
                    for i in range(count):
                        state = parent.child(i).checkState()
                        if state == Qt.Checked:
                            checked += 1
                        elif state == Qt.PartiallyChecked:
                            parent.setCheckState(Qt.PartiallyChecked)
                            break
                    else:
                        if not checked:
                            parent.setCheckState(Qt.Unchecked)
                        elif checked == count:
                            parent.setCheckState(Qt.Checked)
                        else:
                            parent.setCheckState(Qt.PartiallyChecked)
    
                    if parent == root:
                        break
                    parent = parent.parent() or root
    
                self.itemChanged.connect(self.update_children)
    
    
    class MyCombo(QComboBox):
        clickedData = None
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.treeView = QTreeView()
            self.treeView.setHeaderHidden(True)
            self.setView(self.treeView)
            self.treeView.viewport().installEventFilter(self)
    
            self.delegate = QStyledItemDelegate(self.treeView)
    
        def eventFilter(self, obj, event):
            if (
                event.type() == event.MouseButtonPress
                and event.button() == Qt.LeftButton
            ):
                index = self.treeView.indexAt(event.pos())
                if index.isValid():
                    opt = self.treeView.viewOptions()
                    opt.rect = self.treeView.visualRect(index)
                    self.delegate.initStyleOption(opt, index)
                    checkRect = self.style().subElementRect(
                        QStyle.SE_ItemViewItemCheckIndicator, opt, self.treeView)
                    if checkRect.contains(event.pos()):
                        self.clickedData = index, checkRect
            elif event.type() == event.MouseButtonRelease:
                if event.button() == Qt.LeftButton and self.clickedData:
                    index = self.treeView.indexAt(event.pos())
                    pressIndex, checkRect = self.clickedData
                    if index == pressIndex and event.pos() in checkRect:
                        state = index.data(Qt.CheckStateRole)
                        if state == Qt.Checked:
                            state = Qt.Unchecked
                        else:
                            state = Qt.Checked
                        self.model().setData(index, state, Qt.CheckStateRole)
                    self.clickedData = None
                return True
            elif (
                event.type() == event.MouseButtonDblClick
                and event.button() == Qt.LeftButton
            ):
                index = self.treeView.indexAt(event.pos())
                state = index.data(Qt.CheckStateRole)
                if state == Qt.Checked:
                    state = Qt.Unchecked
                else:
                    state = Qt.Checked
                self.model().setData(index, state, Qt.CheckStateRole)
                self.treeView.viewport().update()
                self.clickedData = None
                return True
            return super().eventFilter(obj, event)
    
        def showPopup(self):
            self.treeView.expandAll()
            width = self.treeView.sizeHintForColumn(0)
            maxCount = self.maxVisibleItems()
            index = self.model().index(0, 0, self.rootModelIndex())
            visible = 0
            while index.isValid():
                visible += 1
                index = self.treeView.indexBelow(index)
                if visible > maxCount:
                    # the visible count is higher than the maximum, so the vertical
                    # scroll bar will be shown and we have to consider its width.
                    # Note that this does NOT consider styles that use "transient"
                    # scroll bars, which are shown *within* the content of the view,
                    # as it happens on macOs; see QStyle.styleHint() and
                    # QStyle::SH_ScrollBar_Transient
                    width += self.treeView.verticalScrollBar().sizeHint().width()
                    break
            self.treeView.setMinimumWidth(width)
            super().showPopup()
    
    
    class MainWindow(QWidget):
        def __init__(self):
            super().__init__()
    
            self.setWindowTitle("QCombobox")
            self.comboBox = MyCombo()
            self.comboBox.setEditable(False)
            self.model = MyModel()
            self.comboBox.setModel(self.model)
            
            self.vbox = QVBoxLayout()
            self.setLayout(self.vbox)
            self.vbox.addWidget(self.comboBox)
    
    
    if __name__ == '__main__':
        import sys
        app = QApplication(sys.argv)
        ex = MainWindow()
        ex.show()
        sys.exit(app.exec_())
    

    Margin for improvement

    There are still some tweaks we could use. For instance, preventing the arrows to be shown in parent items (there is no point in showing them since they shouldn't be interactive); and there's still the issue of what to display in the combo box.

    Here are some further adjustments:

    class TreeDelegate(QStyledItemDelegate):
        '''
        A delegate that paints the tree decorations on its own
        '''
        def paint(self, qp, opt, index):
            super().paint(qp, opt, index)
            opt = QStyleOptionViewItem(opt)
            self.initStyleOption(opt, index)
            style = opt.widget.style()
            indent = opt.widget.indentation()
            opt.rect.setWidth(indent)
    
            opt.rect.moveLeft(opt.rect.left() - indent)
            extra = style.State_Enabled | style.State_Active
            opt.state = style.State_Item | extra
            if index.siblingAtRow(index.row() + 1).isValid():
                opt.state |= style.State_Sibling
            style.drawPrimitive(style.PE_IndicatorBranch, opt, qp)
            
            while True:
                opt.rect.moveLeft(opt.rect.left() - indent)
                opt.state = extra
                parent = index.parent()
                if parent.siblingAtRow(parent.row() + 1).isValid():
                    opt.state |= style.State_Sibling
                style.drawPrimitive(style.PE_IndicatorBranch, opt, qp)
                if not parent.isValid():
                    break
                index = parent
    
    
    class MyCombo(QComboBox):
        clickedData = None
        def __init__(self, *args, **kwargs):
            # ...
            self.treeView.setItemsExpandable(False)
            self.treeView.setStyleSheet('QTreeView::branch { image: url(none.png); }')
            self.delegate = TreeDelegate(self.treeView)
            self.treeView.setItemDelegate(self.delegate)
    
        def checkedIndexes(self, parent=None):
            '''
            A recursive function that returns all checked indexes, based on the
            original model sorting; the resulting index list only contains "final"
            items, based on the assumption that, if a parent item is checked,
            all its children are (resulting in returning the list of all children
            that are currently checked): the resulting list only contains "final"
            items, not their parents.
            '''
            if parent is None:
                parent = QModelIndex()
            model = self.model()
            indexes = []
            for i in range(model.rowCount(parent)):
                index = model.index(i, 0, parent)
                if index.data(Qt.CheckStateRole) == Qt.Unchecked:
                    continue
                if not model.rowCount(index):
                    indexes.append(index)
                else:
                    indexes.extend(self.checkedIndexes(index))
            return indexes
    
        def paintEvent(self, event):
            qp = QStylePainter(self)
            qp.setPen(self.palette().color(QPalette.Text))
    
            style = self.style()
            opt = QStyleOptionComboBox()
            self.initStyleOption(opt)
            qp.drawComplexControl(style.CC_ComboBox, opt)
    
            if self.currentIndex() < 0:
                brush = opt.palette.brush(QPalette.ButtonText).color().lighter()
                opt.palette.setBrush(QPalette.ButtonText, brush)
    
            checked = self.checkedIndexes()
            textRect = style.subControlRect(
                style.CC_ComboBox, opt, style.SC_ComboBoxEditField, self)
            if checked:
                text = ', '.join(i.data() for i in checked)
                opt.currentText = self.fontMetrics().elidedText(
                    text, Qt.ElideRight, textRect.width())
                qp.drawControl(QStyle.CE_ComboBoxLabel, opt)
            else:
                text = self.fontMetrics().elidedText(
                    'No selection', Qt.ElideRight, textRect.width())
                qp.setPen(opt.palette.color(QPalette.Disabled, QPalette.ButtonText))
                qp.drawText(textRect, 0, text)
    

    Final considerations

    As you can see, and as it often happens with a well designed toolkit such as Qt, it can be done.

    But the real question remains: should it be done? As a famous pop-culture character properly noted decades ago, even if it works, it doesn't mean that we should use it.

    Note: the above codes are quite advanced; I strongly suggest you to take your time to study them and investigate about their behavior and all the classes and functions they use: even if you end up by not using it, as suggested, it's still useful and educational to know how they work and why they do what they do.