Search code examples
pythonpyqt5

Need PyQt QGroupBox to resize to match contained QTreeWidget


I have a QTreeWidget inside a QGroupBox. If the branches on the tree expand or collapse the QGroupBox should resize rather than show scrollbars. The QGroupBox is in a window with no layout manager as in the full application the user has the ability to drag and resize the GroupBox around the window.

The code below almost does this. I have subclassed QTreeWidget and set its size hint to follow that of the viewport (QAbstractScrollClass) it contains. The viewport sizehint does respond to the changes in the tree branch expansion unlike the tree sizehint. I've then subclassed QGroupBox to adjust its size to the sizehint in its init method.

This part all works. When the gui first comes up the box matches the size of the expanded branches of the tree. Changing the expanded state in code results in the correctly sized box.

enter image description here

I then connected the TreeWidget's signals for itemExpanded and itemCollapsed to a function that calls box.adjustSize(). This bit doesn't work. The sizehint for the box stays stubbornly at the size first set when the box was first shown regardless of the user toggling the branches.

I've looked at size policies etc, and have written a nasty hacks that will work in some situations, but I'd like to figure out how to do this properly.

In the real app the adjustSize will be done I expect with signals but I've simplified here.

import sys
from PyQt5.QtWidgets import (
    QApplication,
    QWidget,
    QGroupBox,
    QVBoxLayout,
    QTreeWidget,
    QTreeWidgetItem,
)

from PyQt5.QtCore import QSize


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

    def sizeHint(self):
        w = QTreeWidget.sizeHint(self).width()
        h = self.viewportSizeHint().height()
        new_size = QSize(w, h + 10)
        print(f"in tree size hint {new_size}")
        return new_size


class GroupBoxSize(QGroupBox):
    def __init__(self, title):
        super().__init__(title)
        print(f"box init {self.sizeHint()}")
        self.adjustSize()


def test(item):
    print(f"test sizehint {box.sizeHint()}")
    print(f"test viewport size hint {tw.viewportSizeHint()}")
    box.adjustSize()


app = QApplication(sys.argv)

win = QWidget()
win.setGeometry(100, 100, 400, 250)
win.setWindowTitle("No Layout Manager")

box = GroupBoxSize(win)
box.setTitle("fixed box")
box.move(10, 10)
layout = QVBoxLayout()
box.setLayout(layout)

l1 = QTreeWidgetItem(["String A"])
l2 = QTreeWidgetItem(["String B"])


for i in range(3):
    l1_child = QTreeWidgetItem(["Child A" + str(i)])
    l1.addChild(l1_child)

for j in range(2):
    l2_child = QTreeWidgetItem(["Child B" + str(j)])
    l2.addChild(l2_child)

tw = TreeWidgetSize()
tw.setColumnCount(1)
tw.setHeaderLabels(["Column 1"])
tw.addTopLevelItem(l1)
tw.addTopLevelItem(l2)

l1.setExpanded(False)
layout.addWidget(tw)

tw.itemExpanded.connect(test)
tw.itemCollapsed.connect(test)

win.show()

sys.exit(app.exec_())


Solution

  • The problem is related to the fact that "floating" widgets behave in a slightly different way. If they do have a layout manager set, calling updateGeometry() on any of the children or even changing their minimum/maximum size has practically no effect on them.

    In order to work around that you have to do find the "closest" parent widget that has a layout which manages the current widget.

    To do so, you need two recursive functions:

    • the main one will ensure that the parent (if any) has a layout manager;
    • another one will be eventually called to check if that layout actually manages the widget (or the parent);
    def widgetInLayout(widget, layout):
        for i in range(layout.count()):
            item = layout.itemAt(i)
            if item.widget() == widget:
                return True
            if item.layout() and widgetInLayout(widget, item.layout()):
                return True
        return False
    
    def topmostParentWithLayout(widget):
        parent = widget.parent()
        if not parent:
            return widget
        layout = parent.layout()
        if layout is None:
            return widget
        if not widgetInLayout(widget, layout):
            return widget
        return topmostParentWithLayout(parent)
    

    With the above, you can ensure that calling adjustSize() will be properly done on the correct widget.

    Then, what is left to do is to properly connect the signals of the tree widget, both for item expand/collapse and model changes. These signals will then call a function that will check the full extent of the visible items, starting from the first top level item, to the last expanded one. In order to do so, we can use the indexBelow() function of QTreeView (from which QTreeWidget inherits).

    The resulting value will be then used as a fixed height instead of a size hint. This could theoretically be done by implementing sizeHint() in a similar fashion and calling self.updateGeometry() with the related signals, but asking the parent to adjust its size becomes a bit more complex, and I'd suggest this simpler approach instead for this specific case.

    class TreeWidgetSize(QTreeWidget):
        _initialized = False
        def __init__(self, parent=None):
            super().__init__(parent=parent)
            self.model().rowsInserted.connect(self.updateHeight)
            self.model().rowsRemoved.connect(self.updateHeight)
            self.itemExpanded.connect(self.updateHeight)
            self.itemCollapsed.connect(self.updateHeight)
    
        def showEvent(self, event):
            super().showEvent(event)
            if not self._initialized:
                self._initialized = True
                self.updateHeight()
    
        def updateHeight(self):
            if not self._initialized:
                return
            height = self.frameWidth() * 2
            if self.header().isVisible():
                height += self.header().sizeHint().height()
    
            model = self.model()
            lastRow = model.rowCount() - 1
            if lastRow < 0:
                # just use the default size for a single item
                defaultSize = self.style().sizeFromContents(
                    QStyle.CT_ItemViewItem, QStyleOptionViewItem(), QSize(), self)
                height += max(self.fontMetrics().height(), defaultSize.height())
            else:
                first = model.index(0, 0)
                firstRect = self.visualRect(first)
                if firstRect.height() <= 0:
                    return
                last = model.index(lastRow, 0)
                while True:
                    below = self.indexBelow(last)
                    if not below.isValid():
                        break
                    last = below
                lastRect = self.visualRect(last)
                if lastRect.height() <= 0:
                    return
                height += lastRect.bottom() - firstRect.y() + 1
            self.setFixedHeight(height)
            topmostParentWithLayout(self).adjustSize()
    

    With the above, you can completely ignore any explicit update on the parent, as the tree widget will automatically take care of that.