8
votes

I'm working on a PyQt5 GUI, so far, I've just had experience with python scripts and did not delve into creating user interfaces.

The GUI will have to be used on different screens (maybe also some old 4:3 ratio screens) and will need to look nice in different sizes. Now, my approach to make my life easier was to enforce a fixed aspect ratio of the window and resize the different elements according to window size.

from PyQt5 import QtCore, QtGui, QtWidgets

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, parent= None):
        super().__init__(parent)
        self.form_widget = FormWidget(self)
        self.setCentralWidget(self.form_widget)
        self.resize(200, 400)
        self.sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
        self.sizePolicy.setHeightForWidth(True)
        self.setSizePolicy(self.sizePolicy)

    def heightForWidth(self, width):
        return width * 2

class FormWidget(QtWidgets.QWidget):

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

    def resizeEvent(self, event):
        f = self.font()
        temp = event.size().height()
        f.setPixelSize(temp / 16)
        self.setFont(f)

        return super().resizeEvent(event)


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

    w = MainWindow()
    w.show()
    sys.exit(app.exec_())

Resizing the elements according to window size works fine, but window aspect ratio is not kept at all. I copied this approach with heightForWidth from old PyQt4 threads. Doesn't this approach work anymore in PyQt5? Am I missing something?

2

2 Answers

1
votes

If I understood your question, you should try using a layout inside the main window.

I did this:

from PyQt5 import QtCore, QtGui, QtWidgets

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, parent= None):
        super().__init__(parent)
        self.central_widget = QtWidgets.QWidget()
        self.central_layout = QtWidgets.QVBoxLayout()
        self.setCentralWidget(self.central_widget)
        self.central_widget.setLayout(self.central_layout)
        # Lets create some widgets inside
        self.label = QtWidgets.QLabel()
        self.list_view = QtWidgets.QListView()
        self.push_button = QtWidgets.QPushButton()
        self.label.setText('Hi, this is a label. And the next one is a List View :')
        self.push_button.setText('Push Button Here')
        # Lets add the widgets
        self.central_layout.addWidget(self.label)
        self.central_layout.addWidget(self.list_view)
        self.central_layout.addWidget(self.push_button)
      
if __name__ == "__main__":
    import sys
    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.show()
    sys.exit(app.exec_())

If you resize the window, the widgets inside it get resized.

enter image description here

enter image description here

0
votes

First, answered by Marc and codeling in this question, heightForWidth is only supported for QGraphicsLayout's subclasses.

Second, how to make a fixed aspect ratio window (or top-level widget) in qt (or pyqt) is a question that have been asked for years. However, as far as I know, there is no standard way of doing so, and it is something surprisingly hard to achieve. In short, my way of doing this is use Qt.FramelessWindowHint to create a frameless window without system move and resize function, and implement custom move and resize.

Explain important mechanism:

move:

  1. In mousePressEvent, keep the place where we last clicked on the widget(the draggable area).
  2. In mouseMoveEvent, calculate the distance between the last clicked point and the current mouse location. Move the window according to this distance.

resize:

  1. Find the increase or decrease step size of width and height by dividing the minimum width and height of the window by their highest common factor.
  2. Use the step size to increase or decrease the window size to keep the aspect ratio.

A screenshot to show that it can resize according to the aspect ratio.

The following code should works with both PyQt5 and Pyside2.

from PyQt5.QtCore import Qt, QRect, QPoint, QEvent
from PyQt5.QtWidgets import (QLabel, QMainWindow, QApplication, QSizePolicy,
                             QVBoxLayout, QWidget, QHBoxLayout, QPushButton)
from enum import Enum


