Search code examples
pythonpyqtpyqt5qdockwidgetqtpy

Qt: How does a dock widget get its initial size?


When a widget is docked, I would like it to change direction and have minimal size relative to how the dock expands.

That is,

  • left/right dock => top-to-bottom direction with minimum width
  • top/bottom dock => left-to-right direction with minimum height

The trouble is, whenever the direction is changed, the dock takes on a seemingly arbitrary width or height. I cannot find a way to resize/force the dock widget to a particular size when docked. I have tried countless variations of overriding the sizeHint, minimumSizeHint, calling adjustSize, and fiddling with the sizePolicy.

How can I ensure the initial dock size?


My basic application looks like:

enter image description here

The application shows primary and secondary information along with corresponding sets of controls. A tab widget containing the primary and secondary content is set as the Central Widget. A QStackedWidget housing the controls in respective dashboards lives in a dock. When the tab changes, the corresponding dashboard is shown. The code for this is given below in basic application code.

The difficulty lies in that changing the direction of the dashboard upsets the size of the dock.

To adjust the dashboard direction, I can think of two reasonable solutions:

  • via the resizeEvent or
  • via the dockLocationChanged signal

Adjusting direction via resizeEvent

This seems, to me, the preferable option. It allows the user the most flexibility. If they dislike the direction of a dock, dragging it past a particular limit will allow them to change the dock's direction. Here I check for whether it is wider than tall.

    class MyDock(QtWidgets.QDockWidget):

        def __init__(self):

            super(MyDock, self).__init__()

        def resizeEvent(self, event):
            size = event.size()
            is_wide = size.width() > size.height()

            container_object = self.widget().currentWidget()

            if is_wide:
                container_object.setDirection(QtWidgets.QBoxLayout.LeftToRight)
            else:
                container_object.setDirection(QtWidgets.QBoxLayout.TopToBottom)

The complete code for this is given below in resize approach.

Change direction on dockLocationChange

As the resize event happens all the time, another approach might be to change the direction only when the dock location changes. To do this, connect a function to the dockLocationChanged signal and adjust the direction depending on the dock.

    class MyDock(QtWidgets.QDockWidget):

        def __init__(self):

            super(MyDock, self).__init__()

            self.dockLocationChanged.connect(self.dock_location_changed)

        def dock_location_changed(self, area):
            top    = QtCore.Qt.DockWidgetArea.TopDockWidgetArea
            bottom = QtCore.Qt.DockWidgetArea.BottomDockWidgetArea

            container_object = self.widget().currentWidget()

            if area in [top, bottom]:
                container_object.setDirection(QtWidgets.QBoxLayout.LeftToRight)
            else:
                container_object.setDirection(QtWidgets.QBoxLayout.TopToBottom)

Basic application code

The program consists of 5 separate classes.

For

  1. MyWindow,
  2. PrimaryDashboard, and
  3. SecondaryDashboard

the reason for separation should be clear enough.

For

  1. MyDock and
  2. DockContainer

