Search code examples
pythonpyqtpyqt5qsplitter

QSplitter, QWidget resizing, setSizes(), setStretchFactor(), and sizeHint() - how to make it all work together?


I'm struggling with working out how to make all the stuff in the title work together in a certain situation. I'm using PyQt5 here, but feel free to respond with regular C++ Qt as I can translate pretty easily.

I'm attempting to make a UI with the following:

  • A main form (inherits from QWidget, could just as well use QMainWindow)

  • The main form should contain a QSplitter oriented vertically containing a QTextEdit at the top and containing a custom class (inheriting from QLabel) to show an image taking up the rest of the space.

  • The QTextEdit at the top should default to about 3 lines of text high, but this should be resizable to any reasonable extreme via the QSplitter.

  • The custom class should resize the image to be as big as possible given the available space while maintaining the aspect ratio.

Of course the tricky part is getting everything to resize correctly depending on how big a monitor the user has and how the move the form around. I need this to run on screens as small as about 1,000 px width and perhaps as big as 3,000+ px width.

Here is what I have so far:

# QSplitter3.py

import cv2
import numpy as np

from PyQt5.QtWidgets import QApplication, QWidget, QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QSizePolicy, \
    QFrame, QTabWidget, QTextEdit, QSplitter
from PyQt5.QtGui import QImage, QPixmap, QPainter
from PyQt5.Qt import Qt
from PyQt5.Qt import QPoint


def main():
    app = QApplication([])

    screenSize = app.primaryScreen().size()
    print('screenSize = ' + str(screenSize.width()) + ', ' + str(screenSize.height()))

    mainForm = MainForm(screenSize)
    mainForm.show()
    app.exec()
# end function

class MainForm(QWidget):

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

        # set the title and size of the Qt QWidget window
        self.setWindowTitle('Qt Window')
        self.setGeometry(screenSize.width() * 0.2, screenSize.height() * 0.2,
                         screenSize.width() * 0.5 , screenSize.height() * 0.7)

        # declare a QTextEdit to show user messages at the top, set the font size, height, and read only property
        self.txtUserMessages = QTextEdit()
        self.setFontSize(self.txtUserMessages, 14)
        self.txtUserMessages.setReadOnly(True)

        # make the min height of the text box about 2 lines of text high
        self.txtUserMessages.setMinimumHeight(self.getTextEditHeightForNLines(self.txtUserMessages, 2))

        # populate the user messages text box with some example text
        self.txtUserMessages.append('message 1')
        self.txtUserMessages.append('message 2')
        self.txtUserMessages.append('message 3')
        self.txtUserMessages.append('stuff here')
        self.txtUserMessages.append('bla bla bla')
        self.txtUserMessages.append('asdasdsadds')

        # instantiate the custom ImageWidget class below to show the image
        self.imageWidget = ImageWidget()
        self.imageWidget.setMargin(0)
        self.imageWidget.setContentsMargins(0, 0, 0, 0)
        self.imageWidget.setScaledContents(True)
        self.imageWidget.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
        self.imageWidget.setAlignment(Qt.AlignCenter)

        # declare the splitter, then add the user message text box and tab widget
        self.splitter = QSplitter(Qt.Vertical)
        self.splitter.addWidget(self.txtUserMessages)
        self.splitter.addWidget(self.imageWidget)

        defaultTextEditHeight = self.getTextEditHeightForNLines(self.txtUserMessages, 3)
        print('defaultTextEditHeight = ' + str(defaultTextEditHeight))
        # How can I use defaultTextEditHeight height here, but still allow resizing ??

        # I really don't like this line, the 1000 is a guess and check that may only work with one screen size !!!
        self.splitter.setSizes([defaultTextEditHeight, 1000])

        # Should setStretchFactor be used here ??  This does not seem to work
        # self.splitter.setStretchFactor(0, 0)
        # self.splitter.setStretchFactor(1, 1)

        # What about sizeHint() ??  Should that be used here, and if so, how ??

        # set the main form's layout to the QGridLayout
        self.gridLayout = QGridLayout()
        self.gridLayout.addWidget(self.splitter)

        self.setLayout(self.gridLayout)

        # open the two images in OpenCV format
        self.openCvImage = cv2.imread('image.jpg')

        if self.openCvImage is None:
            print('error opening image')
            return
        # end if

        # convert the OpenCV image to QImage
        self.qtImage = openCvImageToQImage(self.openCvImage)

        # show the QImage on the ImageWidget
        self.imageWidget.setPixmap(QPixmap.fromImage(self.qtImage))

    # end function

    def setFontSize(self, widget, fontSize):
        font = widget.font()
        font.setPointSize(fontSize)
        widget.setFont(font)
    # end function

    def getTextEditHeightForNLines(self, textEdit, numLines):
        fontMetrics = textEdit.fontMetrics()
        rowHeight = fontMetrics.lineSpacing()
        rowHeight = rowHeight * 1.21
        textEditHeight = int(numLines * rowHeight)
        return textEditHeight
    # end function

# end class

def openCvImageToQImage(openCvImage):
    # get the height, width, and num channels of the OpenCV image, then compute the byte value
    height, width, numChannels = openCvImage.shape
    byteValue = numChannels * width

    # make the QImage from the OpenCV image
    qtImage = QImage(openCvImage.data, width, height, byteValue, QImage.Format_RGB888).rgbSwapped()

    return qtImage
# end function

