Search code examples
pythonqtpysidepyside6

QTreeWidget / QTreeView Custom Folding to "Always Show" Leaf


I want to add the ability to keep any number of leaves within a QTreeWidget or QTreeView visible even when the parent is folded. This will give the user the ability to keep only the information they're interested in visible while hiding other information.

How can I implement this effect?

import sys
from PySide6.QtWidgets import QApplication, QMainWindow, QTreeWidget, QTreeWidgetItem

data = {
    "Module 1": {
        "Sub-Module 1": {
            "Leaf 1": "Leaf 1 Info",
            "Leaf 2": "Leaf 2 Info",
            "Leaf 3": "Leaf 3 Info",
            "Leaf 4": "Leaf 4 Info",
        },
        "Sub-Module 2": {
            "Leaf 5": "Leaf 5 Info",
            "Leaf 6": "Leaf 6 Info",
            "Leaf 7": "Leaf 7 Info",
            "Leaf 8": "Leaf 8 Info",
        },
    },
    "Module 2": {
        "Sub-Module 3": {
            "Leaf 9": "Leaf 9 Info",
            "Leaf 10": "Leaf 10 Info",
            "Leaf 11": "Leaf 11 Info",
            "Leaf 12": "Leaf 12 Info",
        },
    },
}


class TreeWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setGeometry(300, 300, 300, 400)
        tw = QTreeWidget(self)
        tw.setColumnCount(2)
        tw.setAlternatingRowColors(True)
        tw.setHeaderLabels(["Name", "Description"])
        self.fill_tree(tw, data)
        self.setCentralWidget(tw)
        tw.expandAll()
        tw.resizeColumnToContents(0)

    def fill_tree(self, widg, data) -> None:
        for key, val in data.items():
            if isinstance(val, str):
                tw_item = QTreeWidgetItem([key, val])
                keep_going = False
            else:
                tw_item = QTreeWidgetItem([key])
                keep_going = True

            if isinstance(widg, QTreeWidget):
                widg.addTopLevelItem(tw_item)
            else:
                widg.addChild(tw_item)

            if keep_going:
                self.fill_tree(tw_item, val)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = TreeWindow()
    window.show()
    sys.exit(app.exec())

By way of example, if the above code were run and "Leaf 3" were selected to be "always shown" but the rest of the tree was folded, the following is the information desired to be displayed.