the separation is to facilitate overriding sizeHint, setDirection, or other methods.

    import qtpy
    from qtpy import QtWidgets, QtGui, QtCore
    import sys


    class PrimaryDashboard(QtWidgets.QWidget):

        def __init__(self):

            super(PrimaryDashboard, self).__init__()

            self.init_widgets()
            self.init_layout()

        def init_widgets(self):
            self.label = QtWidgets.QLabel('Primary dashboard')
            self.ok = QtWidgets.QPushButton('OK')
            self.cancel = QtWidgets.QPushButton('Cancel')

        def init_layout(self):
            self.layout = QtWidgets.QHBoxLayout()
            self.layout.addWidget(self.label)
            self.layout.addWidget(self.ok)
            self.layout.addWidget(self.cancel)
            self.setLayout(self.layout)

        def setDirection(self, direction):
            self.layout.setDirection(direction)


    class SecondaryDashboard(QtWidgets.QWidget):

        def __init__(self):

            super(SecondaryDashboard, self).__init__()

            self.init_widgets()
            self.init_layout()

        def init_widgets(self):
            self.label = QtWidgets.QLabel('Secondary dashboard')

            self.descr1 = QtWidgets.QLabel('Thing 1')
            self.check1 = QtWidgets.QCheckBox()

            self.descr2 = QtWidgets.QLabel('Thing 2')
            self.check2 = QtWidgets.QCheckBox()

        def init_layout(self):
            self.layout = QtWidgets.QVBoxLayout()

            self.grid = QtWidgets.QGridLayout()
            self.grid.addWidget(self.descr1, 0, 0)
            self.grid.addWidget(self.check1, 0, 1)
            self.grid.addWidget(self.descr2, 1, 0)
            self.grid.addWidget(self.check2, 1, 1)

            self.layout.addWidget(self.label)
            self.layout.addLayout(self.grid)
            self.setLayout(self.layout)

        def setDirection(self, direction):
            self.layout.setDirection(direction)


    class DockContainer(QtWidgets.QStackedWidget):

        def __init__(self):

            super(DockContainer, self).__init__()


    class MyDock(QtWidgets.QDockWidget):

        def __init__(self):

            super(MyDock, self).__init__()


    class MyWindow(QtWidgets.QMainWindow):

        def __init__(self, parent=None):
            super(MyWindow, self).__init__(parent=parent)

            self.resize(600, 400)

            self.init_widgets()
            self.init_layout()

        def init_widgets(self):

            self.tab_widget = QtWidgets.QTabWidget()
            self.tab1 = QtWidgets.QLabel('Primary content')
            self.tab2 = QtWidgets.QLabel('Secondary content')
            self.tab_widget.addTab(self.tab1, 'Primary')
            self.tab_widget.addTab(self.tab2, 'Secondary')
            self.tab_widget.currentChanged.connect(self.tab_selected)

            self.primary_dashboard = PrimaryDashboard()
            self.secondary_dashboard = SecondaryDashboard()

            self.dashboard = DockContainer()
            self.dashboard.addWidget(self.primary_dashboard)
            self.dashboard.addWidget(self.secondary_dashboard)
            self.dashboard.setCurrentWidget(self.primary_dashboard)

            self.dock = MyDock()
            self.dock.setWidget(self.dashboard)
            self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.dock)

        def init_layout(self):
            self.main_layout = QtWidgets.QVBoxLayout()
            self.main_layout.addWidget(self.tab_widget)

            self.main_widget = QtWidgets.QWidget()
            self.main_widget.setLayout(self.main_layout)
            self.setCentralWidget(self.main_widget)

        def tab_selected(self):
            tab_index = self.tab_widget.currentIndex()
            if self.tab_widget.tabText(tab_index) == 'Secondary':
                self.dashboard.setCurrentWidget(self.secondary_dashboard)
                self.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.dock)
            else:  # Primary
                self.dashboard.setCurrentWidget(self.primary_dashboard)
                self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.dock)


    if __name__ == '__main__':
        app = QtWidgets.QApplication(sys.argv)

        window = MyWindow()
        window.show()
        sys.exit(app.exec_())

Resize approach

