Search code examples
pythonpyside2

QStyleItemDelegate with Custom Buttons


I have a QTreeWidget and I want to fully customize the way the items look by using a style delegate.

My main issue is that I would like to create a custom button, on the right of my Item, that allows me to collapse and expand the children of that item. The classical "+" button that can be usually found on the left side of most Trees.

I have no problem to paint the button itself, and change its icon depending if the item is expanded or not. The problem is to make it behave like a button ( Activate a command when is pressed, change color when hovered etc..)

What I came up with is to use the editorEvent to check if the mouse has been pressed on the same position as where I draw the button of the current item.

To obtain an hover effect, I edited the mouseMoveEvent of my tree and checked if the mouse is on top of a the button of the item, and if so, repaint the item with the hover on.

My implementation does the job, but I am concerned that I did it completely wrong, without being efficient and that my tree is going to be slow because of this kind of calculation. So I was wondering, if anyone had some suggestions on how to improve the code below.

The Delegate

class styleDelegate(QtWidgets.QStyledItemDelegate):


    def __init__(self, parent=None, treeWidget = None):
        super(styleDelegate, self).__init__(parent)
        self.tree = treeWidget      

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

        painter.save()
        rect = option.rect

        # set the pen to draw an outline around the item to divide them.
        pen = QPen()
        pen.setBrush(QtGui.QColor(43, 43, 43))
        pen.setWidthF(1)
        painter.setPen(pen)
        item = self.tree.itemFromIndex(index)

        # set the background color based on the item or if it is selected
        if option.state & QStyle.State_Selected:
            painter.setBrush(option.palette.highlight())
        else:
            color = item.color
            painter.setBrush(QtGui.QColor(color[0] * 255, color[1] * 255, color[2] * 255))

        #draw the colored background 
        painter.drawRect(rect)

        #draw the image
        imageScale = 0
        margin = 4
        imageScale = rect.height() - margin * 2 + 1
        painter.drawPixmap(rect.x() + margin, rect.y() + margin , imageScale, imageScale, item.image.scaled(imageScale, imageScale, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation))

        # draw the text 

        painter.setPen(QtGui.QColor(255, 255, 255))
        font = painter.font()
        font.setPointSize(9)
        painter.setFont(font)

        painter.drawText(rect.x() + imageScale + margin * 3, rect.y(), 100, item.scale, Qt.AlignVCenter, item.name)

        # draw the expander button only if the item has children
        if item.childCount():

            # choose the appropriate icon to draw depending on the state of the item.
            if item.isExpanded():
                path = "checked.png"
                if item.hover:
                    path = "checked_hover.png"
            else:
                path = "unchecked.png"
                if item.hover:
                    path = "unchecked_hover.png"
            image = QtGui.QPixmap.fromImage(QtGui.QImage(path))
            size = 20 

            # define the position of the expander button

            positionX = rect.x() + rect.width() - 20 
            positionY = rect.y() + item.scale / 2 - size/2

            painter.drawPixmap(positionX, positionY, size, size, image.scaled(size, size, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation))

            item.expanderStart = QPoint(positionX, positionY)
            item.expanderEnd = QPoint(positionX + 20, positionY + 20)

        painter.restore() 

   def editorEvent(self, event, model, option, index):


        # if an item is clicked, check if the click happened in the area whee the expander button is drawn.
        if event.type() == QEvent.MouseButtonPress:
            item = self.tree.itemFromIndex(index)

            rect = option.rect
            clickX = event.x()
            clickY = event.y()

            # set the expanded expanded if it was clicked
            if  clickX > x and clickX < x + w:
                if clickY > y and clickY < y + h:
                    item.setExpanded(not item.isExpanded())

The Tree

