pythonqtpyqtpyqt6

Problem nesting QT layouts when using if <QT layout> statement


For my application I created a Window class, I minimized my code as much as possible so that there would be nothing unrelated.

The code is below:

import sys
from PyQt6.QtWidgets import (QApplication, QWidget, QLabel, QLineEdit, QLayout,
                             QPushButton, QVBoxLayout, QHBoxLayout, QMessageBox)
from PyQt6.QtCore import Qt

class Window(QWidget):
    def __init__(self, name, width=640, height=480, horizontal_layout=False):
        super().__init__()
        self.setWindowTitle(name)
        self.setGeometry(100, 100, width, height)
        self.main_layout = QHBoxLayout() if horizontal_layout else QVBoxLayout()
        self.setLayout(self.main_layout)
        self.widget_list = list()

    def add_layout(self):
        layout = QVBoxLayout()
        self.main_layout.addLayout(layout)
        return layout

    def add_label(self, text, layout=None):
        label = QLabel(text)
        self.add_widget(label, layout)
        return label

    def add_button(self, text, callback=None, layout=None):
        layout = layout if layout else self.main_layout
        button = QPushButton(text)
        if callback:
            button.clicked.connect(callback)
        self.add_widget(button, layout)
        return button
    
    def add_widget(self, widget, layout=None):
        layout = layout if layout else self.main_layout
        if not hasattr(layout, 'widgets_list'):
            layout.widgets_list = list()
        layout.widgets_list.append(widget)
        layout.addWidget(widget)
                
app = QApplication([])

main = Window("interface", horizontal_layout=True)
col1, col2 = main.add_layout(), main.add_layout()

main.add_label("Label 1", layout=col1)
main.add_button("Button 1", layout=col1)

main.add_label("Label 2", layout=col2)
main.add_button("Button 2", layout=col2)


main.show()
sys.exit(app.exec())

to cut to the chase, I wanted something like on this pic:

What I wanted drawn in paint:

expected result

however, this is the result - the columns do not stack things vertically like I wanted them to, so things just get into a single row in the window:

actual result

in attempts to fix it, I had some version of the code using the addStretch method to the row layout and wrapping all qvboxlayouts inside qwidgets, it kinda fixed the problem, but all the columns's contents sank to the bottom and absolutely nothing worked to fix it.

I would really like some feedback, if there are alternative decisions to this that do not involve qhboxlayout (for example, if you can wrap it and add some css, tho I dont think display flex would work here) or something else - it would be great. I don't know about using the grid layout though because I really want to specifically find the problem in my code.


