Search code examples
pythonpyqtqtableview

QTableView won't display single line of text quite right


Here's an MRE:

import sys, logging, datetime

from PyQt5 import QtWidgets, QtCore, QtGui
from PyQt5.Qt import QVBoxLayout

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.__class__.instance = self
        self.resize(1200, 1600) # w, h
        main_splitter = QtWidgets.QSplitter(self)
        main_splitter.setOrientation(QtCore.Qt.Vertical)
        self.setCentralWidget(main_splitter)
        self.top_frame = QtWidgets.QFrame()
        main_splitter.addWidget(self.top_frame)
        self.bottom_frame = BottomFrame()
        self.bottom_frame.setMaximumHeight(350)                
        self.bottom_frame.setMinimumHeight(100)
        main_splitter.addWidget(self.bottom_frame)
        main_splitter.setCollapsible(1, False)
        self.bottom_frame.construct()
            
class BottomFrame(QtWidgets.QFrame):
    def construct(self):
        layout = QtWidgets.QVBoxLayout(self)
        # without this you get the default 10 px border all round the table: too much
        layout.setContentsMargins(1, 1, 1, 1)
        self.setLayout(layout)
        self.messages_table = LogTableView()
        layout.addWidget(self.messages_table)
        self.messages_table.visual_log('hello world')                
        self.messages_table.visual_log('message 2 qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd ')
        self.messages_table.visual_log('message 3')
        self.messages_table.visual_log('message 4', logging.ERROR)
        self.messages_table.visual_log('message 5')
        self.messages_table.visual_log('message 6 qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd qunaomdd ')
        self.messages_table.visual_log('message 7')
        
class LogTableView(QtWidgets.QTableView):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setModel(QtGui.QStandardItemModel())
        self.horizontalHeader().setStretchLastSection(True)
        self.horizontalHeader().hide()
        self.setVerticalHeader(VerticalHeader(self))
        self.verticalHeader().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
        self.verticalHeader().hide()
        self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
        self.setAlternatingRowColors(True)
        # this doesn't seem to have any effect 
        # self.verticalHeader().setMinimumSectionSize(1)
        
    def sizeHintForRow(self, row ):
        hint = super().sizeHintForRow(row)
        # print(f'size hint for row {row}: {hint}')
        # this doesn't seem to have any effect!
        if hint < 25 and hint > 10: 
            hint = 10
        return hint
        
    def visual_log(self, msg: str, log_level: int=logging.INFO):
        model = self.model()
        i_new_row = model.rowCount()
        model.insertRow(i_new_row)
        datetime_stamp_str = datetime.datetime.now().strftime('%a %H:%M:%S.%f')[:-3]
        model.setItem(i_new_row, 0, QtGui.QStandardItem(datetime_stamp_str))
        model.setItem(i_new_row, 1, QtGui.QStandardItem(str(log_level)))
        self.setColumnWidth(0, 160)
        self.setColumnWidth(1, 100)
        model.setItem(i_new_row, 2, QtGui.QStandardItem(msg))
        QtCore.QTimer.singleShot(0, self.resizeRowsToContents)
        QtCore.QTimer.singleShot(10, self.scrollToBottom)
            
class VerticalHeader(QtWidgets.QHeaderView):
    def __init__(self, parent):
        super().__init__(QtCore.Qt.Vertical, parent)
    
    def sectionSizeHint(self, logical_index):
        hint = super().sectionSizeHint(logical_index)
        print(f'vh index {logical_index} hint {hint}')
        return hint

def main():
    app_instance = QtWidgets.QApplication(sys.argv)
    MainWindow().show()  
    sys.exit(app_instance.exec())
    
if __name__ == '__main__':
    main()

In terms of row heights it's ALMOST doing what I want: try resizing the main window: the table rows adjust their heights depending on the real height of the text (wrapped as needed).

BUT... while this works if you have a piece of text which needs more than one row, sizing the row height just right, the single-line rows always take up slightly too much height. The sizeHintForRow() method of the QTableView always seems to return 24 (pixels) from the superclass ... but even if I interfere with that and brutally say "no, make it a smaller hint", it appears that that gets overridden by something later.

setMinimumSectionSize on the vertical header also seems to have no effect.

I also thought the data() method of the table model might be the culprit but role 13, "SizeHintRole", never seems to get fired.

Source code
I have tried looking at the source code. The method of interest here seems to be on l. 3523, one of several versions of QHeaderView's resizeSections method. The code is naturally quite daunting, but I did spot, for example, invalidateCachedSizeHint() on l. 3261, which might explain why the size hints are being ignored... Anyone with particularly intricate knowledge of QHeaderView's functionality? Update later After Musicamante suggested use of setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContent) (good idea), I don't now know what part of the source code is involved.

Screenshot
main window

... the single-text-line rows are slightly too high. If you see something different, like perfectly tight single-text-line rows, please let me know. I'm on W10. A different OS may produce a different result, who knows?


Solution

  • The problem is twofold, as resizing to contents means that the header queries its own size requirements and the view's:

    • the header's defaultSectionSize(), minimumSectionSize() and sectionSizeFromContents();
    • the view's sizeHintForRow() (or sizeHintForColumn() for horizontal headers), which eventually queries the delegate's sizeHint() function;

    When the view only shows one line, the style (which is what the header functions above query) returns default heights, which may have larger vertical margins above and beyond the displayed text.

    When word wrap is enabled and more than one line is shown, the displayed text normally requires more vertical space than the header would eventually use, and that space is increased by a default margin that may result in a smaller visual margin than in single line mode.

    In order to achieve consistent resizing, you have to implement a custom header that always returns a very small size from sectionSizeFromContents, and arbitrary small sizes should be set as well for both the defaultSectionSize and minimumSectionSize. In this way, the view will always consider the actual contents of the displayed text, and the margins will always be consistent.

    class VerticalHeader(QHeaderView):
        def __init__(self, parent):
            super().__init__(Qt.Vertical, parent)
            # we can set everything in here
            self.setSectionResizeMode(self.ResizeToContents)
            self.setDefaultSectionSize(1)
            self.setMinimumSectionSize(1)
            self.hide()
    
        def sectionSizeFromContents(self, index):
            size = super().sectionSizeFromContents(index)
            size.setHeight(1)
            return size
    

    Note that sectionSizeFromContents() is also used to get the width of a vertical header, but since you're not displaying the vertical header, a simpler return QSize() would suffice.

    Finally, you just need to properly set up the header; you do not need to override sizeHintForRow in the table anymore, but further changes should be considered:

    class LogTableView(QTableView):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            # IMPORTANT! set the column count, so that we can automatically set 
            # the column widths
            self.setModel(QStandardItemModel(0, 3))
            self.setEditTriggers(QAbstractItemView.NoEditTriggers)
            self.setAlternatingRowColors(True)
    
            self.horizontalHeader().setStretchLastSection(True)
            self.horizontalHeader().hide()
            self.setVerticalHeader(VerticalHeader(self))
    
            self.setColumnWidth(0, 160)
            self.setColumnWidth(1, 100)
    
        ...
    

    As an unrelated note, you should avoid to convert numbers to strings if the program logic needs to use numerical values. Instead of doing QStandardItem(str(log_level)), use QStandardItem.setData(); the delegate will automatically convert values to strings wherever necessary.

    The same also goes for date/time values, as they might require proper logical implementation (for instance, sorting or filtering): you may just use a proper delegate in order to display the date according to your format.

    class DateDelegate(QStyledItemDelegate):
        def displayText(self, value, locale):
            if isinstance(value, QDateTime):
                return value.toString('ddd HH:mm:ss.zzz')
            return super().displayText(value, locale)
    
    
    class LogTableView(QTableView):
        def __init__(self, *args, **kwargs):
            ... # as above
    
            self.setItemDelegateForColumn(0, DateDelegate(self))
    
        def visual_log(self, msg: str, log_level: int=logging.INFO):
            model = self.model()
            i_new_row = model.rowCount()
            model.insertRow(i_new_row)
    
            datetime_item = QStandardItem()
            datetime_item.setData(QDateTime.currentDateTime(), Qt.DisplayRole)
            model.setItem(i_new_row, 0, datetime_item)
    
            log_item = QStandardItem()
            log_item.setData(log_level, Qt.DisplayRole)
            model.setItem(i_new_row, 1, log_item)
    
            model.setItem(i_new_row, 2, QStandardItem(msg))