I want to show a list of files with star rating in a QTableView. For this I use the following delegate:
class StarRatingDelegate(QStyledItemDelegate):
def __init__(self, parent=None):
super().__init__(parent)
def paint(self, painter, option, index):
file: File = index.data(Qt.UserRole)
star_rating_widget = StarRatingWidget(10, self.parent())
star_rating_widget.set_rating(file.rating)
star_rating_widget.render(painter, option.rect.topLeft())
StarRatingWidget
Is a simple QWidget that contains 5 QLables in a QHBoxLayout.
This all works so far, but all StarRatingWidgets are shifted to the top left:
The first column shows the rating as number. You can see that all stars are shifted slightly to the left and a little bit more than one row height to the top.
Tests revealed that option.rect
returns the coordinates with (0, 0) being the top left corner of the first cell, but star_rating_widget.render
treats the coordinates with (0, 0) being the top left of the window. So the widgets are shifted by the space between the table and the window border and additionally by the height of the table header.
Before someone asks, here is the full code. It requires pyside6 to run.
#!/usr/bon/env python
from PySide6.QtCore import Qt, Signal, QAbstractItemModel, QModelIndex, QEvent
from PySide6.QtGui import QMouseEvent
from PySide6.QtWidgets import QApplication, QLabel, QTableView, QMainWindow, QSizePolicy, QHBoxLayout, QWidget, QStyledItemDelegate
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setGeometry(100, 100, 250, 600)
self.central_widget = QWidget()
self.main_layout = QHBoxLayout()
self.central_widget.setLayout(self.main_layout)
self.setCentralWidget(self.central_widget)
self.list = QTableView()
self.list.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self.list.setSelectionMode(QTableView.SelectionMode.SingleSelection)
self.list.horizontalHeader().setStretchLastSection = True
self.list.verticalHeader().hide()
self.list.show_grid = False
self.list.setItemDelegateForColumn(1, StarRatingDelegate(self.list))
self.list.setModel(ListModel())
self.main_layout.addWidget(self.list)
class ListModel(QAbstractItemModel):
def __init__(self):
super().__init__()
self.horizontal_header_labels = ['Number', 'Stars']
def rowCount(self, parent=QModelIndex()):
return 50
def columnCount(self, parent=QModelIndex()):
return len(self.horizontal_header_labels)
def data(self, index, role):
if not index.isValid():
return None
if role == Qt.DisplayRole:
rating = (index.row() - 2) % 7
return None if rating >= 5 else rating + 1
return None
def headerData(self, section, orientation, role):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
return self.horizontal_header_labels[section]
return None
def index(self, row, column, parent=QModelIndex()):
if self.hasIndex(row, column, parent):
return self.createIndex(row, column)
return QModelIndex()
def parent(self, index):
return QModelIndex()
class StarRatingWidget(QWidget):
rating_changed = Signal(int)
def __init__(self, font_size, parent=None):
super().__init__(parent)
self.rating = 0
self.hovered_star: int|None = None
self.stars: List[QLabel] = []
self.font_size: int = font_size
self.init_ui()
def star_mouse_event(self, i: int):
def event(event: QMouseEvent):
if event.type() == QEvent.Enter:
self.hovered_star = i
self.update()
elif event.type() == QEvent.Leave:
self.hovered_star = None
self.update()
return event
def init_ui(self):
layout = QHBoxLayout()
for i in range(5):
star = QLabel()
star.mousePressEvent = lambda _, i=i: self.set_rating(i + 1)
star.enterEvent = self.star_mouse_event(i)
star.leaveEvent = self.star_mouse_event(i)
star.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
layout.addWidget(star)
self.stars.append(star)
self.setLayout(layout)
self.update()
def set_rating(self, rating: int|None):
if rating != self.rating:
self.rating = rating
self.update()
self.rating_changed.emit(rating)
def update(self):
for i, star in enumerate(self.stars):
rating = self.rating if self.rating is not None else 0
if i < rating:
star.setText('★')
else:
star.setText('☆')
if self.rating is None:
color = 'gray'
weight = 'normal'
elif i == self.hovered_star:
color = 'blue'
weight = 'bold'
else:
color = 'yellow'
weight = 'normal'
star.setStyleSheet(f'font-size: {self.font_size}px; color: {color}; font-weight: {weight}')
class StarRatingDelegate(QStyledItemDelegate):
def __init__(self, parent=None):
super().__init__(parent)
def paint(self, painter, option, index):
rating = index.data()
star_rating_widget = StarRatingWidget(10, self.parent())
star_rating_widget.set_rating(rating)
star_rating_widget.render(painter, option.rect.topLeft())
def main():
app = QApplication([])
main_window = MainWindow()
main_window.show()
QApplication.exec()
if __name__ == '__main__':
main()
What you see is caused by unresolved bug, but there are also some important issues and misconceptions in your code that, if properly addressed, could easily avoid that problem to begin with, while improving other aspects as well.
The bug (QTBUG-26694), which is surprisingly old, is caused by the fact that the render()
function doesn't consider the deviceTransform()
set on the painter. When rendering widgets, the painter has an internal "device transform" that is relative to the origin point of the window, which is the top left corner.
Due to the above bug, that device transform is not considered within the render()
function, and therefore the widget is drawn relative to the window coordinate system.
A possible solution to this could be to explicitly translate the painter before rendering. Note that you didn't consider the item rectangle, and therefore you must also call resize()
before rendering:
def paint(self, painter, option, index):
rating = index.data()
star_rating_widget = StarRatingWidget(10, self.parent())
star_rating_widget.set_rating(rating)
star_rating_widget.resize(option.rect.size())
painter.save()
painter.translate(option.rect.topLeft())
star_rating_widget.render(painter, QPoint())
painter.restore()
That said, doing the above is wrong in principle, and for many reasons.
First of all, the paint()
function of a delegate is potentially called a lot, possibly hundreds of times in case the style also uses hover effects or simply by scrolling, meaning that for every call to paint()
you are creating a new StarRatingWidget that you're just using once.
This is inefficient and completely wrong. When your program is started I can see that paint
is being called more than 150 times (even though only about 20 items are visible), and it easily reaches 1000 calls just by moving the mouse for a few seconds.
Not only each of those widgets is created just for one rendering, but they're never destroyed. Keep your program running for a few minutes, and you'll end up with tens of thousands widgets that are simply wasting memory.
At the very least, you should choose one of the following two options:
star_rating_widget.deleteLater()
after rendering;__init__
), then update and render it when necessary;The second one is obviously more appropriate and effective:
class StarRatingDelegate(QStyledItemDelegate):
def __init__(self, parent=None):
super().__init__(parent)
self.star_rating_widget = StarRatingWidget(10, parent)
self.star_rating_widget.hide()
def paint(self, painter, option, index):
rating = index.data()
self.star_rating_widget.set_rating(rating)
painter.save()
painter.translate(option.rect.topLeft())
self.star_rating_widget.resize(option.rect.size())
self.star_rating_widget.render(painter, QPoint())
painter.restore()
Note though that using a custom widget for custom painting in a delegate is not really effective. You may believe that it would optimize rendering speed, but that's only partially true in some cases: in your situation, it's actually worse, because you're indirectly calling setStyleSheet()
every time the widget is going to be rendered, and that call results in lots of internal computations that are quite unnecessary if you just want to draw 5 stars, and resizing the widget also potentially invalidates the layout, causing further computations before rendering can actually happen.
Besides, your attempt in providing a hovering feature is completely useless, since the widget is statically rendered and does not receive any actual user event.
The only appropriate way to achieve what you want is by doing a full rendering of the stars within paint()
and override the editorEvent()
of the delegate, so that the stars are properly updated even when hovering.
Further notes:
setStretchLastSection
is a function, but you're overwriting that with True
, which is obviously wrong other than pointless;show_grid
does not originally exist in the QTableView API, and the correct call is self.list.setShowGrid(True)
; since that's also the default, it's pointless anyway; note that that specific line would work if the related features are enabled, but those "features" are often discouraged as they're inconsistent with the API and create unrelated issues, and, besides, many other functions used in your code would be broken anyway because they are using the standard syntax instead;update()
is a slot common to all QWidgets (and shared with other function overloads with the same name), overwriting that function name with another function is completely inappropriate;data()
and headerData()
expect that the role
defaults to Qt.ItemDataRole.DisplayRole
;index()
and parent()
;