class myTree(QtWidgets.QTreeWidget):
    def __init__(self, parent):
        super(myTree, self).__init__(parent)
        self.setMouseTracking(True)

    def mouseMoveEvent(self, event)
        item = self.itemAt(event.pos())
        if item:
            if item.childCount():
                # get the current hovering state. if the item is already hovered, there is no need to repaint it. 
                hover = item.hover
                if (event.pos.x() > item.expanderStart.x()
                    and event.pos.x() < item.expanderEnd.x()
                    and event.pos.y() > item.expanderStart.y()
                    and event.pos.y() < item.expanderEnd.y())
                    item.hover = True
                else:
                    item.hover = False
                if item.hover != hover:
                    self.viewport().update(event.pos().x(), event.pos().y(), 20, 20)

I know this can be fully achieved without using delegates, by simply working with Stylesheets or assigning a widget to the Item. However, I didn't get to far with these methods, since I got several problems with them.

I spent loads of time trying to achieve the result I want without success. Maybe I get my Items to look close to what I Want, but never exactly as I imagine them.

The reason I am being so fussy on getting exactly the look I have in mind with delegates is that this QTreeWidget was once a QListWidget, implemented with stylesheets. Now that I am "updgrading" it to a Tree I don't want the user to even notice the difference, but i was not able to replicate the same exact look with sylesheets alone.

Pardon me if the code above has stupid mistake, I have tested the full version and it was working, and I just posted here the relevant stuff.


EDIT:

As requested, this is some code that (At least to me) produces the desired result. However, I wonder if this is the correct way of doing what im doing or not...

from PySide2.QtGui import *
from PySide2.QtCore import *
from PySide2.QtWidgets import *

class styleDelegate(QStyledItemDelegate):


    def __init__(self, parent=None, treeWidget = None):
        super(styleDelegate, self).__init__(parent)
        self.tree = treeWidget      

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

        painter.save()
        rect = option.rect

        # set the pen to draw an outline around the item to divide them.
        pen = QPen()
        pen.setBrush(QColor(43, 43, 43))
        pen.setWidthF(1)
        painter.setPen(pen)
        item = self.tree.itemFromIndex(index)

        # set the background color based on the item or if it is selected
        if option.state & QStyle.State_Selected:
            painter.setBrush(option.palette.highlight())
        else:
            color = item.color
            painter.setBrush(QColor(color[0], color[1], color[2]))

        #draw the colored background 
        painter.drawRect(rect)

        #draw the image
        margin = 4
        imageScale = rect.height() - margin * 2 + 1
        painter.drawPixmap(rect.x() + margin, rect.y() + margin , imageScale, imageScale, item.image.scaled(imageScale, imageScale, Qt.KeepAspectRatio, Qt.SmoothTransformation))

        # draw the text 

        painter.setPen(QColor(255, 255, 255))
        font = painter.font()
        font.setPointSize(9)
        painter.setFont(font)

        painter.drawText(rect.x() + imageScale + margin * 3, rect.y(), 300, item.scale, Qt.AlignLeft|Qt.AlignVCenter, item.name)

        # draw the expander button only if the item has children
        if item.childCount():

            # choose the appropriate icon to draw depending on the state of the item.
            if item.isExpanded():
                path = "c:\\test.png"
                if item.hover:
                    path = "c:\\test.png"
            else:
                path = "c:\\test.png"
                if item.hover:
                    path = "c:\\test.png"
            image = QPixmap.fromImage(QImage(path))
            size = self.tree.expanderSize

            # define the position of the expander button

            positionX = rect.x() + rect.width() - size - 10
            positionY = rect.y() + item.scale / 2 - size/2

            painter.drawPixmap(positionX, positionY, size, size, image.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation))

            item.expanderStart = QPoint(positionX, positionY)
            item.expanderEnd = QPoint(positionX + size, positionY + size)

        painter.restore() 

    def editorEvent(self, event, model, option, index):


        # if an item is clicked, check if the click happened in the area whee the expander button is drawn.
        if event.type() == QEvent.MouseButtonPress:
            item = self.tree.itemFromIndex(index)
            if item.childCount():
                rect = option.rect
                clickX = event.x()
                clickY = event.y()
                size = self.tree.expanderSize
                # this is the rect of the expander button
                x = rect.x() + rect.width() - 20 
                y = rect.y() + item.scale / 2 - size/2
                w = size # expander width
                h = size # expander height
                # set the expanded expanded if it was clicked
                if (clickX > item.expanderStart.x()
                    and clickX < item.expanderEnd.x()
                    and clickY > item.expanderStart.y()
                    and clickY < item.expanderEnd.y()):
                    print "expand"
                    item.setExpanded(not item.isExpanded())


