Search code examples
pythonpython-3.xpyqtpyqt5qgridlayout

How to resize square children widgets after parent resize in Qt5?


I want to do board with square widgets. When I run code it creates nice board but after resize it become looks ugly. I am trying resize it with resize Event but it exists (probably some errors). I have no idea how to resize children after resize of parent.

Children widgets must be squares so it is also problem since I can not use auto expand. Maybe it is simple problem but I can not find solution. I spend hours testing different ideas but it now works as it should.

This what I want resize (click maximize): enter image description here

After maximize it looks ugly (I should change children widget but on what event (I think on resizeEvent but it is not works) and how (set from parent or children cause program exit).

enter image description here

This is my minimize code:

import logging
import sys

from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QSize
from PyQt5.QtGui import QFont, QPaintEvent, QPainter
from PyQt5.QtWidgets import QApplication, QWidget, QGridLayout


class Application(QApplication):
    pass


class Board(QWidget):
    def square_size(self):
        size = self.size()
        min_size = min(size.height(), size.width())
        min_size_1_8 = min_size // 8
        square_size = QSize(min_size_1_8, min_size_1_8)
        logging.debug(square_size)
        return square_size

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

        square_size = self.square_size()

        grid = QGridLayout()
        grid.setSpacing(0)

        squares = []
        for x in range(8):
            for y in range(8):
                square = Square(self, (x + y - 1) % 2)
                squares.append(squares)
                square.setFixedSize(square_size)
                grid.addWidget(square, x, y)
        self.squares = squares
        self.setLayout(grid)

    def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
        # how to resize children?
        logging.debug('Resize %s.', self.__class__.__name__)
        logging.debug('Size %s.', event.size())
        super().resizeEvent(event)


class Square(QWidget):
    def __init__(self, parent, color):
        super().__init__(parent=parent)
        if color:
            self.color = QtCore.Qt.white
        else:
            self.color = QtCore.Qt.black

    def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
        logging.debug('Resize %s.', self.__class__.__name__)
        logging.debug('Size %s.', event.size())
        super().resizeEvent(event)

    def paintEvent(self, event: QPaintEvent) -> None:
        painter = QPainter()
        painter.begin(self)
        painter.fillRect(self.rect(), self.color)
        painter.end()


def main():
    logging.basicConfig(level=logging.DEBUG)
    app = Application(sys.argv)
    app.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True)

    default_font = QFont()
    default_font.setPointSize(12)
    app.setFont(default_font)

    board = Board()
    board.setWindowTitle('Board')
    # ugly look
    # chessboard.showMaximized()
    # looks nize but resize not works
    board.show()

    sys.exit(app.exec())


if __name__ == '__main__':
    main()

How should I do resize of square children to avoid holes?

2nd try - improved code but still I have not idea how to resize children

Some new idea with centering it works better (no gaps now) but still I do not know how to resize children (without crash).

After show():

enter image description here

Too wide (it keeps proportions):

enter image description here

Too tall (it keeps proportions):

enter image description here

Larger (it keeps proportions but children is not scaled to free space - I do not know how to resize children still?): enter image description here

Improved code:

import logging
import sys

from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QSize
from PyQt5.QtGui import QFont, QPaintEvent, QPainter
from PyQt5.QtWidgets import QApplication, QWidget, QGridLayout, QHBoxLayout, QVBoxLayout


class Application(QApplication):
    pass


class Board(QWidget):
    def square_size(self):
        size = self.size()
        min_size = min(size.height(), size.width())
        min_size_1_8 = min_size // 8
        square_size = QSize(min_size_1_8, min_size_1_8)
        logging.debug(square_size)
        return square_size

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

        square_size = self.square_size()

        vertical = QVBoxLayout()
        horizontal = QHBoxLayout()

        grid = QGridLayout()
        grid.setSpacing(0)

        squares = []
        for x in range(8):
            for y in range(8):
                square = Square(self, (x + y - 1) % 2)
                squares.append(squares)
                square.setFixedSize(square_size)
                grid.addWidget(square, x, y)
        self.squares = squares

        horizontal.addStretch()
        horizontal.addLayout(grid)
        horizontal.addStretch()
        vertical.addStretch()
        vertical.addLayout(horizontal)
        vertical.addStretch()
        self.setLayout(vertical)

    def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
        # how to resize children?
        logging.debug('Resize %s.', self.__class__.__name__)
        logging.debug('Size %s.', event.size())
        super().resizeEvent(event)


