Search code examples
pythonqtpyqt5

pyqt5 central widget with QTableWidgets in a MainWindow not correctly resized


I use as the central widget in a QMainWindow a QDialog with a bunch of QTableWidget in it.

import sys
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *

class MainWindow(QMainWindow):
    
    def __init__(self, parent=None):
        super(MainWindow, self).__init__(parent)
        
        # the central widget
        scrollArea = QScrollArea()
        scrollArea.setWidgetResizable(True)
        self.centerWidget = QDialog()
        self.centerWidgetLayout = QVBoxLayout()
        self.centerWidget.setLayout(self.centerWidgetLayout)
        scrollArea.setWidget(self.centerWidget)
        self.setCentralWidget(scrollArea)
        
        self.populateLayout()
    
    def defineTable(self, ncols):
        tableWidget = QTableWidget()
        tableWidget.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        tableWidget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
        tableWidget.verticalHeader().hide()
        tableWidget.horizontalHeader().hide()
        tableWidget.setMinimumHeight(10)
        tableWidget.horizontalHeader().setDefaultSectionSize(50)
        tableWidget.horizontalHeader().setHighlightSections(False)
        tableWidget.horizontalHeader().setSortIndicatorShown(False)
        tableWidget.horizontalHeader().setStretchLastSection(False)
        tableWidget.setColumnCount(ncols )
        tableWidget.setRowCount(1)
        for col in range(ncols):
            tableWidget.setItem( 0, col, QTableWidgetItem("lalala"))
        return tableWidget
        
    def populateLayout(self):
        for i in range(6):
            self.centerWidgetLayout.addWidget( self.defineTable(i+1) )


if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = MainWindow()
    ex.show()
    sys.exit(app.exec_())

Curiously, the scroll bar is not effective.

Here is a picture illustrating the result when the size of the widget is large:

enter image description here

Everything is fine.

Now, I squeeze the widget and get:

enter image description here

This is not what I expect, and the widget is useless.

If I make it small enough, the scrollbar appears:

enter image description here

but it is still useless.

Any hint on how to solve this issue? Thanks.

Edit 1: As suggested by musicamante, the issue is triggered by

tableWidget.setMinimumHeight(10)

but if I remove this line, I get a blank space between the row and the scroll bar. Any idea how to remove this space?

enter image description here

Edit 2: The white space can be removed using

tableWidget.verticalHeader().setStretchLastSection(True)

However, the rows are now too large:

enter image description here

and my attempts to reduce the height of the rows were not successful. Any idea ?


Solution

  • If you want to limit the space of a view to its contents, you cannot use size restraints like setMinimum or setMaximum (with Size, Height or Width), especially with arbitrary values.

    When you call setMinimumHeight() on those tables using a value bigger than 0, you tell the parent layout (the container widget of the scroll area) that they can also be shrunk down to that height.

    Using a value of 10 for that height is clearly too small; you are telling that the whole view can be shrunk to 10 pixel, and that's a wrong value:

    • you are assuming that 10 pixels are enough to show the items, but that is based on what? If you are using a default font that is less than 10 pixels in height that might be sufficient, but what if the user uses a bigger font, or they are using font scaling?
    • you are not considering the horizontal scroll bar, which you also set as "always on" and explicitly setting a dimension should always consider scroll bars;

    Removing setMinimumHeight() is certainly necessary, but not enough.

    Item views are scroll areas; by default all Qt scroll areas use expanding size policies, and have default size hints:

    • sizeHint() usually defaults to 256x192, and it's used when the parent layout has enough space to show it: if there is no constraint, that will be the size used to show the view, at least when initially shown;
    • minimumSizeHint() normally defaults to 75x75, and it's used as the minimum possible size whenever the parent layout has to use the least space possible;

    For scroll areas, Qt will always try to use the minimumSizeHint of its widgets if any of its dimensions are greater than 0, unless they have an explicit minimum size set (see the point above). This is what happens in your second attempt.

    All the above means that you have to at least override the minimumSizeHint() function and return a reasonable size, and the only proper way to do that is by using a subclass.

    But, what size should be returned?

    The minimum size of the contents of a QTableView is based on the header size hint of each section, but you also have to add the border (using the frameWidth() property, since QAbstractScrollArea inherits from QFrame) and the relative scroll bar size, if visible.

    Note that, for scroll areas that have the visibility of the scroll bars based on their necessity this can be tricky (the view may show scroll bars at the time of the sizeHint() call, but the final hint might not require them); luckily that's not our case, since you've set the horizontal scroll bar policy to ScrollBarAlwaysOn.

    Considering all the above, this is a basic implementation:

    class MinHeightTable(QTableWidget):
        def minimumSizeHint(self):
            height = (
                self.frameWidth() * 2
                + self.verticalHeader().length()
                + self.horizontalScrollBar().sizeHint().height()
            )
            return QSize(super().minimumSizeHint().width(), height)
    
        def sizeHint(self):
            return QSize(
                super().sizeHint().width(), 
                self.minimumSizeHint().height()
            )
    
    class MainWindow(QMainWindow):
        # ...
        def defineTable(self, ncols):
            tableWidget = MinHeightTable()
            # ...
    

    That might not be sufficient though. For example, the view might might change its row count, making it impossible to properly see the other rows if not by using the mouse wheel or the keyboard navigation.

    That could be easily achieved by setting the scroll area's sizeAdjustPolicy property to AdjustToContents, which will automatically tell the container that the size hint has changed.

    Done? Not yet: the user might increase the size of the scroll area, which will still show the "gaps" near the right and bottom edges, in case no other widgets in the same layout claims the remaining available space (remember the "expanding" size policy).

    Considering this specific case, we probably don't need to use size hints at all, since we want to always force a specific height. Then, we should just ensure that we always have a valid height using what learnt so far. Also, since we're already using a subclass, we could just move its configuration in its init.

    Here is a possible implementation:

    class MinHeightTable(QTableWidget):
        def __init__(self, rows=1, cols=0):
            super().__init__(rows, cols)
            self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
            self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
            self.verticalHeader().hide()
            self.horizontalHeader().hide()
            self.horizontalHeader().setDefaultSectionSize(
                self.fontMetrics().horizontalAdvance('X' * 10))
    
            # ensure that whenever the model changes in some way that could affect
            # its "minimum" size, it notifies that its size hints have changed too
            self.model().rowsInserted.connect(self.updateGeometry)
            self.model().rowsRemoved.connect(self.updateGeometry)
            # the dataChanged signal is included just as a precaution, in order to
            # provide support in case the size hint of an item/index is explicitly
            # changed; if both headers have resize modes set to ResizeToContent,
            # the following may become redundant
            self.model().dataChanged.connect(self.updateGeometry)
    
        def baseHeight(self):
            height = (
                self.frameWidth() * 2
                + self.verticalHeader().length()
                + self.horizontalScrollBar().sizeHint().height()
            )
            if self.height() != height:
                self.setFixedHeight(height)
            return height
    
        def minimumSizeHint(self):
            return QSize(
                super().minimumSizeHint().width(), 
                self.baseHeight()
            )
    
        def sizeHint(self):
            return QSize(
                super().sizeHint().width(), 
                self.baseHeight()
            )
    
    
    class MainWindow(QMainWindow):
        # ...
        def defineTable(self, ncols):
            tableWidget = MinHeightTable(1, ncols)
            for col in range(ncols):
                tableWidget.setItem( 0, col, QTableWidgetItem("lalala"))
            return tableWidget
    

    Note: in the above code I did not consider transient scroll bars, which are only shown while hovering them or scrolling content, but do not change the content size if they are visible or not. You should probably check that by querying the current style hint with the QStyle.SH_ScrollBar_Transient enum.

    Finally, remember that QListView allows showing items in horizontal layouts. If you are going to always show only one "row" of items, QListView (or QListWidget) might be a better and actually simpler solution, because it also allows to lay out items horizontally; you would still need to override the size hint functions, but the final implementation might be much easier:

    class SmallListWidget(QListWidget):
        def __init__(self):
            super().__init__()
            self.setFlow(self.LeftToRight)
            self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
    
        def baseHeight(self):
            opt = QStyleOptionViewItem()
            opt.initFrom(self)
            itemSize = self.style().sizeFromContents(
                QStyle.CT_ItemViewItem, opt, QSize(), self)
            height = (
                itemSize.height() 
                + self.frameWidth() * 2
                + self.horizontalScrollBar().sizeHint().height()
            )
            if self.height() != height:
                self.setFixedHeight(height)
            return height
    
        def minimumSizeHint(self):
            return QSize(
                super().minimumSizeHint().width(), 
                self.baseHeight()
            )
    
        def sizeHint(self):
            return QSize(
                super().sizeHint().width(), 
                self.baseHeight()
            )
    
    
    class MainWindow(QMainWindow):
        # ...
        def defineList(self, ncols):
            listWidget = SmallListWidget()
            for col in range(ncols):
                listWidget.addItem("lalala")
            return listWidget
            
        def populateLayout(self):
            for i in range(6):
                self.centerWidgetLayout.addWidget(self.defineList(i + 1))
    

    The above approach has at least two major benefits:

    • items can be accessed just by their index, similarly to a list;
    • the view will never have a vertical extent that could be smaller or bigger than the required size;