QTreeWidget clear empty space at the bottom of widget

Question:

Is this example i have a QTreeWidget with 4 columns. The last column is filled by QFrames.

File ui.py

from PyQt5 import QtCore, QtGui, QtWidgets
import sys

if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    app.setStyle("Windows")
    treeWidget = QtWidgets.QTreeWidget()
    treeWidget.headerItem().setText(0, "Α/Α")
    treeWidget.headerItem().setText(1,"Τύπος")
    treeWidget.headerItem().setText(2,"Τίτλος")
    treeWidget.headerItem().setText(3,"Προεπισκόπιση")
    treeWidget.setStyleSheet("QTreeWidget::item{height:60px;}")
    l = []
    for i in range(0,30):
        l.append(QtWidgets.QTreeWidgetItem(["1","1","1","1"]))
    treeWidget.addTopLevelItems(l)  # add everything to the tree
    
    treeWidget.show()
    
    right_height = treeWidget.header().height()
    for el in l:
        right_height += treeWidget.visualItemRect(el).height()
    print(right_height)
    sys.exit(app.exec_())

Output (after scrolling to the bottom of QTreeWidget):

enter image description here

The desired total height of ScrollArea (inside QTreeWidget) is 1823 and it’s calculated as the sum of header height and height of each line.

As you can see there is empty space after last row in QTreeWidget. This problem doesn’t appear after resizing QDialog manually.

Edit: This may be usefull.

Asked By: Chris P

||

Answers:

After checking the code for QTreeWidget and inherited/related classes (QTreeView, QAbstractItemView, QAbstractScrollArea and QWidget, but also QAbstractSlider, used for the scroll bars), it seems clear that QTreeView does not respect the behavior shown in QTableView, which automatically scrolls the view to the bottom (without any further margin) whenever the scroll bar reaches the maximum.[1]

Note that this only happens when the (default) verticalScrollMode property is set to ScrollPerItem. For obvious reasons, whenever it is set to ScrollPerPixel, the scroll bar/area will only extend to the visible area of the viewport.

Unfortunately, the laying out of items (and related function results) of QTreeView is based on this aspect, meaning that we cannot try to just paint the tree (by overriding drawTree() and translating the painter), because in that case painting would be only partially consistent, but the behavior will not. For instance, when hovering or using drag&drop.

The above is most probably caused by optimization reasons: there is no way of knowing the whole extent of a tree, and, unless the uniformRowHeights property is True and all items actually have the same heights (which is clearly not your case), the view should always compute again the geometries of each items; while that could be feasible for a table (2d) model, that becomes quite unreasonable for an undefinite tree (3d) model, as it could theoretically block the view updates. At least, based on the default implementation of QTreeView.

There is a possibility, though: completely override the behavior of the scroll bar, and as long as you know that your model has a known and relatively limited extent.

By default, when ScrollPerItem is active, the scroll bar will always have a range that is equal to total_item_count - visible_item_count: if the viewport has x items and it can currently show y items (with y > x) in its viewport, the scroll bar maximum will be y - x (eg: with 10 visible items, if the viewport can only fully show 9, the maximum will be 1).

When the ScrollPerPixel mode is set instead, the extent will always be the maximum pixel height minus the viewport pixel size. Which means that we can know if the top left item is fully shown or not.

Now, the following requires a bit of trickery and ingenuity.

We need to consider the following aspects:

  • QScrollBar (based on QAbstractSlider) provides an actionTriggered signal that tells us whenever the user tries to manually change the value using the arrow buttons or by clicking on the "sub/add" page areas (the space within the "groove" that is not covered by the slider handle);
  • QAbstractItemView internally installs an event filter on the scroll bars, and connects to its valueChanged signals;
  • bonus: any well designed QObject will update its property (and emit its related changed signal) only when the new value is different from the current one, so we can normally be sure that trying to set the scroll bar value to the same one won’t trigger anything;

Considering the above, we could implement a few functions in a subclass and connect them (directly or not) to user generated signals and events. The only catch is that we must use the ScrollPerPixel scroll mode for the vertical scroll bar, which will result in a slightly inconsistent display of the scroll bar handle size.

Well, we can live with that.

Here is a possible implementation that considers the above aspects:

class TreeScrollFix(QTreeWidget):
    _ignoreScrollBarChange = False
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.verticalScrollBar().actionTriggered.connect(self.vbarTriggered)
        self.verticalScrollBar().valueChanged.connect(self.fixVBarValue)
        self.setVerticalScrollMode(self.ScrollPerPixel)

    def vbarTriggered(self, action):
        if action in (
            QAbstractSlider.SliderNoAction, 
            QAbstractSlider.SliderToMinimum, 
            QAbstractSlider.SliderToMaximum, 
            QAbstractSlider.SliderMove, 
        ):
            # we can safely ignore the above, eventually relying on the
            # fixVBarValue function
            return
        if action in (
            QAbstractSlider.SliderSingleStepAdd, 
            QAbstractSlider.SliderSingleStepSub
        ):
            delta = 1
        else:
            delta = QApplication.wheelScrollLines()
            if not delta:
                # this should not happen...
                return

        if action in (
            QAbstractSlider.SliderSingleStepAdd, 
            QAbstractSlider.SliderPageStepAdd
        ):
            func = self.indexBelow
        else:
            func = self.indexAbove
            if self.verticalScrollBar().value() == self.verticalScrollBar().maximum():
                delta -= 1

        index = self.indexAt(QPoint(0, 1)) # note the extra pixel

        while delta:
            newIndex = func(index)
            if not newIndex.isValid():
                break
            index = newIndex
            delta -= 1
        self.scrollTo(index, self.PositionAtTop)

    def fixVBarValue(self, value):
        vbar = self.verticalScrollBar()
        if not value or vbar.maximum() == value:
            return
        topLeftIndex = self.indexAt(QPoint(0, 0))
        topLeftRect = self.visualRect(topLeftIndex)
        # adjust the theoretical value to the actual y of the item (which is
        # a negative one)
        value += topLeftRect.y()

        showTop = topLeftRect.center().y() > 0
        if not showTop:
            # the item currently shown on the top left is not fully shown, and 
            # the visible height is less than half of its height;
            # let's show the next one instead by adding that item's height
            value += topLeftRect.height()

        if value != vbar.value():
            vbar.setValue(value)

    def eventFilter(self, obj, event):
        if event.type() == event.Wheel and obj == self.verticalScrollBar():
            delta = event.angleDelta().y()
            if delta: # delta != 0 -> no vertical scrolling
                # "synthesize" the event by explicitly calling the custom 
                # vbarTriggered function just as it would be normally called;
                # note that this is a real workaround that will never work with 
                # normal implicit or explicit event handling, which means that 
                # QApplication.postEvent and QApplication.sendEvent might be
                # potentially ignored by this if another event filter exists.
                self.vbarTriggered(
                    QAbstractSlider.SliderPageStepSub if delta > 1
                    else QAbstractSlider.SliderPageStepAdd
                )
            # the event has been handled, do not let the scroll bar handle it.
            return True
        return super().eventFilter(obj, event)

    def scrollTo(self, index, hint=QAbstractItemView.EnsureVisible):
        if hint in (self.PositionAtTop, self.PositionAtTop):
            if hint == self.PositionAtBottom:
                self._ignoreScrollBarChange = True
            super().scrollTo(index, hint)
            self._ignoreScrollBarChange = False
            return

        itemRect = self.visualRect(index)
        viewRect = self.viewport().rect()

        if hint == self.EnsureVisible and itemRect.y() < viewRect.y():
            super().scrollTo(index, self.PositionAtTop)
            return

        vbar = self.verticalScrollBar()
        if not self.indexBelow(index).isValid():
            # last item
            vbar.setValue(vbar.maximum())
            return

        self._ignoreScrollBarChange = True
        if hint == self.PositionAtCenter:
            super().scrollTo(index, self.PositionAtCenter)
        elif itemRect.bottom() > viewRect.bottom():
            super().scrollTo(index, self.PositionAtBottom)

        topLeftIndex = self.indexAt(QPoint(0, 0))
        topLeftRect = self.visualRect(topLeftIndex)
        if topLeftRect.y() < 0:
            delta = topLeftRect.height() + topLeftRect.y()
            vbar.setValue(vbar.value() + delta)

        self._ignoreScrollBarChange = False

And an example code to test it:

from random import randrange
from PyQt5.QtCore import *
from PyQt5.QtWidgets import *

class TreeScrollFix(QTreeWidget):
    # as above...

app = QApplication([])
treeWidget = TreeScrollFix()
treeWidget.setColumnCount(2)
for i in range(1, 31):
    topLevel = QTreeWidgetItem(treeWidget, ["top item {}".format(i)])
    for j in range(randrange(5)):
        child = QTreeWidgetItem(topLevel, 
            ['', topLevel.text(0)])
        # a random vertical size hint
        hint = QSize(100, randrange(30, 80))
        child.setSizeHint(1, hint)
        child.setText(0, 'height: {}'.format(hint.height()))

treeWidget.header().setSectionResizeMode(QHeaderView.ResizeToContents)

# expand top level indexes randomly
for i in range(randrange(5, treeWidget.topLevelItemCount())):
    topIndex = randrange(treeWidget.topLevelItemCount())
    treeWidget.setExpanded(treeWidget.model().index(topIndex, 0), True)

treeWidget.setStyleSheet('''
    QTreeView::item {
        border: 1px solid palette(highlight);
    }
    QTreeView::item:selected {
        border-color: red;
        background: palette(highlight);
        color: palette(highlighted-text);
    }
''')

treeWidget.resize(app.primaryScreen().size() * 2 / 3)
treeWidget.show()

app.exec_()

Note that I added an override for scrollTo(), which is always called when using keyboard navigation. Normally, the item view takes care of the top alignment when ScrollPerItem is active, but in our case the pixel scrolling could create some issues for items that do not have uniform row heights, and when scrolling to the bottom. The override takes care of that depending on the hint argument of that function, so that whenever scrolling won’t show the top item in full, it automatically scrolls down to show the next item on top, otherwise it will just scroll to the bottom for the last available, not expaned item. To avoid unnecessary calls, I also used a _ignoreScrollBarChange flag that will make ignore any further and unnecessary computing in fixVBarValue(). This will also work for the internally delayed call to scrollTo() that happens when selecting any item.

Be aware that I’ve done some testing and it should work as expected. Unfortunately, QAbstractItemView and QTreeView use delayed item layout management, and I cannot completely be sure about these aspects. At least in one case in dozens, I got a UI freeze, but I was not able to reproduce the issue (which might have been caused by external causes). I strongly advice you to take your time to check the code above, the documentation and the Qt sources, and consider using some carefully thought test suite.

Also, for obvious reasons, if you want to use a custom QScrollBar, you’d need to properly disconnect the previous functions and connect them again to the new one.

[1] I am not sure, but it is probably related to a comment in the QTreeView code (near line 3500), which says: optimize (maybe do like QHeaderView by letting items have startposition); see the official sources or the KDAB code browser

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.