class Square(QWidget):
    def __init__(self, parent, color):
        super().__init__(parent=parent)
        if color:
            self.color = QtCore.Qt.white
        else:
            self.color = QtCore.Qt.black

    def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
        logging.debug('Resize %s.', self.__class__.__name__)
        logging.debug('Size %s.', event.size())
        super().resizeEvent(event)

    def paintEvent(self, event: QPaintEvent) -> None:
        painter = QPainter()
        painter.begin(self)
        painter.fillRect(self.rect(), self.color)
        painter.end()


def main():
    logging.basicConfig(level=logging.DEBUG)
    app = Application(sys.argv)
    app.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True)

    default_font = QFont()
    default_font.setPointSize(12)
    app.setFont(default_font)

    board = Board()
    board.setWindowTitle('Board')
    # ugly look
    # chessboard.showMaximized()
    # looks nice but resize not works
    board.show()

    sys.exit(app.exec())


if __name__ == '__main__':
    main()

How should I resize square children without crash?


Solution

  • There are two possible solution.
    You can use the Graphics View framework, which is intended exactly for this kind of applications where custom/specific graphics and positioning have to be taken into account, otherwise create a layout subclass. While reimplementing a layout is slightly simple in this case, you might face some issues as soon as the application becomes more complex. On the other hand, the Graphics View framework has a steep learning curve, as you'll need to understand how it works and how object interaction behaves.

    Subclass the layout

    Assuming that the square count is always the same, you can reimplement your own layout that will set the correct geometry based on its contents.

    In this example I also created a "container" with other widgets to show the resizing in action.

    When the window width is very high, it will use the height as a reference and center it horizontally: window very wide

    On the contrary, when the height is bigger, it will be centered vertically: window very tall

    Keep in mind that you should not add other widgets to the board, otherwise you'll get into serious issues.
    This would not be impossible, but its implementation might be much more complex, as the layout would need to take into account the other widgets positions, size hints and possible expanding directions in order to correctly compute the new geometry.

    from PyQt5 import QtCore, QtGui, QtWidgets
    
    class Square(QtWidgets.QWidget):
        def __init__(self, parent, color):
            super().__init__(parent=parent)
            if color:
                self.color = QtCore.Qt.white
            else:
                self.color = QtCore.Qt.black
            self.setMinimumSize(50, 50)
    
        def paintEvent(self, event: QtGui.QPaintEvent) -> None:
            painter = QtGui.QPainter(self)
            painter.fillRect(self.rect(), self.color)
    
    
    class EvenLayout(QtWidgets.QGridLayout):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.setSpacing(0)
    
        def setGeometry(self, oldRect):
            # assuming that the minimum size is 50 pixel, find the minimum possible
            # "extent" based on the geometry provided
            minSize = max(50 * 8, min(oldRect.width(), oldRect.height()))
            # create a new squared rectangle based on that size
            newRect = QtCore.QRect(0, 0, minSize, minSize)
            # move it to the center of the old one
            newRect.moveCenter(oldRect.center())
            super().setGeometry(newRect)
    
    
    class Board(QtWidgets.QWidget):
        def __init__(self):
            super().__init__()
            self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
            layout = EvenLayout(self)
            self.squares = []
            for row in range(8):
                for column in range(8):
                    square = Square(self, not (row + column) & 1)
                    self.squares.append(square)
                    layout.addWidget(square, row, column)
    
    
    class Chess(QtWidgets.QWidget):
        def __init__(self):
            super().__init__()
            layout = QtWidgets.QGridLayout(self)
            header = QtWidgets.QLabel('Some {}long label'.format('very ' * 20))
            layout.addWidget(header, 0, 0, 1, 3, QtCore.Qt.AlignCenter)
            self.board = Board()
            layout.addWidget(self.board, 1, 1)
    
            leftLayout = QtWidgets.QVBoxLayout()
            layout.addLayout(leftLayout, 1, 0)
            rightLayout = QtWidgets.QVBoxLayout()
            layout.addLayout(rightLayout, 1, 2)
            for b in range(1, 9):
                leftLayout.addWidget(QtWidgets.QPushButton('Left Btn {}'.format(b)))
                rightLayout.addWidget(QtWidgets.QPushButton('Right Btn {}'.format(b)))
    
            footer = QtWidgets.QLabel('Another {}long label'.format('very ' * 18))
            layout.addWidget(footer, 2, 0, 1, 3, QtCore.Qt.AlignCenter)
    
    
    if __name__ == '__main__':
        import sys
        app = QtWidgets.QApplication(sys.argv)
        w = Chess()
        w.show()
        sys.exit(app.exec_())
    

    Using the Graphics View

    The result will be visually identical to the previous one, but while the overall positioning, drawing and interaction would be conceptually a bit easier, understanding how Graphics Views, Scenes and objects work might require you some time to get the hang of it.

    from PyQt5 import QtCore, QtGui, QtWidgets
    
    
    class Square(QtWidgets.QGraphicsWidget):
        def __init__(self, color):
            super().__init__()
            if color:
                self.color = QtCore.Qt.white
            else:
                self.color = QtCore.Qt.black
    
        def paint(self, qp, option, widget):
            qp.fillRect(option.rect, self.color)
    
    
    class Scene(QtWidgets.QGraphicsScene):
        def __init__(self):
            super().__init__()
    
            self.container = QtWidgets.QGraphicsWidget()
            layout = QtWidgets.QGraphicsGridLayout(self.container)
            layout.setSpacing(0)
            self.container.setContentsMargins(0, 0, 0, 0)
            layout.setContentsMargins(0, 0, 0, 0)
            self.addItem(self.container)
            for row in range(8):
                for column in range(8):
                    square = Square(not (row + column) & 1)
                    layout.addItem(square, row, column, 1, 1)
    
    
    class Board(QtWidgets.QGraphicsView):
        def __init__(self):
            super().__init__()
            scene = Scene()
            self.setScene(scene)
            self.setAlignment(QtCore.Qt.AlignCenter)
            # by default a graphics view has a border frame, disable it
            self.setFrameShape(0)
            # make it transparent
            self.setStyleSheet('QGraphicsView {background: transparent;}')
    
        def resizeEvent(self, event):
            super().resizeEvent(event)
            # zoom the contents keeping the ratio
            self.fitInView(self.scene().container, QtCore.Qt.KeepAspectRatio)
    
    
    class Chess(QtWidgets.QWidget):
        def __init__(self):
            super().__init__()
            layout = QtWidgets.QGridLayout(self)
            header = QtWidgets.QLabel('Some {}long label'.format('very ' * 20))
            layout.addWidget(header, 0, 0, 1, 3, QtCore.Qt.AlignCenter)
            self.board = Board()
            layout.addWidget(self.board, 1, 1)
    
            leftLayout = QtWidgets.QVBoxLayout()
            layout.addLayout(leftLayout, 1, 0)
            rightLayout = QtWidgets.QVBoxLayout()
            layout.addLayout(rightLayout, 1, 2)
            for b in range(1, 9):
                leftLayout.addWidget(QtWidgets.QPushButton('Left Btn {}'.format(b)))
                rightLayout.addWidget(QtWidgets.QPushButton('Right Btn {}'.format(b)))
    
            footer = QtWidgets.QLabel('Another {}long label'.format('very ' * 18))
            layout.addWidget(footer, 2, 0, 1, 3, QtCore.Qt.AlignCenter)
    
    
    if __name__ == '__main__':
        import sys
        app = QtWidgets.QApplication(sys.argv)
        w = Chess()
        w.show()
        sys.exit(app.exec_())