class ImageWidget(QLabel):
    def __init__(self):
        super(QLabel, self).__init__()
    # end function

    def setPixmap(self, pixmap):
        self.pixmap = pixmap
    # end function

    def paintEvent(self, event):
        size = self.size()
        painter = QPainter(self)
        point = QPoint(0, 0)
        scaledPixmap = self.pixmap.scaled(size, Qt.KeepAspectRatio, transformMode=Qt.SmoothTransformation)
        point.setX((size.width() - scaledPixmap.width()) / 2)
        point.setY((size.height() - scaledPixmap.height()) / 2)
        painter.drawPixmap(point, scaledPixmap)
    # end function
# end class

if __name__ == '__main__':
    main()

Currently I'm testing on a 2560x1440 screen and with the magic 1000 entered it works on this screen size, but I really don't like the hard-coded 1000. I suspect the area of the code where I'm missing something is this part:

# declare the splitter, then add the user message text box and tab widget
        self.splitter = QSplitter(Qt.Vertical)
        self.splitter.addWidget(self.txtUserMessages)
        self.splitter.addWidget(self.imageWidget)

        defaultTextEditHeight = self.getTextEditHeightForNLines(self.txtUserMessages, 3)
        print('defaultTextEditHeight = ' + str(defaultTextEditHeight))
        # How can I use defaultTextEditHeight height here, but still allow resizing ??

        # I really don't like this line, the 1000 is a guess and check that may only work with one screen size !!!
        self.splitter.setSizes([defaultTextEditHeight, 1000])

        # Should setStretchFactor be used here ??  This does not seem to work
        # self.splitter.setStretchFactor(0, 0)
        # self.splitter.setStretchFactor(1, 1)

        # What about sizeHint() ??  Should that be used here, and if so, how ??

        # set the main form's layout to the QGridLayout
        self.gridLayout = QGridLayout()
        self.gridLayout.addWidget(self.splitter)

With the hard coded 1000 and on this particular screen it works pretty well:

enter image description here

To reiterate (hopefully more clearly) I'm attempting to be able to remove the hard-coded 1000 and command Qt as follows:

  • Initially make the form take up about 2/3 of the screen
  • Initially make the text box about 3 lines of text high (min of 2 lines of text high)
  • Allow the user to use the QSplitter to resize the text box and image at any time and without limit
  • When the form is resized (or minimized or maximized), resize the text box and image proportionally per how the user had them at the time of the resize

I've tried about every combination of the stuff mentioned in the title and so far in this post but I've not been able to get this functionality, except with the hard-coded 1000 that probably won't work with a different screen size.

How can I remove the hard-coded 1000 and modify the above to achieve the intended functionality?


Solution

  • In my solution I will not take into account the part of opencv since it adds unnecessary complexity.

    The solution is to use the setStretchFactor() method, in this case override the sizeHint() method of the QTextEdit to set the initial size and setMinimumHeight() for the minimum height. To show the image I use a QGraphicsView instead of the QLabel since the logic is easier.

    from PyQt5 import QtCore, QtGui, QtWidgets
    
    
    class TextEdit(QtWidgets.QTextEdit):
        def __init__(self, parent=None):
            super().__init__(parent)
            self.setReadOnly(True)
            font = self.font()
            font.setPointSize(14)
            self.setFont(font)
            self.setMinimumHeight(self.heightForLines(2))
    
        def heightForLines(self, n):
            return (
                n * self.fontMetrics().lineSpacing() + 2 * self.document().documentMargin()
            )
    
        def showEvent(self, event):
            self.verticalScrollBar().setValue(self.verticalScrollBar().minimum())
    
        def sizeHint(self):
            s = super().sizeHint()
            s.setHeight(self.heightForLines(3))
            return s
    
    
    class GraphicsView(QtWidgets.QGraphicsView):
        def __init__(self, parent=None):
            super().__init__(parent)
            self.setFrameShape(QtWidgets.QFrame.NoFrame)
            self.setBackgroundBrush(self.palette().brush(QtGui.QPalette.Window))
            scene = QtWidgets.QGraphicsScene(self)
            self.setScene(scene)
    
            self._pixmap_item = QtWidgets.QGraphicsPixmapItem()
            scene.addItem(self._pixmap_item)
    
        def setPixmap(self, pixmap):
            self._pixmap_item.setPixmap(pixmap)
    
        def resizeEvent(self, event):
            self.fitInView(self._pixmap_item, QtCore.Qt.KeepAspectRatio)
            self.centerOn(self._pixmap_item)
            super().resizeEvent(event)
    
    
    class Widget(QtWidgets.QWidget):
        def __init__(self, parent=None):
            super().__init__(parent)
    
            self.textedit = TextEdit()
            for i in range(10):
                self.textedit.append("Message {}".format(i))
    
            self.graphicsview = GraphicsView()
            self.graphicsview.setPixmap(QtGui.QPixmap("image.jpg"))
    
            splitter = QtWidgets.QSplitter(QtCore.Qt.Vertical)
    
            splitter.addWidget(self.textedit)
            splitter.addWidget(self.graphicsview)
    
            splitter.setStretchFactor(1, 1)
    
            lay = QtWidgets.QGridLayout(self)
            lay.addWidget(splitter)
    
            screenSize = QtWidgets.QApplication.primaryScreen().size()
            self.setGeometry(
                screenSize.width() * 0.2,
                screenSize.height() * 0.2,
                screenSize.width() * 0.5,
                screenSize.height() * 0.7,
            )
    
    
    def main():
        app = QtWidgets.QApplication([])
        w = Widget()
        w.resize(640, 480)
        w.show()
        app.exec_()
    
    
    if __name__ == "__main__":
        main()
    

    enter image description here