class myTree(QTreeWidget):
    def __init__(self, parent = None):
        super(myTree, self).__init__(parent)
        self.setMouseTracking(True)
        self.setHeaderHidden(True)
        self.setRootIsDecorated(False)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
    def mouseMoveEvent(self, event):
        item = self.itemAt(event.pos())
        if item:
            if item.childCount():
                # get the current hovering state. if the item is already hovered, there is no need to repaint it. 
                hover = item.hover
                if (event.pos() .x() > item.expanderStart.x()
                    and event.pos() .x() < item.expanderEnd.x()
                    and event.pos() .y() > item.expanderStart.y()
                    and event.pos() .y() < item.expanderEnd.y()):
                    item.hover = True
                else:
                    item.hover = False
                if item.hover != hover:
                    self.viewport().update(event.pos().x(), event.pos().y(), 20, 20)     
                    print "Hover", item.hover  
    def closeEvent(self, event):
        self.deleteLater()

def generateTree():
    tree = myTree()
    tree.setGeometry(500, 500, 1000, 500)
    tree.expanderSize = 50
    delegate = styleDelegate(tree, treeWidget = tree)
    tree.setItemDelegate(delegate)

    for object in ["Aaaaaaa", "Bbbbbbb", "Ccccccc"]:
        item = QTreeWidgetItem()
        item.name = object
        item.image = QPixmap.fromImage(QImage("c:\\test.png"))
        item.color = [150, 150, 150]
        item.hover = False
        item.scale = 100
        tree.addTopLevelItem(item)
        item.setSizeHint(0, QSize(item.scale, item.scale ))
        for child in ["Eeeeee", "Fffffff"]:
            childItem = QTreeWidgetItem()
            childItem.name = child
            childItem.image = QPixmap.fromImage(QImage("c:\\test.png"))
            childItem.color = [150, 150, 150]
            childItem.scale = 90

            item.addChild(childItem)
            childItem.setSizeHint(0, QSize(childItem.scale, childItem.scale))
    return tree

tree = generateTree()    
tree.show()

Note that my monitor is 4k and I quickly Hardcoded most of the sizes, so out of the box this code will produce much bigger widgets on a HD monitor.


