Search code examples
pythonpysidepyside6qlabelqlayout

Pyside6, how to constrain layout width from its content?


I'm trying to create a widget containing 4 labels displaying text, and one label displaying an image. Main layout is a QVBoxLayout containing :

  • a QHBoxLayout with the two text label
  • the Label for the image
  • a QHBoxLayout with the two last label

I customized the QLabel for the image in order to handle rescaling while maintaining initial aspect ratio.

What I'd like is to constraint the two QHBoxLayout so it's the same width as the Image displayed in the center when I'm resizing the Widget => the text should always stick to the image corners

Expected behavior Actual behavior
expected actual

I tried that using size hints and size policy, but without any success. How could I do that?

Here is the code I have for now :

import sys

from PySide6.QtCore import Qt
from PySide6.QtGui import QImage, QPixmap, QResizeEvent
from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget


class Thumbnail(QWidget):
    def __init__(self, image_preview: QPixmap, metadata_1: str, metadata_2: str, metadata_3: str, metadata_4: str):
        super().__init__()

        # infos settings
        metadata_1_label = QLabel()
        metadata_1_label.setText(metadata_1)
        metadata_1_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom)
        metadata_2_label = QLabel()
        metadata_2_label.setText(metadata_2)
        metadata_2_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignBottom)

        image = ScaledImageLabel()
        image.setPixmap(image_preview)
        image.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter)

        metadata_3_label = QLabel()
        metadata_3_label.setText(metadata_3)
        metadata_3_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
        metadata_4_label = QLabel()
        metadata_4_label.setText(metadata_4)
        metadata_4_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop)

        # layout organization
        top_metadata_layout = QHBoxLayout()
        top_metadata_layout.addWidget(metadata_1_label)
        top_metadata_layout.addWidget(metadata_2_label)

        bot_metadata_layout = QHBoxLayout()
        bot_metadata_layout.addWidget(metadata_3_label)
        bot_metadata_layout.addWidget(metadata_4_label)

        main_layout = QVBoxLayout()
        main_layout.addLayout(top_metadata_layout)
        main_layout.addWidget(image)
        main_layout.addLayout(bot_metadata_layout)
        main_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)

        self.setLayout(main_layout)


class ScaledImageLabel(QLabel):
    def __init__(self):
        super().__init__()
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
        self.setMinimumSize(100, 100)

    def resizeEvent(self, event: QResizeEvent):
        self.setPixmap(self._original_pixmap)
        return super().resizeEvent(event)

    def setPixmap(self, arg__1: QPixmap | QImage | str):
        self._original_pixmap = arg__1
        QLabel.setPixmap(self, self._original_pixmap.scaled(self.frameSize(), Qt.AspectRatioMode.KeepAspectRatio))


def main():
    app = QApplication(sys.argv)

    dummy_preview = QPixmap("tests\\data\\png_data\\IMG_6867.png")
    window = Thumbnail(dummy_preview, "Once", "Upon", "A", "Time")
    window.show()

    app.exec()


if __name__ == "__main__":
    sys.exit(main())


