Need PyQt QGroupBox to resize to match contained QTreeWidget

Question:

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_())

Asked By: elfnor

||

Answers:

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.

Answered By: musicamante
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.