This code is identical to the basic application code yet with resizeEvent overridden in the dock widget.

    import qtpy
    from qtpy import QtWidgets, QtGui, QtCore
    import sys


    class PrimaryDashboard(QtWidgets.QWidget):

        def __init__(self):

            super(PrimaryDashboard, self).__init__()

            self.init_widgets()
            self.init_layout()

        def init_widgets(self):
            self.label = QtWidgets.QLabel('Primary dashboard')
            self.ok = QtWidgets.QPushButton('OK')
            self.cancel = QtWidgets.QPushButton('Cancel')

        def init_layout(self):
            self.layout = QtWidgets.QHBoxLayout()
            self.layout.addWidget(self.label)
            self.layout.addWidget(self.ok)
            self.layout.addWidget(self.cancel)
            self.setLayout(self.layout)

        def setDirection(self, direction):
            self.layout.setDirection(direction)


    class SecondaryDashboard(QtWidgets.QWidget):

        def __init__(self):

            super(SecondaryDashboard, self).__init__()

            self.init_widgets()
            self.init_layout()

        def init_widgets(self):
            self.label = QtWidgets.QLabel('Secondary dashboard')

            self.descr1 = QtWidgets.QLabel('Thing 1')
            self.check1 = QtWidgets.QCheckBox()

            self.descr2 = QtWidgets.QLabel('Thing 2')
            self.check2 = QtWidgets.QCheckBox()

        def init_layout(self):
            self.layout = QtWidgets.QVBoxLayout()

            self.grid = QtWidgets.QGridLayout()
            self.grid.addWidget(self.descr1, 0, 0)
            self.grid.addWidget(self.check1, 0, 1)
            self.grid.addWidget(self.descr2, 1, 0)
            self.grid.addWidget(self.check2, 1, 1)

            self.layout.addWidget(self.label)
            self.layout.addLayout(self.grid)
            self.setLayout(self.layout)

        def setDirection(self, direction):
            self.layout.setDirection(direction)


    class DockContainer(QtWidgets.QStackedWidget):

        def __init__(self):

            super(DockContainer, self).__init__()


    class MyDock(QtWidgets.QDockWidget):

        def __init__(self):

            super(MyDock, self).__init__()

        def resizeEvent(self, event):
            size = event.size()
            is_wide = size.width() > size.height()

            container_object = self.widget().currentWidget()

            if is_wide:
                container_object.setDirection(QtWidgets.QBoxLayout.LeftToRight)
            else:
                container_object.setDirection(QtWidgets.QBoxLayout.TopToBottom)


    class MyWindow(QtWidgets.QMainWindow):

        def __init__(self, parent=None):
            super(MyWindow, self).__init__(parent=parent)

            self.resize(600, 400)

            self.init_widgets()
            self.init_layout()

        def init_widgets(self):

            self.tab_widget = QtWidgets.QTabWidget()
            self.tab1 = QtWidgets.QLabel('Primary content')
            self.tab2 = QtWidgets.QLabel('Secondary content')
            self.tab_widget.addTab(self.tab1, 'Primary')
            self.tab_widget.addTab(self.tab2, 'Secondary')
            self.tab_widget.currentChanged.connect(self.tab_selected)

            self.primary_dashboard = PrimaryDashboard()
            self.secondary_dashboard = SecondaryDashboard()

            self.dashboard = DockContainer()
            self.dashboard.addWidget(self.primary_dashboard)
            self.dashboard.addWidget(self.secondary_dashboard)
            self.dashboard.setCurrentWidget(self.primary_dashboard)

            self.dock = MyDock()
            self.dock.setWidget(self.dashboard)
            self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.dock)

        def init_layout(self):
            self.main_layout = QtWidgets.QVBoxLayout()
            self.main_layout.addWidget(self.tab_widget)

            self.main_widget = QtWidgets.QWidget()
            self.main_widget.setLayout(self.main_layout)
            self.setCentralWidget(self.main_widget)

        def tab_selected(self):
            tab_index = self.tab_widget.currentIndex()
            if self.tab_widget.tabText(tab_index) == 'Secondary':
                self.dashboard.setCurrentWidget(self.secondary_dashboard)
                self.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.dock)
            else:  # Primary
                self.dashboard.setCurrentWidget(self.primary_dashboard)
                self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.dock)


    if __name__ == '__main__':
        app = QtWidgets.QApplication(sys.argv)

        window = MyWindow()
        window.show()
        sys.exit(app.exec_())

dockLocationChanged approach

