Search code examples
pythonpyqtchatpyside2qtextedit

PySide2 QTextEdit doesn't adjust to it's own content when using wrapping. (Making chat window)


The PySide2 QTextEdit doesn't change it's own size when the text is placed in.

I'm trying to create something like chat window, where every message - QTextEdit in OnlyRead mode. All the 'messages' placed in QScrollArea. The main goal is to let message-boxes (message-boxeslike on the screen below) adjust their size to content. wrong working example

I tried this code https://ru.stackoverflow.com/questions/1408239/Как-сделать-двухсторонний-чат-в-qt-pyqt/1408264#1408264

which has been copy-pasted lots of times. But it doesn't do what i want. It creates a fixed, no resizable QTextEdit message-boxes.

As example what i actually mean, if we have a single-word message, QTextEdit widget must become a single stroke box, with width of the message. If we have a multi-sentences message, QTextEdit widget must become a multi-stroke box (already expanded in height, without the need to scroll it inside), with maximum constant length(which i ll choose).

Next is the example with correct messages displaying (good example)


Solution

  • In order to implement a self-adjusting message, some precautions are required.

    As explained in my answer and comments to the related post, you must consider the complex and delicate relation between the dimensions of a layout and its requirement, which becomes even more complex as a text layout doesn't have a fixed ratio.

    The main problem with setting the width based on the text is that it can change the height required to display it, which can cause a recursion, and since the size is based on the current viewport size, the result is that the minimumSizeHint() will always return the smallest possible size after a certain amount of recursive calls.

    Considering the above, we must do the following changes to my original code:

    • the scroll area must always set a maximum width to its message widgets, possibly with a specified margin (for sent/received distinction) whenever the view is resized;
    • widgets must be added to the layout with an alignment argument;
    • the minimumSizeHint() must be changed to:
      1. compute the preferred text width (idealWidth() based on the maximum size of the widget;
      2. get the reference height for that text width;
      3. set the text width to the current width;
      4. compare the new document height with the previous one, if they are the same it means that we can use the new width as maximum width for the hint (the text can be shorter), otherwise we use the initial text width based on the maximum size;

    Note that there are a couple of differences from the modified code of your link: most importantly, rewriting the stylesheet doesn't make a lot of sense, and setting the margin creates an issue with the value returned by frameWidth() (that's why they subtracted 100 from the document height); that is certainly not a good choice, as the margin should be set within the layout.

    class WrapLabel(QtWidgets.QTextEdit):
        def __init__(self, text=''):
            super().__init__(text)
            self.setReadOnly(True)
            self.setSizePolicy(QtWidgets.QSizePolicy.Preferred, 
                QtWidgets.QSizePolicy.Maximum)
            self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
            self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
            self.textChanged.connect(self.updateGeometry)
    
        def minimumSizeHint(self):
            margin = self.frameWidth() * 2
            doc = self.document().clone()
            doc.setTextWidth(self.maximumWidth())
            idealWidth = doc.idealWidth()
            idealHeight = doc.size().height()
            doc.setTextWidth(self.viewport().width())
            if doc.size().height() == idealHeight:
                idealWidth = doc.idealWidth()
            return QtCore.QSize(
                max(50, idealWidth + margin), 
                doc.size().height() + margin)
    
        def sizeHint(self):
            return self.minimumSizeHint()
    
        def resizeEvent(self, event):
            super().resizeEvent(event)
            self.updateGeometry()
    
    
    class ChatTest(QtWidgets.QScrollArea):
        def __init__(self):
            super().__init__()
            self.margin = 100
            self.marginRatio = .8
            self.messages = []
    
            container = QtWidgets.QWidget()
            self.setWidget(container)
            self.setWidgetResizable(True)
    
            layout = QtWidgets.QVBoxLayout(container)
            layout.addStretch()
            self.resize(480, 360)
    
            letters = 'abcdefghijklmnopqrstuvwxyz       '
            for i in range(1, 11):
                msg = ''.join(choice(letters) for i in range(randrange(10, 250)))
                QtCore.QTimer.singleShot(500 * i, lambda msg=msg, i=i:
                    self.addMessage(msg, i & 1))
    
        def addMessage(self, text, sent=False):
            message = WrapLabel(text)
            message.setStyleSheet('''
                WrapLabel {{
                    border: 1px outset palette(dark);
                    border-radius: 8px;
                    background: {};
                }}
            '''.format(
                '#fff8c7' if sent else '#ceffbd')
            )
            self.messages.append(message)
            self.widget().layout().addWidget(message, 
                alignment=QtCore.Qt.AlignRight if sent else QtCore.Qt.AlignLeft)
            QtCore.QTimer.singleShot(0, self.scrollToBottom)
    
        def scrollToBottom(self):
            QtWidgets.QApplication.processEvents()
            self.verticalScrollBar().setValue(
                self.verticalScrollBar().maximum())
    
        def resizeEvent(self, event):
            sb = self.verticalScrollBar()
            atMaximum = sb.value() == sb.maximum()
            maxWidth = max(self.width() * self.marginRatio, 
                self.width() - self.margin) - sb.sizeHint().width()
            for message in self.messages:
                message.setMaximumWidth(maxWidth)
            super().resizeEvent(event)
            if atMaximum:
                sb.setValue(sb.maximum())