pythonpyqt5qt5

Stretchable QLinearGradient as BackgroundRole for resizeable QTableView cells in PyQt5?


Consider this example, where I want to apply a "vertical" background to all cells in the third column of the table:

import sys
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import (Qt, QPointF)
from PyQt5.QtGui import (QColor, QGradient, QLinearGradient, QBrush)
# starting point from https://www.pythonguis.com/tutorials/qtableview-modelviews-numpy-pandas/

class TableModel(QtCore.QAbstractTableModel):
    def __init__(self, data):
        super(TableModel, self).__init__()
        self._data = data
        self.bg_col1 = QColor("#A3A3FF")
        self.bg_col2 = QColor("#FFFFA3")
        self.bg_grad = QLinearGradient(QPointF(0.0, 0.0), QPointF(0.0, 1.0)) # setcolor 0 on top, 1 on bottom
        self.bg_grad.setCoordinateMode(QGradient.ObjectMode) #StretchToDeviceMode) #ObjectBoundingMode)
        self.bg_grad.setSpread(QGradient.PadSpread) #RepeatSpread) # PadSpread (default)
        self.bg_grad.setColorAt(0.0, self.bg_col1)
        self.bg_grad.setColorAt(1.0, self.bg_col2)
        self.bg_grad_brush = QBrush(self.bg_grad)
    #
    def data(self, index, role):
        if role == Qt.DisplayRole:
            # See below for the nested-list data structure.
            # .row() indexes into the outer list,
            # .column() indexes into the sub-list
            return self._data[index.row()][index.column()]
        if role == Qt.BackgroundRole:
            if index.column() == 2:
                return self.bg_grad_brush
    #
    def rowCount(self, index):
        # The length of the outer list.
        return len(self._data)
    #
    def columnCount(self, index):
        # The following takes the first sub-list, and returns
        # the length (only works if all rows are an equal length)
        return len(self._data[0])

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.table = QtWidgets.QTableView()
        data = [
          [4, 9, 2],
          [1, 0, 0],
          [3, 5, 0],
          [3, 3, 2],
          [7, 8, 9],
        ]
        self.model = TableModel(data)
        self.table.setModel(self.model)
        self.setCentralWidget(self.table)

app=QtWidgets.QApplication(sys.argv)
window=MainWindow()
window.show()
app.exec_()

When I run this code, first I get this drawing:

table first

Only the cell in the first row is with the gradient as I had imagined it to be; but all the others below it appear with flat colors.

I can notice, that if I resize the second row height, there is a gradient in the related cell there, but it is somehow wrong - it does not stretch proportionally to the bounds of the cell:

table second

If I afterwards resize the height of row 1, then I can see that the cell with background in that row "stretches" the background gradient according to my expectations - while in the meantime, the cell below it lost the gradient it showed previously:

table third

What can I do, so that all cells with a gradient BackgroundRole behave the same as the cell in the first rown (full gradient shown and stretched according to cell size), and do not "lose" their gradient rendering if another cell changes size?


Solution

  • The problem is caused by the fact that most QStyles use the default QCommonStyle implementation to draw the background of items, which calls QPainter.setBrushOrigin() based on the index rect before filling it with the given brush.

    Since the gradient is in "object mode", the gradient is still using the bounding rectangle of the given object (the item rectangle) as reference for its colors, but since the style changes the brush origin, the result is that the gradient is shifted.

    This may be more clear if you use the ScrollPerPixel vertical scroll mode for the view, or if you use the RepeatSpread, which shows that the "start" of the gradient changes depending on the top left rectangle of the item, and becomes "correct" only as long as the vertical origin of the item is exactly a multiple of its height.

    One possible work around is therefore to make a negative vertical translation to the brush so that when the style tries to adjust the painter it will actually be shown where it should, because the gradient is "shifted back" to its actual origin, relative to the item's rect.

    This can be easily achieved by using a delegate, but the correct translation depends on the coordinate mode of the gradient.

    If you use the (technically deprecated, but still valid even in Qt6) ObjectBoundingMode, you can just translate using the negative of the top left corner of the item rect:

    class TableModel(QtCore.QAbstractTableModel):
        def __init__(self, data):
            ...
            self.bg_grad.setCoordinateMode(QGradient.ObjectBoundingMode)
            ...
    
    
    class GradientDelegate(QStyledItemDelegate):
        def initStyleOption(self, opt, index):
            super().initStyleOption(opt, index)
            if (
                isinstance(opt.backgroundBrush, QBrush) 
                and opt.backgroundBrush.gradient()
                and (opt.rect.x() or opt.rect.y())
            ):
                opt.backgroundBrush.setTransform(
                    QTransform.fromTranslate(-opt.rect.x(), -opt.rect.y()))
    
    
    class MainWindow(QMainWindow):
        def __init__(self):
            ...
            self.table.setItemDelegateForColumn(2, GradientDelegate(self.table))
    

    If the more compliant ObjectMode is used instead, the translation also needs to consider the rectangle size, and this is because the brush transform is applied in object space (see the note about ObjectBoundingMode):

    class GradientDelegate(QStyledItemDelegate):
        def initStyleOption(self, opt, index):
            ... as above
    
                x, y, width, height = opt.rect.getRect()
                opt.backgroundBrush.setTransform(
                    QTransform.fromTranslate(-x / width, -y / height))
    

    Alternatively, you can just avoid transformations, by using the default LogicalMode (so, without calling setCoordinateMode()), and manually setting the coordinates of the gradient every time considering the logical size of the bounding rect. Obviously, this only works if the logical positions of the gradient stops are known (meaning that the implementation will be different for different gradient types or their intended coordinates): in the case at hand, since the gradient is from top to bottom, the start is at 0, 0, while the end is at 0, <rect height>.

    class GradientDelegate(QStyledItemDelegate):
        def initStyleOption(self, opt, index):
            super().initStyleOption(opt, index)
            if (
                isinstance(opt.backgroundBrush, QBrush) 
                and opt.backgroundBrush.gradient()
                and (opt.rect.x() or opt.rect.y())
            ):
                grad = opt.backgroundBrush.gradient()
                grad.setStart(QPointF())
                grad.setFinalStop(QPointF(0, opt.rect.height()))
    

    Note: for older PyQt versions, QBrush.gradient() returns a generic QGradient; in that case, you need to import sip and use grad = sip.cast(opt.backgroundBrush.gradient(), QLinearGradient) (if you're using that type) before being able to call its functions.