Solution

  • That's tricky, and requires some understanding of the layout issues: there is fundamentally no standard way to get a complex widget to always respect a given aspect ratio, especially if the size of that widget is required to lay out other widgets and set a proper size for the top level window.

    In your case, the image resizing only works because it has an expanding policy that allows it to take all available space: the result is that the QLabel showing the image always has a size equal or bigger than the finally shown image.

    If you add the following line to your code, you will see that the image label always takes much more space than the image requires whenever the window size has one dimension that is greater than the image aspect ratio would give:

        window.setStyleSheet('QLabel { border: 1px solid red;}')
    

    This is unfortunately irrelevant for the parent layout, because it doesn't know anything about the visual contents of the label: the other widgets are placed based on its real size, and that's why the labels are placed "outside" of the horizontal image boundaries. Note that this would also happen if your layout placed the labels at the sides of the image and the window height was much bigger than its ratio would need.

    That is a commonly debated issue with widget layouts (not only for Qt): there is no absolute way to have widgets that have a constant aspect ratio, and each case requires different handling depending on the situation.
    People often argue about this issue replying that web browsers are capable of such feature, but that's a misconception: browsers are, by nature, scrollable areas, and that aspect makes actually easy to resize widgets while keeping a possible aspect ratio: since the viewport is theoretically infinite, there's always available space to allow that. Windowed UIs don't have such luxury.

    A commonly suggested solution to this is to create a custom QLayout subclass, but this "simple" situation makes that an unnecessary complication: since the logical layout of objects is known, it can be achieved by manually laying out those objects within a QWidget subclass.

    Note that in the following code I completely got rid of the image label, as it's unnecessary for this implementation and only adds complexity we can clearly avoid. Also be aware that, while not strictly problematic in your case (indirectly "helped" by the expanding policy), calling functions that may alter the size of a widget (including setPixmap()) within a resizeEvent() is discouraged, as it may result in partial if not infinite recursion. You can probably notice the issue if you remove the setSizePolicy() line and try to instantly resize the window (for instance, by maximizing it), which may show some kind of "progress" in the image resizing, until it reaches the final size.

    Also, your code only works as soon as setPixmap() is actually called before the ScaledImageLabel is shown or added to an already active layout, but if that didn't happen, that would cause a crash since no _original_pixmap attribute existed. You must be really careful when implementing event handlers like resizeEvent() if they rely on dynamic properties and attributes that may be created at runtime.

    Now, the concept is to consider the sizes of the labels as reference for the minimum size, and eventually add further margins/spacings. This is necessary for two aspects:

    • properly return valid sizes (the minimum and the hint);
    • actually set all the geometries of each objects: the labels, and the image;

    The minimum size and size hints for the widget are based on the sizes of the labels, and eventually expanded, depending on the requested purpose: since you were using a layout, I considered the default layout margins and spacings of the current style (so that it will look like a "real" Qt layout), then I added the minimum size you set for the image label for the minimum overall size, and the actual image size for the regular hint.
    For instance (and assuming that the pixmap is valid), if the image width with added layout borders is smaller than the "minimum" size, it will allow resizing to smaller dimensions, while the size hint will always try to respect the original image size, which is what Qt will try to use when the program is first shown. Be aware, though, that Qt always provides a size hint for the top level widget that is at most 2/3 of the screen in which it is being shown: a bigger size hint will always be bound to that computed size.

    The geometry of all objects is finally computed in a similar way within the resizeEvent().
    We get the minimum size required for all the labels, subtract it from the actual widget size to get the remaining space for the image, then we set the final geometry of the image and compute the new geometries of each label based on that: the geometry of the top left label is placed above the top left corner of the image, and so on.
    The call to setMinimumSize() is required since we're not using an actual QLayout.

    Finally, the paintEvent() will consider the pixmap rect set within resizeEvent() in order to properly paint the image at its correct geometry.
    Note that, unlike your resizeEvent(), while the existence of that rectangle attribute is a "guessed" assumption, that's also a valid and educated guess, since widgets always receive a resizeEvent() before being shown and finally painted.

    Here are the results, depending on the window size, using a source image of 512x512.

    Original size (as shown upon program start)

    initial window

    Size width greater than height

    window resized as wider

    Size height greater than width

    window resized as taller

    class Thumbnail(QWidget):
        def __init__(self, image_preview: QPixmap, metadata_1: str, metadata_2: str, metadata_3: str, metadata_4: str):
            super().__init__()
            self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
    
            self.tlLabel = QLabel(metadata_1, self)
            self.trLabel = QLabel(metadata_2, self)
            self.blLabel = QLabel(metadata_3, self)
            self.brLabel = QLabel(metadata_4, self)
            self.labels = (self.tlLabel, self.trLabel, self.blLabel, self.brLabel)
    
            self.pixmap = QPixmap(image_preview)
    
            self.setMinimumSize(self.minimumSizeHint())
    
        def changeEvent(self, event):
            super().changeEvent(event)
            if event.type() == event.Type.StyleChange:
                self.setMinimumSize(self.minimumSizeHint())
                self.updateGeometry()
    
        def labelHints(self):
            return (l.sizeHint() for l in self.labels)
    
        def styleLayoutData(self):
            style = self.style()
            return (
                style.pixelMetric(style.PixelMetric.PM_LayoutLeftMargin), 
                style.pixelMetric(style.PixelMetric.PM_LayoutHorizontalSpacing), 
                style.pixelMetric(style.PixelMetric.PM_LayoutRightMargin), 
                style.pixelMetric(style.PixelMetric.PM_LayoutTopMargin), 
                style.pixelMetric(style.PixelMetric.PM_LayoutVerticalSpacing), 
                style.pixelMetric(style.PixelMetric.PM_LayoutBottomMargin), 
            )
    
        def hintData(self):
            tl, tr, bl, br = self.labelHints()
            minWidth = max((tl + tr).width(), (bl + br).width())
            minHeight = max((tl + bl).height(), (tr + br).height())
    
            left, hs, right, top, vs, bottom = self.styleLayoutData()
    
            return (
                QSize(minWidth + hs, minHeight + vs * 2), 
                QSize(left + right, top + bottom)
            )
    
        def minimumSizeHint(self):
            hint, margins = self.hintData()
            if not self.pixmap.isNull():
                hint = self.pixmap.size().boundedTo(QSize(100, 100))
            else:
                hint += QSize(100, 100)
            return hint + margins
    
        def sizeHint(self):
            hint, margins = self.hintData()
            if not self.pixmap.isNull():
                pmSize = QSize(100, 100).expandedTo(self.pixmap.size() + margins)
                hint = QSize(
                    max(hint.width(), pmSize.width()), 
                    hint.height() + pmSize.height()
                )
            else:
                hint += QSize(100, 100) + margins
            return hint
    
        def resizeEvent(self, event):
            tl, tr, bl, br = self.labelHints()
            minWidth = max((tl + tr).width(), (bl + br).width())
            minHeight = max((tl + bl).height(), (tr + br).height())
    
            left, hs, right, top, vs, bottom = self.styleLayoutData()
    
            available = self.size()
            pmWidth = max(minWidth, available.width() - (left + right))
            pmHeight = available.height() - (top + bottom + minHeight + vs * 2)
    
            if self.pixmap.isNull():
                pmSize = QSize(100, 100).scaled(
                    pmWidth, pmHeight, Qt.AspectRatioMode.KeepAspectRatio)
            else:
                pmSize = self.pixmap.size().scaled(
                    pmWidth, pmHeight, Qt.AspectRatioMode.KeepAspectRatio)
    
            self.pmRect = QRect(0, 0, pmSize.width(), pmSize.height())
            self.pmRect.moveCenter(self.rect().center())
    
            tlGeo = QRect(QPoint(), tl)
            tlGeo.moveBottomLeft(QPoint(self.pmRect.x(), self.pmRect.y() - vs))
            self.tlLabel.setGeometry(tlGeo)
            trGeo = QRect(QPoint(), tr)
            trGeo.moveBottomRight(QPoint(self.pmRect.right(), self.pmRect.y() - vs))
            self.trLabel.setGeometry(trGeo)
            blGeo = QRect(QPoint(), bl)
            blGeo.moveTopLeft(QPoint(self.pmRect.x(), vs + self.pmRect.bottom()))
            self.blLabel.setGeometry(blGeo)
            brGeo = QRect(QPoint(), br)
            brGeo.moveTopRight(QPoint(self.pmRect.right(), vs + self.pmRect.bottom()))
            self.brLabel.setGeometry(brGeo)
    
        def paintEvent(self, event):
            qp = QPainter(self)
            if self.pixmap.isNull():
                qp.drawRect(self.pmRect)
                pixmap = QPixmap(':/qt-project.org/styles/commonstyle/images/file-16.png')
                if pixmap.isNull():
                    return
                imgRect = pixmap.rect()
                imgRect.moveCenter(self.rect().center())
            else:
                pixmap = self.pixmap
                imgRect = self.pmRect
            qp.setRenderHint(qp.RenderHint.SmoothPixmapTransform)
            qp.drawPixmap(imgRect, pixmap)
    

    As you can see, this is quite complex.

    It can be made a bit simpler if you don't consider style margins, and if you completely get rid of the labels (by using QFontMetrics to get text size hints and QPainter to directly draw the text).
    Still, the issue remains: respecting the aspect ratio for a windowed widget is never an easy matter.