Solution

  • Your code has the following errors:

    • It is not necessary to use QPixmap.fromImage(QImage(path)), you can create a QPixmap directly with the path: QPixmap(path)

    • If they are the same images, it is better to load it once and reuse it, for example in my solution I do it for the buttons QPixmap.

    • Do not create dynamic attributes because it generates code coupling, in the case of items you must use the roles.

    • To know if an item is expanded or you should not use QStyle::State_Open so we avoid coupling and the delegate can be used by other views without making many changes.

    • Use QRect to delimit a rectangle and so for example you use contains to see if a point is inside the rectangle.

    The above are the main observations, in the following part is the solution:

    from PySide2 import QtCore, QtGui, QtWidgets
    from enum import Enum  
    
    ScaleRole= QtCore.Qt.UserRole + 1
    expanderSize = 50
    
    
    class TreeDelegate(QtWidgets.QStyledItemDelegate):
        def __init__(self, parent=None):
            super(TreeDelegate, self).__init__(parent)
    
            self.pixmap_collapsed =  QtGui.QPixmap("collapsed.png")
            self.pixmap_collapsed_hover = QtGui.QPixmap("collapsed_hover.png")
            self.pixmap_expanded = QtGui.QPixmap("expanded.png")
            self.pixmap_expanded_hover = QtGui.QPixmap("expanded_hover.png")
    
        def paint(self, painter, option, index):
            image = index.data(QtCore.Qt.DecorationRole)
            scale = index.data(ScaleRole)
            name = index.data()
    
            painter.save()
            rect = option.rect
            painter.setPen(QtGui.QPen(brush=QtGui.QColor(43, 43, 43), widthF=1))
            if option.state & QtWidgets.QStyle.State_Selected:
                painter.setBrush(option.palette.highlight())
            else:
                painter.setBrush(index.data(QtCore.Qt.BackgroundRole))
            painter.drawRect(rect)
    
            margin = 4
            image_scale = (rect.height() - margin * 2 + 1)*QtCore.QSize(1, 1)
            if image is not None and not image.isNull():
                image = image.scaled(image_scale, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
                painter.drawPixmap(rect.topLeft() + margin*QtCore.QPoint(1, 1), image)
    
            painter.setPen(QtGui.QColor(255, 255, 255))
            font = painter.font()
            font.setPointSize(9)
            painter.setFont(font)
            painter.drawText(QtCore.QRect(rect.topLeft() + QtCore.QPoint(image_scale.width() + 3*margin, 0) , QtCore.QSize(300, scale)), 
                QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, name)
    
            if index.model().hasChildren(index):
                pixmap = self.pixmap_collapsed
                if option.state & QtWidgets.QStyle.State_Open:
                    if option.state & QtWidgets.QStyle.State_MouseOver:
                        pixmap = self.pixmap_expanded_hover
                    else:
                        pixmap = self.pixmap_expanded
                else :
                    if option.state & QtWidgets.QStyle.State_MouseOver:
                        pixmap = self.pixmap_collapsed_hover
                size = expanderSize
                pixmap = pixmap.scaled(size*QtCore.QSize(1, 1), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
                pos = rect.topRight() - QtCore.QPoint(size+10, (size-scale)/2)
                painter.drawPixmap(pos, pixmap)
            painter.restore()
    
    
    class MyTreeItem(QtWidgets.QTreeWidgetItem):
        def __init__(self, name, image, color, scale):
            super(MyTreeItem, self).__init__([name])
            self.setData(0, ScaleRole, scale)
            self.setData(0, QtCore.Qt.BackgroundRole, color)
            self.setData(0, QtCore.Qt.DecorationRole, image)
    
    
    class MyTree(QtWidgets.QTreeWidget):
        def __init__(self, parent=None):
            super(MyTree, self).__init__(parent)
            self.setMouseTracking(True)
            self.setHeaderHidden(True)
            self.setRootIsDecorated(False)
            self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
    
        def mousePressEvent(self, event):
    
            if not self.itemsExpandable(): return
            index = self.indexAt(event.pos())
            if not index.isValid(): return
            # restore state
            is_expanded = self.isExpanded(index)
            QtWidgets.QAbstractItemView.mousePressEvent(self, event)
            self.setExpanded(index, is_expanded)
    
            if not self.model().hasChildren(index): return
            rect = self.visualRect(index)
            size = expanderSize
            scale = index.data(ScaleRole)
            pos = rect.topRight() - QtCore.QPoint(size+10, (size-scale)/2)
            r = QtCore.QRect(pos, size*QtCore.QSize(1, 1))
            if r.contains(event.pos()):
                self.setExpanded(index, not self.isExpanded(index))
    
    
    def generate_tree():
        tree = MyTree()
        scale = 100
        delegate = TreeDelegate(tree)
        tree.setItemDelegate(delegate)
    
        for text in ["Aaaaaaa", "Bbbbbbb", "Ccccccc"]:
            item = MyTreeItem(text, QtGui.QPixmap("image.png"), QtGui.QColor(150, 150, 150), scale)
            item.setSizeHint(0, QtCore.QSize(scale, scale))
            tree.addTopLevelItem(item)
            for child in ["Eeeeee", "Fffffff"]:
                childItem = MyTreeItem(child, QtGui.QPixmap("image.png"), QtGui.QColor(150, 150, 150), scale)
                childItem.setSizeHint(0, QtCore.QSize(scale, scale))
                item.addChild(childItem)
        return tree
    
    
    if __name__ == '__main__':
        import sys
    
        app = QtWidgets.QApplication(sys.argv)
        tree = generate_tree()    
        tree.show()
        sys.exit(app.exec_())