Search code examples
qtpyqtdelegatesqtreewidgetqtreewidgetitem

Why are checkboxes showing through in qt (pyqt) QStyledItemDelegate for TreeWidget


I am trying to build a custom TreeWidget using a QStyledItemDelegate to draw custom checkboxes. Everything is working fine, except when I resize the TreeWidget columns. As you'll see below, when the "Age" column is moved all the way to the left, the "Name" checkbox from the first child item 'shows through' (even though all the text is properly elided and hidden).

Can anyone suggest why this is happening?

I've tried setting a size hint for the QStyledItemDelegate but this has no effect. Here is a minimum reproducible example:

import sys
from PyQt5 import QtCore, QtWidgets


class CustomTreeWidgetDelegate(QtWidgets.QStyledItemDelegate):
    def __init__(self, parent=None) -> None:
        super().__init__(parent)

    def paint(self, painter, option, index):

        options = QtWidgets.QStyleOptionViewItem(option)
        self.initStyleOption(options, index)

        if options.widget:
            style = option.widget.style()
        else:
            style = QtWidgets.QApplication.style()

        # lets only draw checkboxes for col 0
        if index.column() == 0:

            item_options = QtWidgets.QStyleOptionButton()
            item_options.initFrom(options.widget)

            if options.checkState == QtCore.Qt.Checked:
                item_options.state = (
                    QtWidgets.QStyle.State_On | QtWidgets.QStyle.State_Enabled
                )
            else:
                item_options.state = (
                    QtWidgets.QStyle.State_Off | QtWidgets.QStyle.State_Enabled
                )

            item_options.rect = style.subElementRect(
                QtWidgets.QStyle.SE_ViewItemCheckIndicator, options
            )

            QtWidgets.QApplication.style().drawControl(
                QtWidgets.QStyle.CE_CheckBox, item_options, painter
            )

        if index.data(QtCore.Qt.DisplayRole):

            rect = style.subElementRect(QtWidgets.QStyle.SE_ItemViewItemText, options)
            painter.drawText(
                rect,
                QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
                options.fontMetrics.elidedText(
                    options.text, QtCore.Qt.ElideRight, rect.width()
                ),
            )


if __name__ == "__main__":

    class MyTree(QtWidgets.QTreeWidget):
        def __init__(self):
            super().__init__()

            self.setItemDelegate(CustomTreeWidgetDelegate())

            header = self.header()

            head = self.headerItem()
            head.setText(0, "Name")
            head.setText(1, "Age")

            parent = QtWidgets.QTreeWidgetItem(self)
            parent.setCheckState(0, QtCore.Qt.Unchecked)
            parent.setText(0, "Jack Smith")
            parent.setText(1, "30")

            child = QtWidgets.QTreeWidgetItem(parent)
            child.setCheckState(0, QtCore.Qt.Checked)
            child.setText(0, "Jane Smith")
            child.setText(1, "10")
            self.expandAll()

    # create pyqt5 app
    App = QtWidgets.QApplication(sys.argv)

    # create the instance of our Window
    myTree = MyTree()
    myTree.show()

    # start the app
    sys.exit(App.exec())

Treewidget before column resize

enter image description here

Treewidget after column resize

enter image description here


Solution

  • Unlike widget painting (which is always clipped to the widget geometry), delegate painting is not restricted to the item bounding rect.

    This allows to theoretically paint outside the item rect, for instance to display "expanded" decorations around items, but it's usually discouraged since the painting order is not guaranteed and might result in some graphical artifacts.

    The solution is to always clip the painter to the option rect, which should always happen within a saved painter state:

    class CustomTreeWidgetDelegate(QtWidgets.QStyledItemDelegate):
        def paint(self, painter, option, index):
            painter.save()
            painter.setClipRect(option.rect)
    
            # ...
    
            painter.restore()
    

    Note that:

    • the two if options.ViewItemFeature checks are useless, since those are constants (and being they greater than 0 they will always be "truthy ");
    • you should always draw the "base" of the item in order to consistently show its selected/hovered state;
    • while you stated that you want to draw a custom checkbox, be aware that you should always consider the state of the item (i.e. if it's selected or disabled);
    • the above is also valid for drawing the item text: most importantly, selected items have a different color, as it must have enough contrast against the selection background, so it's normally better to use QStyle drawItemText();

    Considering the above, here's a revised version of your delegate:

    class CustomTreeWidgetDelegate(QtWidgets.QStyledItemDelegate):
        def paint(self, painter, option, index):
    
            painter.save()
            painter.setClipRect(option.rect)
    
            option = QtWidgets.QStyleOptionViewItem(option)
            self.initStyleOption(option, index)
    
            widget = option.widget
            if widget:
                style = widget.style()
            else:
                style = QtWidgets.QApplication.style()
    
            # draw item base, including hover/selected highlighting
            style.drawPrimitive(
                style.PE_PanelItemViewItem, option, painter, widget
            )
    
            if option.features & option.HasCheckIndicator:
    
                item_option = QtWidgets.QStyleOptionButton()
                if widget:
                    item_option.initFrom(widget)
    
                item_option.rect = style.subElementRect(
                    QtWidgets.QStyle.SE_ViewItemCheckIndicator, option
                )
    
                item_option.state = option.state
                # disable focus appearance
                item_option.state &= ~QtWidgets.QStyle.State_HasFocus
                if option.checkState == QtCore.Qt.Checked:
                    item_option.state |= QtWidgets.QStyle.State_On
                else:
                    item_option.state |= QtWidgets.QStyle.State_Off
    
                QtWidgets.QApplication.style().drawControl(
                    QtWidgets.QStyle.CE_CheckBox, item_option, painter
                )
    
            # "if index.data():" doesn't work if the value is a *numeric* zero
            if option.text:
    
                alignment = (
                    index.data(QtCore.Qt.TextAlignmentRole) 
                    or QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter
                )
    
                rect = style.subElementRect(
                    QtWidgets.QStyle.SE_ItemViewItemText, option, widget
                )
    
                margin = style.pixelMetric(
                    style.PM_FocusFrameHMargin, None, widget) + 1
                rect.adjust(margin, 0, -margin, 0)
    
                text = option.fontMetrics.elidedText(
                    option.text, QtCore.Qt.ElideRight, rect.width()
                )
    
                if option.state & style.State_Selected:
                    role = QtGui.QPalette.HighlightedText
                else:
                    role = QtGui.QPalette.Text
    
                style.drawItemText(painter, rect, 
                    alignment, option.palette, 
                    index.flags() & QtCore.Qt.ItemIsEnabled, 
                    text, role
                )
    
            painter.restore()