This code is identical to the basic application code yet with the dockLocationChanged signal connected to a method which adjusts the direction based on the current dock location.

    import qtpy
    from qtpy import QtWidgets, QtGui, QtCore
    import sys


    class PrimaryDashboard(QtWidgets.QWidget):

        def __init__(self):

            super(PrimaryDashboard, self).__init__()

            self.init_widgets()
            self.init_layout()

        def init_widgets(self):
            self.label = QtWidgets.QLabel('Primary dashboard')
            self.ok = QtWidgets.QPushButton('OK')
            self.cancel = QtWidgets.QPushButton('Cancel')

        def init_layout(self):
            self.layout = QtWidgets.QHBoxLayout()
            self.layout.addWidget(self.label)
            self.layout.addWidget(self.ok)
            self.layout.addWidget(self.cancel)
            self.setLayout(self.layout)

        def setDirection(self, direction):
            self.layout.setDirection(direction)


    class SecondaryDashboard(QtWidgets.QWidget):

        def __init__(self):

            super(SecondaryDashboard, self).__init__()

            self.init_widgets()
            self.init_layout()

        def init_widgets(self):
            self.label = QtWidgets.QLabel('Secondary dashboard')

            self.descr1 = QtWidgets.QLabel('Thing 1')
            self.check1 = QtWidgets.QCheckBox()

            self.descr2 = QtWidgets.QLabel('Thing 2')
            self.check2 = QtWidgets.QCheckBox()

        def init_layout(self):
            self.layout = QtWidgets.QVBoxLayout()

            self.grid = QtWidgets.QGridLayout()
            self.grid.addWidget(self.descr1, 0, 0)
            self.grid.addWidget(self.check1, 0, 1)
            self.grid.addWidget(self.descr2, 1, 0)
            self.grid.addWidget(self.check2, 1, 1)

            self.layout.addWidget(self.label)
            self.layout.addLayout(self.grid)
            self.setLayout(self.layout)

        def setDirection(self, direction):
            self.layout.setDirection(direction)


    class DockContainer(QtWidgets.QStackedWidget):

        def __init__(self):

            super(DockContainer, self).__init__()


    class MyDock(QtWidgets.QDockWidget):

        def __init__(self):

            super(MyDock, self).__init__()

            self.dockLocationChanged.connect(self.dock_location_changed)

        def dock_location_changed(self, area):
            top    = QtCore.Qt.DockWidgetArea.TopDockWidgetArea
            bottom = QtCore.Qt.DockWidgetArea.BottomDockWidgetArea
            # left   = QtCore.Qt.DockWidgetArea.LeftDockWidgetArea
            # right  = QtCore.Qt.DockWidgetArea.RightDockWidgetArea

            container_object = self.widget().currentWidget()

            if area in [top, bottom]:
                container_object.setDirection(QtWidgets.QBoxLayout.LeftToRight)
            else:
                container_object.setDirection(QtWidgets.QBoxLayout.TopToBottom)


    class MyWindow(QtWidgets.QMainWindow):

        def __init__(self, parent=None):
            super(MyWindow, self).__init__(parent=parent)

            self.resize(600, 400)

            self.init_widgets()
            self.init_layout()

        def init_widgets(self):

            self.tab_widget = QtWidgets.QTabWidget()
            self.tab1 = QtWidgets.QLabel('Primary content')
            self.tab2 = QtWidgets.QLabel('Secondary content')
            self.tab_widget.addTab(self.tab1, 'Primary')
            self.tab_widget.addTab(self.tab2, 'Secondary')
            self.tab_widget.currentChanged.connect(self.tab_selected)

            self.primary_dashboard = PrimaryDashboard()
            self.secondary_dashboard = SecondaryDashboard()

            self.dashboard = DockContainer()
            self.dashboard.addWidget(self.primary_dashboard)
            self.dashboard.addWidget(self.secondary_dashboard)
            self.dashboard.setCurrentWidget(self.primary_dashboard)

            self.dock = MyDock()
            self.dock.setWidget(self.dashboard)
            self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.dock)

        def init_layout(self):
            self.main_layout = QtWidgets.QVBoxLayout()
            self.main_layout.addWidget(self.tab_widget)

            self.main_widget = QtWidgets.QWidget()
            self.main_widget.setLayout(self.main_layout)
            self.setCentralWidget(self.main_widget)

        def tab_selected(self):
            tab_index = self.tab_widget.currentIndex()
            if self.tab_widget.tabText(tab_index) == 'Secondary':
                self.dashboard.setCurrentWidget(self.secondary_dashboard)
                self.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.dock)
            else:  # Primary
                self.dashboard.setCurrentWidget(self.primary_dashboard)
                self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.dock)


    if __name__ == '__main__':
        app = QtWidgets.QApplication(sys.argv)

        window = MyWindow()
        window.show()
        sys.exit(app.exec_())

