Reputation: 57
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.
Upvotes: 1
Views: 815
Reputation: 244132
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_())
Upvotes: 1