class MainWindow(QMainWindow):

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

        self.setWindowFlags(Qt.FramelessWindowHint)

        self.createCostumTitleBar()

        self.setContentsMargins(0, 0, 0, 0)

        self.central = QWidget()
        self.central.setStyleSheet("background-color: #f8ecdf")

        self.centralLayout = QVBoxLayout()
        self.central.setLayout(self.centralLayout)
        self.centralLayout.addWidget(
            self.costumsystemmenu, alignment=Qt.AlignTop)
        self.centralLayout.setContentsMargins(0, 0, 0, 0)

        self.setCentralWidget(self.central)

        # Set the minimum size to avoid window being resized too small.

        self.setMinimumSize(300, 400)
        self.minheight = self.minimumHeight()
        self.minwidth = self.minimumWidth()

        self.resize(300, 400)

        # make sure your minium size have the same aspect ratio as the step.
        self.stepY = 4
        self.stepX = 3

        # install the event filter on this window.
        self.installEventFilter(self)
        self.grabarea.installEventFilter(self)

        self.cursorpos = CursorPos.DEFAULT
        self.iswindowpress = False

    def createCostumTitleBar(self):
        self.costumsystemmenu = QWidget()
        self.costumsystemmenu.setStyleSheet("background-color: #ccc")
        self.costumsystemmenu.setContentsMargins(0, 0, 0, 0)
        self.costumsystemmenu.setMinimumHeight(30)

        self.grabarea = QLabel("")
        self.grabarea.setStyleSheet("background-color: #ccc")
        self.grabarea.setSizePolicy(
            QSizePolicy.Expanding, QSizePolicy.Preferred)

        titlebarlayout = QHBoxLayout()
        titlebarlayout.setContentsMargins(11, 11, 11, 11)
        titlebarlayout.setSpacing(0)

        self.closeButton = QPushButton("X")
        self.closeButton.setSizePolicy(
            QSizePolicy.Minimum, QSizePolicy.Preferred)
        self.closeButton.clicked.connect(self.close)

        self.costumsystemmenu.setLayout(titlebarlayout)
        titlebarlayout.addWidget(self.grabarea)
        titlebarlayout.addWidget(self.closeButton, alignment=Qt.AlignRight)

        self.istitlebarpress = False

    def eventFilter(self, object, event):
        # The eventFilter() function must return true if the event
        # should be filtered, (i.e. stopped); otherwise it must return false.
        # https://doc.qt.io/qt-5/qobject.html#eventFilter

        # check if the object is the mainwindow.
        if object == self:

            if event.type() == QEvent.HoverMove:
                if not self.iswindowpress:
                    self.setCursorShape(event)
                return True

            elif event.type() == QEvent.MouseButtonPress:
                self.iswindowpress = True
                # Get the position of the cursor and map to the global coordinate of the widget.
                self.globalpos = self.mapToGlobal(event.pos())
                self.origingeometry = self.geometry()

                return True

            elif event.type() == QEvent.MouseButtonRelease:
                self.iswindowpress = False
                return True

            elif event.type() == QEvent.MouseMove:
                if self.cursorpos != CursorPos.DEFAULT and self.iswindowpress:
                    self.resizing(self.globalpos, event,
                                  self.origingeometry, self.cursorpos)

                return True

            else:
                return False

        elif object == self.grabarea:
            if event.type() == QEvent.MouseButtonPress:
                if event.button() == Qt.LeftButton and self.iswindowpress == False:
                    self.oldpos = event.globalPos()
                    self.oldwindowpos = self.pos()
                    self.istitlebarpress = True

                return True
            elif event.type() == QEvent.MouseButtonRelease:
                self.istitlebarpress = False
                return True
            elif event.type() == QEvent.MouseMove:
                if (self.istitlebarpress):
                    distance = event.globalPos()-self.oldpos
                    newwindowpos = self.oldwindowpos + distance
                    self.move(newwindowpos)
                return True
            else:
                return False
        else:
            return False

    # Change the cursor shape when the cursor is over different part of the window.
    def setCursorShape(self, event, handlersize=11):
        rect = self.rect()
        topLeft = rect.topLeft()
        topRight = rect.topRight()
        bottomLeft = rect.bottomLeft()
        bottomRight = rect.bottomRight()

        # get the position of the cursor
        pos = event.pos()

        # make the resize handle include some space outside the window,
        # can avoid user move too fast and loss the handle.
        # top handle
        if pos in QRect(QPoint(topLeft.x()+handlersize, topLeft.y()-2*handlersize),
                        QPoint(topRight.x()-handlersize, topRight.y()+handlersize)):
            self.setCursor(Qt.SizeVerCursor)
            self.cursorpos = CursorPos.TOP

        # bottom handle
        elif pos in QRect(QPoint(bottomLeft.x()+handlersize, bottomLeft.y()-handlersize),
                          QPoint(bottomRight.x()-handlersize, bottomRight.y()+2*handlersize)):
            self.setCursor(Qt.SizeVerCursor)
            self.cursorpos = CursorPos.BOTTOM

        # right handle
        elif pos in QRect(QPoint(topRight.x()-handlersize, topRight.y()+handlersize),
                          QPoint(bottomRight.x()+2*handlersize, bottomRight.y()-handlersize)):
            self.setCursor(Qt.SizeHorCursor)
            self.cursorpos = CursorPos.RIGHT

        # left handle
        elif pos in QRect(QPoint(topLeft.x()-2*handlersize, topLeft.y()+handlersize),
                          QPoint(bottomLeft.x()+handlersize, bottomLeft.y()-handlersize)):
            self.setCursor(Qt.SizeHorCursor)
            self.cursorpos = CursorPos.LEFT

        # topRight handle
        elif pos in QRect(QPoint(topRight.x()-handlersize, topRight.y()-2*handlersize),
                          QPoint(topRight.x()+2*handlersize, topRight.y()+handlersize)):
            self.setCursor(Qt.SizeBDiagCursor)
            self.cursorpos = CursorPos.TOPRIGHT

        # topLeft handle
        elif pos in QRect(QPoint(topLeft.x()-2*handlersize, topLeft.y()-2*handlersize),
                          QPoint(topLeft.x()+handlersize, topLeft.y()+handlersize)):
            self.setCursor(Qt.SizeFDiagCursor)
            self.cursorpos = CursorPos.TOPLEFT

        # bottomRight handle
        elif pos in QRect(QPoint(bottomRight.x()-handlersize, bottomRight.y()-handlersize),
                          QPoint(bottomRight.x()+2*handlersize, bottomRight.y()+2*handlersize)):
            self.setCursor(Qt.SizeFDiagCursor)
            self.cursorpos = CursorPos.BOTTOMRIGHT

        # bottomLeft handle
        elif pos in QRect(QPoint(bottomLeft.x()-2*handlersize, bottomLeft.y()-handlersize),
                          QPoint(bottomLeft.x()+handlersize, bottomLeft.y()+2*handlersize)):
            self.setCursor(Qt.SizeBDiagCursor)
            self.cursorpos = CursorPos.BOTTOMLEFT

        # Default is the arrow cursor.
        else:
            self.setCursor(Qt.ArrowCursor)
            self.cursorpos = CursorPos.DEFAULT

    def resizing(self, originpos, event, geo, cursorpos):
        newpos = self.mapToGlobal(event.pos())

        # find the distance between new and old cursor position.
        dist = newpos - originpos

        # calculate the steps to grow or srink.
        if cursorpos in [CursorPos.TOP, CursorPos.BOTTOM,
                         CursorPos.TOPRIGHT,
                         CursorPos.BOTTOMLEFT, CursorPos.BOTTOMRIGHT]:
            steps = dist.y()//self.stepY
        elif cursorpos in [CursorPos.LEFT, CursorPos.TOPLEFT, CursorPos.RIGHT]:
            steps = dist.x()//self.stepX

        # if the distance moved is too stort, grow or srink by 1 step.
        if steps == 0:
            steps = -1 if dist.y() < 0 or dist.x() < 0 else 1

        oldwidth = geo.width()
        oldheight = geo.height()

        oldX = geo.x()
        oldY = geo.y()

        if cursorpos in [CursorPos.TOP, CursorPos.TOPRIGHT]:

            width = oldwidth - steps * self.stepX
            height = oldheight - steps * self.stepY

            newX = oldX
            newY = oldY + (steps * self.stepY)

            # check if the new size is within the size limit.
            if height >= self.minheight and width >= self.minwidth:
                self.setGeometry(newX, newY, width, height)

        elif cursorpos in [CursorPos.BOTTOM, CursorPos.RIGHT, CursorPos.BOTTOMRIGHT]:

            width = oldwidth + steps * self.stepX
            height = oldheight + steps * self.stepY

            self.resize(width, height)

        elif cursorpos in [CursorPos.LEFT, CursorPos.BOTTOMLEFT]:

            width = oldwidth - steps * self.stepX
            height = oldheight - steps * self.stepY

            newX = oldX + steps * self.stepX
            newY = oldY

            # check if the new size is within the size limit.
            if height >= self.minheight and width >= self.minwidth:
                self.setGeometry(newX, newY, width, height)

        elif cursorpos == CursorPos.TOPLEFT:

            width = oldwidth - steps * self.stepX
            height = oldheight - steps * self.stepY

            newX = oldX + steps * self.stepX
            newY = oldY + steps * self.stepY

            # check if the new size is within the size limit.
            if height >= self.minheight and width >= self.minwidth:
                self.setGeometry(newX, newY, width, height)

        else:
            pass

# cursor position
class CursorPos(Enum):
    TOP = 1
    BOTTOM = 2
    RIGHT = 3
    LEFT = 4
    TOPRIGHT = 5
    TOPLEFT = 6
    BOTTOMRIGHT = 7
    BOTTOMLEFT = 8
    DEFAULT = 9


if __name__ == "__main__":
    import sys
    app = QApplication(sys.argv)
    w = MainWindow()
    w.show()
    sys.exit(app.exec_())

Finally, I'd like to give special thanks to the authors and editors of this question, GLHF, DRPK, Elad Joseph, and SimoN SavioR. Without their contribution to the community, it wouldn't be possible to come up with this answer.