Solution

  • Think of an application as a set of Matryoshka dolls. An inner doll's size dictates that of the subsequent outer ones. Clearly, an inner doll cannot be bigger than the one that contains it! QWidgets are modeled similarly.

    By default, composite widgets which do not provide a size hint will be sized according to the space requirements of their child widgets.

    The documentation for QWidget.sizeHint() goes on to say,

    The default implementation of QWidget.sizeHint() returns an invalid size if there is no layout for this widget, and returns the layout’s preferred size otherwise.

    Altogether, a widget's sizing comes from the inside out, based on the layout.

    If you were to implement a resizeEvent1 for each of the objects in the base application code, you would see the following sequence of sizings,

    1. PrimaryDashboard resizeEvent
    2. DockContainer resizeEvent
    3. MyDock resizeEvent

    This is the nesting we expect. The PrimaryDashboard is consulted first for its size, then the DockContainer, and then the MyDock. Technically speaking, it's widgets all the way down. However, the PrimaryDashboard contains buttons and labels which ought to be smaller than the width/height of the MainWindow in the majority of circumstances. The first doll in the sequence to significantly affect dock sizing is the PrimaryDashboard.

    Examining the resizeEvent, using event.size(), we can see that a reasonable minimum for a horizontal dashboard is a height of 120 pixels whereas a vertical orientation has a reasonable minimum width of 146. The sizeHint() can then be set to return the minimumSizeHint() and have the minimum return the (146, 120) for each of the dashboards2. In effect, this tells the application to prefer a minimum size of (146, 120) while still allowing for resizing in general.

    def sizeHint(self):
        return self.minimumSizeHint()
    
    def minimumSizeHint(self):
        return QtCore.QSize(146, 120)
    

    Granted, using fixed sizing can be dangerous, as absolutes are unforgiving and not flexible by definition. However, the content likely has a natural minimum size3. We can simply setMinimumSize() on the entire application to not allow resizing smaller than our minimumSizeHint().

    To change the direction of the dock widget content, we can use the dockLocationChanged signal. We can also make the code a little neater than how it was presented in the question. Rather than connect the signal within the dock widget, we can connect it at the instance level within MyWindow. In fact, there is no need to define MyDock at all. A plain QDockWidget will suffice.

    Docks with a minimal initial size

    import qtpy
    from qtpy import QtWidgets, QtGui, QtCore
    import sys
    
    
    class PrimaryDashboard(QtWidgets.QWidget):
    
        def __init__(self):
    
            super(PrimaryDashboard, self).__init__()
    
            self.init_widgets()
            self.init_layout()
    
        def init_widgets(self):
            self.label = QtWidgets.QLabel('Primary dashboard')
            self.ok = QtWidgets.QPushButton('OK')
            self.cancel = QtWidgets.QPushButton('Cancel')
    
        def init_layout(self):
            self.layout = QtWidgets.QHBoxLayout()
            self.layout.addWidget(self.label)
            self.layout.addWidget(self.ok)
            self.layout.addWidget(self.cancel)
            self.setLayout(self.layout)
    
        def setDirection(self, direction):
            self.layout.setDirection(direction)
    
        def sizeHint(self):
            return self.minimumSizeHint()
    
        def minimumSizeHint(self):
            return QtCore.QSize(146, 120)
    
    
    class SecondaryDashboard(QtWidgets.QWidget):
    
        def __init__(self):
    
            super(SecondaryDashboard, self).__init__()
    
            self.init_widgets()
            self.init_layout()
    
        def init_widgets(self):
            self.label = QtWidgets.QLabel('Secondary dashboard')
    
            self.descr1 = QtWidgets.QLabel('Thing 1')
            self.check1 = QtWidgets.QCheckBox()
    
            self.descr2 = QtWidgets.QLabel('Thing 2')
            self.check2 = QtWidgets.QCheckBox()
    
        def init_layout(self):
            self.layout = QtWidgets.QVBoxLayout()
    
            self.grid = QtWidgets.QGridLayout()
            self.grid.addWidget(self.descr1, 0, 0)
            self.grid.addWidget(self.check1, 0, 1)
            self.grid.addWidget(self.descr2, 1, 0)
            self.grid.addWidget(self.check2, 1, 1)
    
            self.layout.addWidget(self.label)
            self.layout.addLayout(self.grid)
            self.setLayout(self.layout)
    
        def setDirection(self, direction):
            self.layout.setDirection(direction)
    
        def sizeHint(self):
            return self.minimumSizeHint()
    
        def minimumSizeHint(self):
            return QtCore.QSize(146, 120)
    
    
    class DockContainer(QtWidgets.QStackedWidget):
    
        def __init__(self):
    
            super(DockContainer, self).__init__()
    
        def dock_location_changed(self, area):
            top    = QtCore.Qt.DockWidgetArea.TopDockWidgetArea
            bottom = QtCore.Qt.DockWidgetArea.BottomDockWidgetArea
    
            container_object = self.currentWidget()
    
            if area in [top, bottom]:
                container_object.setDirection(QtWidgets.QBoxLayout.LeftToRight)
            else:
                container_object.setDirection(QtWidgets.QBoxLayout.TopToBottom)
    
    
    class MyWindow(QtWidgets.QMainWindow):
    
        def __init__(self, parent=None):
            super(MyWindow, self).__init__(parent=parent)
    
            # Force minimumSize to ensure a sensible dashboard size
            self.setMinimumSize(QtCore.QSize(600, 400))
    
            self.init_widgets()
            self.init_layout()
    
        def init_widgets(self):
    
            self.tab_widget = QtWidgets.QTabWidget()
            self.tab1 = QtWidgets.QLabel('Primary content')
            self.tab2 = QtWidgets.QLabel('Secondary content')
            self.tab_widget.addTab(self.tab1, 'Primary')
            self.tab_widget.addTab(self.tab2, 'Secondary')
            self.tab_widget.currentChanged.connect(self.tab_selected)
    
            self.primary_dashboard = PrimaryDashboard()
            self.secondary_dashboard = SecondaryDashboard()
    
            self.dashboard = DockContainer()
            self.dashboard.addWidget(self.primary_dashboard)
            self.dashboard.addWidget(self.secondary_dashboard)
            self.dashboard.setCurrentWidget(self.primary_dashboard)
    
            self.dock = QtWidgets.QDockWidget()
            self.dock.setWidget(self.dashboard)
            self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.dock)
    
            # Connect signal at the main application level
            self.dock.dockLocationChanged.connect(self.dashboard.dock_location_changed)
    
        def init_layout(self):
            self.main_layout = QtWidgets.QVBoxLayout()
            self.main_layout.addWidget(self.tab_widget)
    
            self.main_widget = QtWidgets.QWidget()
            self.main_widget.setLayout(self.main_layout)
            self.setCentralWidget(self.main_widget)
    
        def tab_selected(self):
            tab_index = self.tab_widget.currentIndex()
            if self.tab_widget.tabText(tab_index) == 'Secondary':
                self.dashboard.setCurrentWidget(self.secondary_dashboard)
                self.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.dock)
            else:  # Primary
                self.dashboard.setCurrentWidget(self.primary_dashboard)
                self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.dock)
    
    
    if __name__ == '__main__':
        app = QtWidgets.QApplication(sys.argv)
    
        window = MyWindow()
        window.show()
        sys.exit(app.exec_())
    
    

    1. How one might implement such a resizeEvent to see who is resizing:

        def resizeEvent(self, event):
            print('resizeEvent for ', self, flush=True)
    

    2. A natural question is, "Why not simply set the sizeHint() to return the minimum size instead of calling minimumSizeHint()? The best response I have is, "It don't work that way." Setting only the sizeHint() doesn't resize the dock to the minimum as you might expect.

    The sizeHint() and minimumSizeHint() methods are virtual functions. My guess is that Qt has other functionality, which we're not privy to, that references these methods independently, requiring us to define things this way.

    3. If the content is a map, for example, it's unlikely the user would ever want the map to be 10px x 10px. Furthermore, it's a reasonable assumption that the user won't be working with a screen resolution less than 600 x 400 unless they're on mobile. But if you're developing for mobile using PySide or PyQt5, there are some important questions you should be asking yourself, such as "Why?".