Solution

  • tl;dr

    When doing comparisons to singletons like None, you should always use is or is not, not the simple if or if not. Your code doesn't work as expected because if layout does not tell whether layout is None or not.

    Those lines should be changed so that the layout reference is set to self.main_layout only if layout is None:

    def someFunction(self, ..., layout=None):
        if layout is None:
            layout = self.main_layout
        ...
    

    Explanation

    In PyQt, the if <PyQt-object>: syntax does not always result in checking whether <PyQt-object> is None or not. If, for instance, <PyQt-object> is a Qt layout, it checks if the layout contains at least one QLayoutItem, which is an abstract object used to manage the geometry of widgets, spacers or nested layouts within that layout.

    Truthfulness of an object or expression

    Remember that if is a conditional statement that evaluates the "truthfulness" of an expression: if <something>: evaluates if <something> is true or false. If the <something> is an object, then the expression is evaluated through Truth Value Testing:

    By default, an object is considered true unless its class defines either a __bool__() method that returns False or a __len__() method that returns zero, when called with the object.

    If an object (typically, a class) does not implement those methods, if <object>: will enter the block of that statement because the object "exists" (it is "something" that is not "none").
    If it does implement one of those functions, it will enter that block only if one those functions return True or not zero, respectively.

    Those are convenience implementations (commonly called "syntactic sugar") allowing things such as if <someObject>:

    if someList:
        ... # the list has at least one item
    if someString:
        ... # the string is not empty
    
    # as opposed to
    if len(someList):
        ...
    if someString != '':
        ...
    

    PyQt implementation

    Historically, PyQt implements some built-in dunder methods (instance methods that start and end with a double underscore, such as __init__) used in the data model when certain conditions are met for the class.

    Typically, if a Qt class has a count() function, it normally means that it can contain some items in a mono-dimensional data model (a "list"). This is the case of classes such as, for instance: QListWidget, QComboBox, QPolygon[F], and, interestingly enough, layout managers (including layouts that do not "show" items in a mono-dimensional concept, which is the case of QGridLayout).

    In most cases, when a class implements count(), PyQt also implements the __len__() method, internally returning the result of count().

    Remember the "Truth Value Testing" above: if <object>: means that an object is considered true, unless the class defines either __bool__() that returns False, or a __len__() returning zero.

    If a class does contain a count() function, PyQt implements __len__() and returns the result of count(). Consider the following:

    combo = None
    if not combo:
        # this will be printed
        print('no combo')
    
    combo = QComboBox()
    if not combo:
        # this will be printed as well!
        print('the combo exists, but it is empty!')
    

    Another example of these convenience implementations is for classes that have a contains() function, such as QRect, which implements __contains__() in PyQt; in that case, using the in operator returns the result of the various QRect::contains() function overrides:

    point = QPoint(1, 2)
    rect = QRect(0, 0, 10, 10)
    if point in rect:
        ... # the rectangle contains the point
    
    # as opposed to
    
    if rect.contains(point):
        ...
    

    Note that these convenience implementations are only available in PyQt, not PySide.
    If you plan to release your program allowing the possibility of using either of the bindings (such as using shims like QtPy or Qt.py) you cannot use that syntax, and you must follow the official Qt API.

    Truthfulness of a Qt layout

    Qt layout managers (subclasses of QLayout) always have a list of their items, it doesn't matter how they're eventually placed or shown in the UI.

    Even though QGridLayout shows its items in rows and columns, it still has an internal list of items, and its count() function returns the length of that list.

    This means that, in PyQt, if layout may evaluate as False if the layout is empty (therefore the block within if layout: won't be executed), even though a QLayout object does exist.

    Proper comparison against None and better syntax

    As written at the beginning, a reference that may be None should never be tested with a simple if <object>: unless you really know what you're doing.

    Consider the post if A vs if A is not None:, and the following important note in the Programming Recommendations in the Style Guide for Python Code:

    Comparisons to singletons like None should always be done with is or is not, never the equality operators.

    Also, beware of writing if x when you really mean if x is not None – e.g. when testing whether a variable or argument that defaults to None was set to some other value. The other value might have a type (such as a container) that could be false in a boolean context!

    In a case like yours, not only it would be mandatory to use the is None (or is not None) testing due to the PyQt implementation explained above, but it would be more appropriate in principle: the usage of None for keyworded (default) arguments means that the argument can be explicitly used with None (to follow the default behavior) and with anything that may not compare to None, as the "Truth Value Testing" explains: if <object>: may give the same result even if that object is 0, an empty string, False, an empty list and any other object that may implement __bool__ or __len__ even though the object is not None.

    Your code could be changed in the following way:

    layout = layout if layout is not None else self.main_layout
    

    Yet, while sometimes necessary, there is usually no point in overwriting the reference with itself if the object is considered valid. The above would be fundamentally identical to:

    if layout is not None:
        layout = layout
    else:
        layout = self.main_layout
    

    You can probably spot the redundancy in the second line: it's completely pointless.

    You probably choose the x if x else y "one-liner" approach for code simplicity, but in reality it's usually inefficient and irrelevant.

    Now, consider this approach, using a slightly different syntax while giving an absolutely identical result:

    layout = self.main_layout if layout is None else layout
    

    It's slightly simpler, and sometimes preferable. Yet, let's see the more verbose version without the one-liner:

    if layout is None:
        layout = self.main_layout
    else:
        layout = layout
    

    It's clear that the whole else: block is completely pointless.

    This means that you can (and should!) change those lines with the following:

    def someFunction(self, ..., layout=None):
        if layout is None:
            layout = self.main_layout
        ...
    

    Yes, those are two lines, not one. But they are more appropriate.

    Note that Python does allow one-liners. The following would be completely legal:

    def someFunction(self, ..., layout=None):
        if layout is None: layout = self.main_layout
        ...
    

    Yet, such a syntax is normally discouraged: the block indentation requirement of Python is not just about code fanciness, but readability, which is an extremely important aspect of programming.

    The last snippet above will only give extremely small benefits when the code is being parsed from the Python interpreter, but you'd need to have thousands of similar one-liners to see any relevant difference, while still having thousands of less readable lines that will make your code more difficult to read and understand, both while debugging and reviewing (especially if returning to code written a lot of time before).

    Remember: don't code "short", code smart.