Get a grip of row heights in QTableView with HTML rendering

Question:

Here’s an MRE:

import sys
from PyQt5 import QtWidgets, QtCore, QtGui

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('Get a grip of table view row height MRE')
        self.setGeometry(QtCore.QRect(100, 100, 1000, 800))
        self.table_view = SegmentsTableView(self)
        self.setCentralWidget(self.table_view)
        # self.table_view.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Fixed)
        rows = [
         ['one potatoe two potatoe', 'one potatoe two potatoe'],
         ['Sed ut perspiciatis, unde omnis iste natus error sit voluptatem accusantium doloremque',
          'Sed ut <b>perspiciatis, unde omnis <i>iste natus</b> error sit voluptatem</i> accusantium doloremque'],
         ['Nemo enim ipsam voluptatem, quia voluptas sit, aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos, qui ratione voluptatem sequi nesciunt, neque porro quisquam est, qui do lorem ipsum, quia dolor sit amet consectetur adipiscing velit, sed quia non numquam do eius modi tempora incididunt, ut labore et dolore magnam aliquam quaerat voluptatem.',
          'Nemo enim ipsam <i>voluptatem, quia voluptas sit, <b>aspernatur aut odit aut fugit, <u>sed quia</i> consequuntur</u> magni dolores eos</b>, qui ratione voluptatem sequi nesciunt, neque porro quisquam est, qui do lorem ipsum, quia dolor sit amet consectetur adipiscing velit, sed quia non numquam do eius modi tempora incididunt, ut labore et dolore magnam aliquam quaerat voluptatem.'
          ],
         ['Ut enim ad minima veniam',
          'Ut enim ad minima veniam'],
         ['Quis autem vel eum iure reprehenderit',
          'Quis autem vel eum iure reprehenderit'],
         ['At vero eos et accusamus et iusto odio dignissimos ducimus, qui blanditiis praesentium voluptatum deleniti atque corrupti, quos dolores et quas molestias excepturi sint, obcaecati cupiditate non provident, similique sunt in culpa, qui officia deserunt mollitia animi, id est laborum et dolorum fuga.',
          'At vero eos et accusamus et iusto odio dignissimos ducimus, qui blanditiis praesentium voluptatum deleniti atque corrupti, quos dolores et quas molestias excepturi sint, obcaecati cupiditate non provident, similique sunt in culpa, qui officia deserunt mollitia animi, id est laborum et dolorum fuga.'
         ]]
        # if the column widths are set before populating the table they seem to be ignored
        # self.table_view.setColumnWidth(0, 400)
        # self.table_view.setColumnWidth(1, 400)
        
        for n_row, row in enumerate(rows):
            self.table_view.model().insertRow(n_row)
            self.table_view.model().setItem(n_row, 0, QtGui.QStandardItem(row[0]))
            self.table_view.model().setItem(n_row, 1, QtGui.QStandardItem(row[1]))
        self.table_view.resizeRowsToContents()
        self.table_view.setColumnWidth(0, 400)
        self.table_view.setColumnWidth(1, 400)
        # if you try to resize the rows after setting the column widths the columns stay 
        # the desired width but completely wrong height ... and yet the point size and option.rect.width in 
        # delegate .paint() and .sizeHint() seem correct
        print('A') # this printout is followed by multiple paints and sizeHints showing that repainting occurs 
        # when the following line is uncommented 
        # self.table_view.resizeRowsToContents()
        
class SegmentsTableView(QtWidgets.QTableView):
    def __init__(self, parent):
        super().__init__(parent)
        self.setItemDelegate(SegmentsTableViewDelegate(self))
        self.setModel(QtGui.QStandardItemModel())
        v_header =  self.verticalHeader()
        # none of the below seems to have any effect:
        v_header.setMinimumSectionSize(5)
        v_header.sectionResizeMode(QtWidgets.QHeaderView.Fixed)
        v_header.setDefaultSectionSize(5)
        
class SegmentsTableViewDelegate(QtWidgets.QStyledItemDelegate):
    def paint(self, painter, option, index):
        doc = QtGui.QTextDocument()
        doc.setDocumentMargin(0)
        print(f'option.font.pointSize {option.font.pointSize()}')
        doc.setDefaultFont(option.font)
        self.initStyleOption(option, index)
        painter.save()
        doc.setTextWidth(option.rect.width())                
        doc.setHtml(option.text)
        option.text = ""
        option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter)
        painter.translate(option.rect.left(), option.rect.top())
        clip = QtCore.QRectF(0, 0, option.rect.width(), option.rect.height())
        print(f'row {index.row()} option.rect.width() {option.rect.width()}')
        print(f'... option.rect.height() {option.rect.height()}')
        
        # has a wild effect: rows gradually shrink to nothing as successive paints continue!
        # self.parent().verticalHeader().resizeSection(index.row(), option.rect.height())
        
        painter.setClipRect(clip)
        ctx = QtGui.QAbstractTextDocumentLayout.PaintContext()
        ctx.clip = clip
        doc.documentLayout().draw(painter, ctx)
        painter.restore()
            
    def sizeHint(self, option, index):
        self.initStyleOption(option, index)
        doc = QtGui.QTextDocument()
        # this indicates a problem: columns option.rect.width is too narrow... e.g. 124 pixels: why?
        print(f'sizeHint: row {index.row()} option.rect.width() |{option.rect.width()}|')

        # setting this to the (known) column width ALMOST solves the problem
        option.rect.setWidth(400)
        
        doc.setTextWidth(option.rect.width())
        print(f'... option.font.pointSize {option.font.pointSize()}')
        doc.setDefaultFont(option.font)
        doc.setDocumentMargin(0)
        # print(f'... option.text |{option.text}|')
        doc.setHtml(option.text)
        doc_height_int = int(doc.size().height())
        print(f'... doc_height_int {doc_height_int}')

        # NB parent is table view        
        # has no effect:
        # self.parent().verticalHeader().resizeSection(index.row(), doc_height_int - 20)
        
        return QtCore.QSize(int(doc.idealWidth()), doc_height_int)
                