|-- Module 1
|   `-- Sub-Module 1
|       `-- Leaf 3
`-- Module 2

Solution

  • Well, that's not easy to achieve.

    The most important aspect to consider is that a tree view has a conventional and expected behavior that users expect: when you "fold" (the proper term is collapse) an item, the user expects that all its children (including [great-]grand children) are collapsed as well: only the parent is expected to be visible.

    Luckily, Qt is quite extensible and allows us to find some work arounds, but they are not cost-free.

    The most important problem is that the user (righteously) expects to see a visible hint that shows whether an item is expanded or collapsed. Qt, in its "simplicity", just assumes that aspect based on the current view state, meaning that the "expanding decoration" will be shown according to the fact that a parent has (and shows) its children or not.

    Then, there is a conceptual problem in this request: what if one of the children is "collapsed", but any of the other is not? What should the parent show? And what if the parent is "expanded", but all its children are not?

    That's another reason for which, considering UX aspects, such interface is normally not provided (nor desired) in a generic toolkit implementation.

    Still, we want that behavior... so?

    One possibility is to work around all these problems, which requires us to take care of many aspects:

    • ensure that the "virtual" collapsed/expanded state is persistent, based on the parent;
    • override the drawing, possibly using a QProxyStyle;
    • use recursive functions that take care of the expanded/collapsed state of a parent;
    • take care of user interaction;

    With the above in mind, here is a possible (though, imperfect) solution:

    import sys
    from PyQt5.QtCore import *
    from PyQt5.QtWidgets import *
    
    data = {
    # ... as above
    }
    
    ExpandedRole = Qt.UserRole + 100 # a custom role to "override" the parent state
    
    
    class ProxyStyle(QProxyStyle):
        _childIndex = None
        def drawPrimitive(self, elem, opt, qp, widget=None):
            if elem == self.PE_IndicatorBranch and self._childIndex:
                # use the _childIndex reference for the *first* call to 
                # drawPrimitive only, then revert to the default painting
                if self._childIndex.model().hasChildren(self._childIndex):
                    opt.state |= self.State_Children
                    if not self._childIndex.data(ExpandedRole):
                        opt.state &= ~self.State_Open
                self._childIndex = None
    
            super().drawPrimitive(elem, opt, qp, widget)
    
    
    class ProxyStyle(QProxyStyle):
        _childIndex = None
        def drawPrimitive(self, elem, opt, qp, widget=None):
            if elem == self.PE_IndicatorBranch and self._childIndex:
                if self._childIndex.model().hasChildren(self._childIndex):
                    opt.state |= self.State_Children
                    if not self._childIndex.data(ExpandedRole):
                        opt.state &= ~self.State_Open
                self._childIndex = None
    
            super().drawPrimitive(elem, opt, qp, widget)
    
    
    class TreeWidget(QTreeWidget):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.setAnimated(False)
            # override the internal collapsed signal
            self.collapsed.connect(self.expand)
            # do the same for internal calls for expanding
            self.expanded.connect(lambda index: self.setExpanded(index, True))
    
        def drawBranches(self, qp, rect, index):
            # set a "reference" index for the style, necessary for the indentation
            self.style()._childIndex = index
            super().drawBranches(qp, rect, index)
    
        def isCheckedRecursive(self, index):
            # tell if an item or any of its children is checked (not "hideable")
            if index.data(Qt.CheckStateRole):
                return True
            model = index.model()
            for row in range(model.rowCount(index)):
                childIndex = model.index(row, 0, index)
                if self.isCheckedRecursive(childIndex):
                    return True
            return False
    
        def setExpanded(self, parent, expanded):
            # an overridden "slot"
            model = self.model()
            if not model.hasChildren(parent):
                return False
            wasExpanded = parent.data(ExpandedRole)
            if expanded == wasExpanded:
                return wasExpanded
            model.setData(parent, expanded, ExpandedRole)
            r = self.visualRect(parent)
            r.setLeft(0)
            self.viewport().update(r)
            hasExpanded = False
            for row in range(model.rowCount(parent)):
                childIndex = model.index(row, 0, parent)
                if self.setExpanded(childIndex, expanded):
                    hasExpanded = True
                elif expanded:
                    self.setRowHidden(row, parent, False)
                else:
                    self.setRowHidden(row, parent, 
                        not self.isCheckedRecursive(childIndex))
            return hasExpanded
    
        def toggleExpanded(self, index):
            expand = index.data(ExpandedRole)
            if expand is not None:
                expand = not expand
            else:
                for r in range(self.model().rowCount(index)):
                    if self.isRowHidden(r, index):
                        expand = True
                        break
                else:
                    expand = False
    
            self.setExpanded(index, expand)
            return expand
    
        def hasExpandArrowAtPos(self, pos):
            # if the mouse position is in the "arrow" rect, return the index
            index = self.indexAt(pos)
            if not index.isValid() or not self.model().hasChildren(index):
                return
            parent = index.parent()
            rootDecorated = self.rootIsDecorated()
            if not parent.isValid() and not rootDecorated:
                return
            indent = self.indentation()
            if rootDecorated:
                itemIndent = 0
            else:
                itemIndent = -indent
            while parent.isValid():
                itemIndent += indent
                parent = parent.parent()
    
            position = self.header().sectionViewportPosition(0)
            itemRect = self.visualRect(index)
            rect = QRect(position + itemIndent, itemRect.y(), 
                indent, itemRect.height())
    
            opt = QStyleOption()
            opt.initFrom(self)
            
            opt.rect = rect
            arrowRect = self.style().subElementRect(
                QStyle.SE_TreeViewDisclosureItem, opt, self)
            if arrowRect.contains(pos):
                return index
    
        def mousePressEvent(self, event):
            if event.button() == Qt.LeftButton:
                index = self.hasExpandArrowAtPos(event.pos())
                if index:
                    self.toggleExpanded(index)
                    self.setCurrentIndex(index)
                    return
            super().mousePressEvent(event)
    
    
    class TreeWindow(QMainWindow):
        # ...
        def fill_tree(self, widg, data) -> None:
            for key, val in data.items():
                # ...
                if keep_going:
                    self.fill_tree(tw_item, val)
                else:
                    tw_item.setCheckState(0, Qt.Checked)
    

    Note that the above implementation is not perfect.

    For instance:

    • it doesn't consider double clicking a parent
    • it doesn't properly work with keyboard navigation; if you want to implement that aspect, you must do it within a moveCursor override, consider the current index and viewport scroll position, eventually expand/collapse the parent or current item and return the new resulting index, otherwise just revert to the default implementation;
    • some internal functions and behaviors might not work as expected, such as direct calls to expand(), collapse(), expandToDepth() or expandRecursively();
    • as mentioned above, it's not a good choice from the UX perspective: it's not really intuitive, and the parent/grandparent inconsistency might be very confusing to the user;

    Frankly, while I can see the need for such a behavior, I would not really suggest it. The UX aspect is what most worries me, because it's not intuitive: if I want to collapse an item, I don't expect it to still show its children.

    A better solution, instead, would be to use a proxy model and a specifically dedicated UI element to toggle the visible state that would filter the defined items: it would then just be a matter of calling invalidateFilter() and implementing filterAcceptsRow() using a common function that checks whether an item (or any of its [grand]children) should be shown or not.