Search code examples
pythonpython-3.xpyqt5qtextedit

Automatically expanding and contracting `QTextEdit` for PyQt5


I'm trying to create a custom QTextEdit widget in PyQt5 that automatically expands and contracts based on its content. The widget should grow in height as the user types new lines and shrink when lines are removed. I have some code but when the TextEdit is resized, the window size remains constant so the widget above the TextEdit, which is supposed to have a dynamic size, increases in size. My MRE is as follows:

from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
import sys

class App(QWidget):
    def __init__(self):
        super().__init__()

        self.layout = QVBoxLayout()
        self.layout.setSpacing(0)
        self.layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(self.layout)

        self.init_ui()

    def init_ui(self):
        ### ChatWidget ###
        chat_widget = QWidget(self)
        chat_widget.setObjectName("chatbox")
        chat_widget.setStyleSheet("#chatbox {background-color: black; border: 0;}")

        chat_widget.scroll_area = QScrollArea(self)
        chat_widget.scroll_area.setWidgetResizable(True)
        chat_widget.scroll_area.setWidget(chat_widget)
        chat_widget.scroll_area.setStyleSheet("""
        QScrollArea {
        }
        QScrollBar:vertical {
            background-color: black;
            color: white;
        }
        """)

        chat_widget.setMinimumHeight(449)
        chat_widget.setMinimumWidth(300)
        chat_widget.scroll_area.setMinimumHeight(453)
        chat_widget.scroll_area.setMinimumWidth(333)

        self.layout.addWidget(chat_widget.scroll_area)
        ###

        ### CustomTextEdit ###
        self.text_edit = CustomTextEdit(5, self)
        self.layout.addWidget(self.text_edit)
        self.text_edit.setStyleSheet("""
        font-size: 12pt;
        """)
        QTimer().singleShot(5, self.text_edit.update_height)
        ###

class CustomTextEdit(QTextEdit):
    def __init__(self, max_lines=5, parent=None):
        super().__init__(parent)
        self.max_lines = max_lines
        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
        self.document().contentsChanged.connect(self.update_height)

        self.setFixedHeight(self.fontMetrics().height())

    def update_height(self):
        document_height = self.document().size().height()
        line_height = self.fontMetrics().lineSpacing()

        text_layout = self.document().documentLayout()
        block = self.document().begin()
        num_lines = 0
        while block.isValid():
            layout = block.layout()
            num_lines += layout.lineCount()
            block = block.next()

        desired_height = document_height + self.contentsMargins().top() + self.contentsMargins().bottom()

        if num_lines > self.max_lines:
            self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        else:
            self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
            self.setFixedHeight(int(desired_height))

    def scroll_to_bottom(self):
        scrollbar = self.verticalScrollBar()
        scrollbar.setValue(scrollbar.maximum())

if __name__ == '__main__':
    app = QApplication(sys.argv)
    app_widget = App()
    app_widget.show()
    sys.exit(app.exec_())

Example UI Image

I'd appreciate it if someone could help, thanks!

Attempt/Goal/Expectation: I tried to make the TextEdit resize as the number of lines increase. Problem: The other widgets are being resized / The window is not being resized.


Solution

  • When a child widget requires more space than it previously has, it normally causes the parent to increase their size accordingly if no available space is left from the other widgets, eventually going up to the top level window. This is to ensure that all widgets are always visible and usable.

    When the change instead decreases the size requirement, this does not happen, as it is assumed that the space could be better used, instead of trying to arbitrarily reduce the overall size. This is not normally done, because the decrease in size cannot be properly computed by internal widgets and containers, as they do not know what caused the decrease, they just know that a resize has happened: it may have been caused by the user resizing the window, or by another widget changing its size constraints.

    In order to try to "reset" the size to the minimum, we could eventually attempt to get the minimum preferred size requirement (the sizeHint()) of the window , but only after the change has been properly processed by the layout manager (which is the reason for calling activate()).

        def update_height(self):
            ...
                self.window().layout.activate()
                self.window().resize(
                    self.window().width(), 
                    self.window().sizeHint().height()
                )
    

    Note, though, that this change in size is generally discouraged. While the increase in size may be justified (the UI must be able to show its contents), both changes should actually consider the current size, in case the user manually resized the window.

    The drawback of doing all this is clearly visible even in your original code: try to make the window slightly taller than it is on start up (a few pixels), then add enough text to show more than one line; the window will only slightly increase its height. Now, if you clear the text, the size will not be restored to the previous size.
    If you do the same with the new addition, the window will always be restored to its minimum size, no matter how the user eventually tried to resize it.

    That's one of the many reasons for which changes in the window size should only be done when necessary, and under specific conditions (no fully dynamic changes in the contents). While it's not technically impossible to implement what you wanted in a proper way, it always needs to follow very delicate procedures (checking the parent and top level window, size constraints, sibling widgets, etc.), which may be difficult to achieve. Furthermore, it's generally considered annoying to the user, especially for input fields, and it actually provides no real benefit from the UX perspective.

    A more appropriate approach should instead properly use size hints, and specifically the minimumSizeHint() and sizeHint(), which return the minimum possible and preferred size of the widget, respectively: the child widgets should eventually provide a preferred size that may eventually be shrunk if more space is needed by other widgets.

    The window should not be resized as a consequence of user input, unless it's absolutely necessary in order to properly show all its contents.

    In your case, this should be implemented as a QScrollArea subclass:

    class ScrollArea(QScrollArea):
        def minimumSizeHint(self):
            return QSize(300, 350)
    
        def sizeHint(self):
            return QSize(300, 450)
    
    
    class App(QWidget):
        ...
        def init_ui(self):
            ...
            chat_widget.scroll_area = ScrollArea(self)
            self.setMinimumHeight(self.sizeHint().height())
    

    The above will give an initial result similar to yours, with the benefit that the "message area" will eventually be shrunk if the input area requires more space, and then everything will be restored accordingly if less vertical space is required.

    Further notes:

    • if you're using setFixedHeight(), there is no point of setting the size policy;
    • chat_widget is a child of the scroll area, not the opposite; it's conceptually wrong to make scroll_area its member, as it's the other way around;
    • layout() is a dynamic function for all widgets, and should never be overwritten with a "static" member, mostly because it makes it not callable (in the first example, self.window().layout() would have been more appropriate, and it would've worked for any parent at any level, but that wasn't possible because you made it an uncallable reference);
    • always use the proper parent argument: most of the time, it's not required if the widget is going to be added to a layout manager right after its construction, but if you do provide it, it should be the correct parent: for instance, chat_widget = QWidget(self) is inappropriate, since it will be reparented as a child of the scroll area;