app = QtWidgets.QApplication([])
default_font = QtGui.QFont()
default_font.setPointSize(12)
app.setFont(default_font)
main_window = MainWindow()
main_window.show()
exec_return = app.exec()
sys.exit(exec_return)

NB OS is W10… things may work differently on other OSs.

If you run this without the line in sizeHint: option.rect.setWidth(400) you see the problem in its "real" manifestation. For some reason, sizeHint‘s option parameter is being told the cells are narrower than they are.

So first question: where does this option parameter come from? What constructs it and sets its rect? And can this be altered?

Second question: even with the option.rect.setWidth(400) line, although the longer lines, thus involving word breaks, look OK and fit very neatly into their cells, this is not the case with the shorter lines, which fit into a single line: they always seem to have an extraneous gap or padding or margin at the bottom, as though the table view’s vertical header has a default section height or minimum section height which is overriding the desired height. But in fact setting the vertical header’s setMinimumSectionSize and/or setDefaultSectionSize has no effect.

So what’s causing that bit of "padding", or incorrect row height, and how can it be corrected to fit the single lines very neatly in their cells?

PS I have experimented with verticalHeader().resizeSection() in paint and sizeHint (and elsewhere!)… This might be some part of the solution, but I haven’t managed to find it.

Asked By: mike rodent

||

Answers:

You’re using resizeRowToContents before setting the column sizes, so the height of the rows is based on the current column section size, which is the default size based on the header contents.

Move that call after resizing the columns, or, alternatively, connect the function to the sectionResized signal of the horizontal header:

class SegmentsTableView(QtWidgets.QTableView):
    def __init__(self, parent):
        # ...
        self.horizontalHeader().sectionResized.connect(self.resizeRowsToContents)

The option is created by the view’s viewOptions() (which has an empty rectangle), then it’s "setup" depending on the function that is being called.

The rectangle is set using the header sections that correspond to the index, and it shouldn’t be modified in the sizeHint() of the delegate, since that’s not its purpose.

The problem with the increased height when only one line is shown is due to the QStyle, and it’s because resizeRowsToContents uses both the size hint of the row and the header sectionSizeHint. The section size hint is the result of the SizeHintRole of the headerData for that section or, if it’s not set (which is the default), the sectionSizeFromContents, which uses the content of the section and creates an appropriate size using the style’s sizeFromContents function.

If you’re sure that you want this to be the default behavior, then you need to override resizeRowsToContents, so that it will only consider the size hint of the row while ignoring the section hint.

But you should also consider double clicking on the header handle. In this case, the problem is that the signal is directly connected to the resizeRowToContents (Row is singular here!) C++ function, and overriding it will not work, so the only possibility is to completely disconnect the signal and connect it to the overridden function:

class SegmentsTableView(QtWidgets.QTableView):
    def __init__(self, parent):
        # ...
        v_header =  self.verticalHeader()
        v_header.sectionHandleDoubleClicked.disconnect()
        v_header.sectionHandleDoubleClicked.connect(self.resizeRowToContents)
        self.horizontalHeader().sectionResized.connect(self.resizeRowsToContents)

    def resizeRowToContents(self, row):
        self.verticalHeader().resizeSection(row, self.sizeHintForRow(row))

    def resizeRowsToContents(self):
        header = self.verticalHeader()
        for row in range(self.model().rowCount()):
            hint = self.sizeHintForRow(row)
            header.resizeSection(row, hint)

Note that you should not try to resize sections in the sizeHint nor paint functions, because that could cause recursion.

Answered By: musicamante

Musicamante is the guru for all things PyQt. So I hesitate before putting in another answer here. But I have finally found a way to implement a variable-row-height table, which also renders medium-complexity HTML.

The solution given above by Musicamante, with his proposal for overriding resizeRowToContents and resizeRowsToContents has no problem in this MRE. But when I come to implement slightly more complex HTML (each cell contains several <span>...</span>, to highlight words in fact with multiple highlighter colours), a problem occurs: after you resize the window there is a nasty phenomenon of text being "written on top of" text. The text then cleans itself up if you just click in the cell. But I want to do that programmatically. It appears a final paint is needed, in an asynchronous manner.

My suggestion is a very slight tweak to Musicamante’s code:

def resizeRowsToContents(self):
    header = self.verticalHeader()
    def resize_rows_again():
        for row in range(self.model().rowCount()):
            hint = self.sizeHintForRow(row)
            header.resizeSection(row, hint)    
    QtCore.QTimer.singleShot(1, resize_rows_again)

… and other variations also seem to work, for example:

def resizeRowsToContents(self):
    super().resizeRowsToContents()
    def resize_rows_again():
        for row in range(self.model().rowCount()):
            self.resizeRowToContents(row)
    QtCore.QTimer.singleShot(1, resize_rows_again)

… with the above example it appears no longer necessary to override resizeRowToContents ("Row" singular) in fact…

Answered By: mike rodent