Search code examples
pyqt5pyqtgraph

QGridLayout creates an incorrect layout in PyQt5


I am having an issue with QGridLayout using PyQt5. In my UI, I have two pyqtgraph PlotWidget(). One of them contains 5 different plots and the other one has only one. I want the PlotWidget() with 5 plots to be 5 times that of the other one. The following code configures everything for this plot size proportion:

grid_layout.addLayout(vlayout_signals, 0, 0, 2, 1)
grid_layout.addWidget(self.p1, 0, 1, 5, 1) # self.p1 has 5 plots in it.
grid_layout.addWidget(self.p2, 5, 1, 1, 1) # self.p2 has only 1 plot in it.
    

The problem is python does exactly the opposite. The widget with one plot becomes 5 times bigger. I don't know why. I wanted self.p1 to be at (row=0, col=1) and occupy 5 rows and 1 column, and self.p2 to be at (row=5, col=1) and only occupy 1 row and 1 column. For some reason, Python is not doing it (I'm using python 3.11).

Here is a minimal working example:

NFRAME = 200 * 6

import sys
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication, QLabel, QMainWindow, QVBoxLayout, QHBoxLayout, QPushButton, QWidget, QGridLayout, QComboBox, QSlider
from PyQt5.QtGui import QColor
import pyqtgraph as pg

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        grid_layout = QGridLayout()
        vlayout = QVBoxLayout()

        self.p1 = pg.PlotWidget()
        self.p2 = pg.PlotWidget()
        self.p1.plotItem.addLegend()
        self.p2.plotItem.addLegend()
        x = np.linspace(0, 2 * np.pi * 2, NFRAME)
        y1 = np.sin(2 * np.pi * 1 * x)
        y2 = 10 * np.sin(2 * np.pi * 2 * x) + 50
        y3 = 20 * np.sin(2 * np.pi * 3 * x) + 200
        y4 = 30 * np.sin(2 * np.pi * 4 * x) + 300
        y5 = 40 * np.sin(2 * np.pi * 5 * x) + 400

        self.ph1_y1 = self.p1.plot(x, y1, pen=pg.mkPen(QColor(255, 0, 0)), name="y1")
        self.ph1_y2 = self.p1.plot(x, y2, pen=pg.mkPen(QColor(255, 255, 0)), name="y2")
        self.ph1_y3 = self.p1.plot(x, y3, pen=pg.mkPen(QColor(0, 255, 255)), name="y3")
        self.ph1_y4 = self.p1.plot(x, y4, pen=pg.mkPen(QColor(255, 0, 255)), name="y4")
        self.ph1_y5 = self.p1.plot(x, y5, pen=pg.mkPen(QColor(102, 102, 255)), name="y5")
        self.ph2_y1 = self.p2.plot(x, 0 * x + 6)

        signal_select = QComboBox(self)
        signal_select.addItem("Item1")
        signal_select.addItem("Item2")

        signal_offset = QSlider(Qt.Orientation.Vertical)
        signal_gain = QSlider(Qt.Orientation.Vertical)
        hlayout_sliders = QHBoxLayout()
        hlayout_sliders.addWidget(signal_offset)
        hlayout_sliders.addWidget(signal_gain)

        hlayout_sliders_labels = QHBoxLayout()
        signal_offset_label = QLabel()
        signal_gain_label = QLabel()
        signal_offset_label.setText("Offset")
        signal_offset_label.setAlignment(Qt.AlignCenter)
        signal_gain_label.setText("Gain")
        signal_gain_label.setAlignment(Qt.AlignCenter)
        hlayout_sliders_labels.addWidget(signal_offset_label)
        hlayout_sliders_labels.addWidget(signal_gain_label)

        arduino_signal_reset = QPushButton("Reset")

        vlayout_signals = QVBoxLayout()
        vlayout_signals.addWidget(signal_select)
        vlayout_signals.addLayout(hlayout_sliders)
        vlayout_signals.addLayout(hlayout_sliders_labels)
        vlayout_signals.addWidget(arduino_signal_reset)

        grid_layout.addLayout(vlayout_signals, 0, 0, 2, 1)
        grid_layout.addWidget(self.p1, 0, 1, 5, 1)
        grid_layout.addWidget(self.p2, 5, 1, 1, 1)

        widget = QWidget()
        widget.setLayout(grid_layout)

        vlayout.addWidget(widget)
        self.setLayout(grid_layout)
        self.setCentralWidget(widget)
        self.setWindowTitle('My softwaree')


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

