pythonpyside6qstyleditemdelegate

QStyledItemDelegate in QTableView is misaligned


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: Outcome

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()

Solution

  • 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:

    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: