1

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

elfnor
  • 469
  • 1
  • 5
  • 14
  • Note that your `title` argument is not properly named (and used): in your example code, the argument is actually a widget. Since overloads with variable arguments are not natively possible in Python (but [there are possible solutions](https://stackoverflow.com/q/6434482)), and QGroupBox has 2 possible `__init__` variations, I'd suggest you to just use the simple `*args, **kwargs` signature. Then you can directly do `box = GroupBoxSize("fixed box", win). – musicamante Dec 18 '22 at 02:07

1 Answers1

1

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.

musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Awesome, That works, and also allows me to put other stuff in the box with the tree, a requirement I left out of the original question. The client's app has approximately 70 of these boxes over 7 tabs, so having a way to do all the connecting up in the tree class is going to save a lot of work. Figuring out who sets the size in PyQt is still a mission for me. In the usual use cases it just works. Thanks. – elfnor Dec 18 '22 at 21:18
  • @elfnor You're welcome! Consider that if the box count is that big, maybe you should think about other alternatives, like using [QMdiArea](https://doc.qt.io/qt-5/qmdiarea.html) or the [Graphics View Framework](https://doc.qt.io/qt-5/graphicsview.html). In this way, the "window" management would be much easier, since widgets created in these contexts behave similarly to top level windows, with the benefit of having common window-like controls not only for moving or resizing, but also stacking management. – musicamante Dec 19 '22 at 01:06
  • Layout management is quite complex in any context that allows advanced size options, using recursive calls to each item in the widget tree (widgets and nested layouts) using the size hints (`sizeHint()`, `minimumSizeHint()` and minimum/maximum size constraints) that tell the "parent" layout how much space they need or could use, and [size policies](//doc.qt.io/qt-5/qsizepolicy.html) to decide how to use those hints within the available space, eventually change the geometry of all widgets that particular layout manages, and even change that layout requirements in turn for its parent layout. – musicamante Dec 19 '22 at 01:17