enter image description here


Solution

  • TL;DR

    Don't use spans to set the proportions between widgets, instead use setRowStretch(), or, better, nested layouts.

    Explanation

    A common misconception about QGridLayout is that using "gaps" in rows and columns would result in placing them at different positions, and, similarly, using bigger row/column spans would result in a bigger size available for the widget.
    That is not how the grid layout works, since its rows and columns have no specific size, nor they are directly considered for size ratios.

    ...At least, in theory.

    What happens in your case is related to the complex way both QGridLayout and PlotWidget behave and interact.

    When a layout-managed widget is shown for the first time (and whenever it's resized), it queries its layout, which in turn makes lots of internal computations for the items it manages, including considering their size hints, constraints and policies.
    Note that by "items" I mean widgets, spacers and even inner layouts (see QLayoutItem).

    QGridLayout is quite complex, and its behavior is not always intuitive, especially when dealing with complex widgets and layouts.

    Items in a grid layout are placed in "cells", and when computing geometries for those cells (and the items they occupy) the layout engine does, more or less, the following:

    • query each layout item (widget, spacer or nested layout) about their size hints, constraints and policies;
    • correlate those items with the cells they occupy;
    • compute each item final geometry, which is based on the item x and y, and width and height of the last row/column they occupy;
    • add the last row/height coordinates to the width/height of the item, minus the initial x/y;
    • compute the internal required geometry of the item, considering the contents of the area occupied by the item;
    • set the geometry of the items, and eventually do everything again in case those items react to their resizing by updating their hints (which happens for "size reactive" widgets like QLabel or those that implement heightForWidth());

    While this generally works, it has an important drawback: if the chosen span doesn't include other items in the same rows or columns, the resulting widths and heights are possibly "nullified" (meaning that their possible size is the minimum), especially whenever any widget has an Expanding size policy, which is the case of PlotWidget, which inherits from QAbstractScrollArea (since it's based on QGraphicsView), that by default always tries to expand its contents whenever there is space left from other widgets that don't expand theirselves.

    Note that things are actually even more complex, as other widgets that by default have Expanding size policies might not give the same result (ie. QScrollArea). This answer is already quite extended, and I won't go too much into the darkness of Qt size management... The problem is mainly caused by a wrong usage of spans: if you want to know more, I suggest you to take your time to patiently study the Qt sources.

    Take this basic example:

    from PyQt5.QtWidgets import *
    
    class Test(QWidget):
        def __init__(self):
            super().__init__()
            self.setStyleSheet('''
                QLabel {
                    border: 1px solid red;
                }
            ''')
            layout = QGridLayout(self)
            w1 = QLabel('Row span=2')
            w1.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
            w2 = QLabel('Row span=1')
            w2.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
            layout.addWidget(w1, 0, 0, 2, 1)
            layout.addWidget(w2, 2, 0, 1, 1)
    
    app = QApplication([])
    example = Test()
    example.show()
    sys.exit(app.exec_())
    

    Which will result conceptually like your case:

    Screenshot of the span issue

    This behavior may seem invalid and certainly not perfect, but the point remains: as mentioned above, the purpose of spans is not to directly suggest a size for the item; using something in the wrong way often results in unexpected behavior.

    This is one of the reasons for which I generally don't use (and often discourage) QGridLayout for complex UIs: most of the times that I decide for a QGridLayout, I end up with dismantling it and use nested layouts instead.

    Solutions

    If you want your widgets to have proportional sizes, then you have two choices. Either use QGridLayout properly (with item based rows/columns and proper stretch factors), or use nested layouts.

    In your case, keeping a grid layout requires you to:

    • use two rows, with each plot widget taking just one row;
    • use setRowStretch() for both rows, with a value of 5 for the first, and 1 for the second;
    • use a row span of 2 for the left panel;
    • add a stretch to the bottom of the left panel layout, so that its contents are "pushed" to the top;
            vlayout_signals.addStretch()
            grid_layout.addLayout(vlayout_signals, 0, 0, 2, 1)
            grid_layout.addWidget(self.p1, 0, 1)
            grid_layout.addWidget(self.p2, 1, 1)
            grid_layout.setRowStretch(0, 5)
            grid_layout.setRowStretch(1, 1)
    

    With a nested layout, instead:

    • use a HBoxLayout as main layout;
    • create a vertical layout for the plot widgets, and add them using the stretch argument;
    • add a stretch to the left panel as above;
            main_layout = QHBoxLayout(self)
            vlayout_signals.addStretch()
            main_layout.addLayout(vlayout_signals)
            plot_layout = QVBoxLayout()
            main_layout.addLayout(plot_layout)
            plot_layout.addWidget(self.p1, stretch=5)
            plot_layout.addWidget(self.p2, stretch=1)
    

    Note: all QLayouts have an optional QWidget argument that automatically installs the layout on the given widget, thus avoiding calling layout.setLayout(widget).

    I would strongly suggest you this last solution, as it's more consistent with widgets that will probably have no size relations: the moment you need to add more plots, you don't have to care about the row span of the panel.

    Interestingly enough, the only case for which a grid layout would have really made sense is the slider/label pair: instead of adding two unrelated horizontal layouts, you should have used a grid for them. You were "lucky" because those labels are relatively small and similar in widths, but the reality is that they are not properly aligned with their corresponding sliders. Try to add the following:

            self.setStyleSheet('QSlider, QLabel {border: 1px solid red;}')
    

    And that's the result:

    wrong alignment

    If those label had very different lengths, their offset would have been very confusing.

    Conclusions

    While QGridLayout is certainly useful, it's important to consider that it can rarely be used and relied upon as "main layout" that directly manages items that are very different in their "visual" purpose; its row/column cells are just abstract references to item positions, not their resulting sizes, and spans must always be used only according to the other contents in the corresponding rows and columns.

    Complex UIs almost always require nested layout managers that group their contents based on the hierarchy of their usage.

    The rule of thumb is that a layout has to be considered for items that are grouped within the same hierarchy. You can certainly use QGridLayout as a main layout, but only as long as it manages items that can be considered siblings. For instance, you could consider a grid layout like this:

    ┌─────────────┬─────────────────────────┐
    |┌───────────┐|┌───────────────────────┐|
    ||   combo   |||                       ||
    ||┌────┬────┐|||         plot1         ||
    |||    |    ||||                       ||
    ||| s1 | s2 |||└───────────────────────┘|
    |||    |    |||┌───────────────────────┐|
    ||├────┼────┤|||                       ||
    ||| l1 | l2 ||||                       ||
    ||└─────────┘|||                       ||
    ||    btn    |||                       ||
    ||     ^     |||         plot2         ||
    ||     ║     |||                       ||
    ||  stretch  |||                       ||
    ||     ║     |||                       ||
    ||     v     |||                       ||
    |└───────────┘|└───────────────────────┘|
    ├─────────────┴─────────────────────────┤
    |             something else            |
    └───────────────────────────────────────┘
    

    Which would be written as follows:

    main_layout = QGridLayout()
    
    panel_layout = QVBoxLayout()
    main_layout.addLayout(panel_layout)
    
    panel_layout.addWidget(combo)
    
    slider_layout = QGridLayout()
    panel_layout.addLayout(slider_layout)
    slider_layout.addWidget(s1)
    slider_layout.addWidget(s2, 0, 1)
    slider_layout.addWidget(l1, 1, 0)
    slider_layout.addWidget(l2, 1, 0)
    
    panel_layout.addWidget(btn)
    panel_layout.addStretch()
    
    plot_layout = QVBoxLayout()
    main_layout.addLayout(plot_layout, 0, 1)
    plot_layout.addWidget(plot1)
    plot_layout.addWidget(plot2)
    
    main_layout.addWidget(else, 1, 0, 1, 2)
    

    I suggest you to take your time to experiment with Qt Designer, in order to get accustomed with layout managers and different widget types, policies or restraints.