PyQt6 auto-resize QTextEdit to fit content problem

Question:

I have a bunch of QTextEdits in a QVBoxLayout in a QScrollArea.

The texts can often get very long and the horizontal space is limited by design, and QTextEdit automatically wraps text in multiple lines which is good.

I want to automatically resize the QTextEdit to fit to the wrapped text, the text itself will always be in one line, and the wrapped text can have multiple lines, I want the QTextEdits to fit to the height of wrapped lines.

By hours upon hours of Google searching, I have found a solution, but it doesn’t work as expected, there can sometimes be one extra line at the bottom, I will post example code below:

from PyQt6.QtCore import *
from PyQt6.QtGui import *
from PyQt6.QtWidgets import *

font = QFont('Noto Serif', 9)

class Editor(QTextEdit):
    doubleClicked = pyqtSignal(QTextEdit)
    def __init__(self):
        super().__init__()
        self.setReadOnly(True)
        self.setFont(font)
        self.textChanged.connect(self.autoResize)
        self.margins = self.contentsMargins()
        self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen)
    @pyqtSlot(QMouseEvent)
    def mouseDoubleClickEvent(self, e: QMouseEvent) -> None:
        self.doubleClicked.emit(self)
    
    def autoResize(self):
        self.show()
        height = int(self.document().size().height() + self.margins.top() + self.margins.bottom())
        self.setFixedHeight(height)

class Window(QMainWindow):
    def __init__(self):
        super().__init__()
        self.resize(405, 720)
        frame = self.frameGeometry()
        center = self.screen().availableGeometry().center()
        frame.moveCenter(center)
        self.move(frame.topLeft())
        self.centralwidget = QWidget(self)
        self.vbox = QVBoxLayout(self.centralwidget)
        self.scrollArea = QScrollArea(self.centralwidget)
        self.scrollArea.setWidgetResizable(True)
        self.scrollAreaWidgetContents = QWidget()
        self.scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
        self.verticalLayout = QVBoxLayout(self.scrollAreaWidgetContents)
        self.verticalLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
        self.scrollArea.setWidget(self.scrollAreaWidgetContents)
        self.scrollArea.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
        self.vbox.addWidget(self.scrollArea)
        self.setCentralWidget(self.centralwidget)

def addItems():
    items = [
        "L'Estro Armonico No. 9 in D Majoru2014III. Allegro", 
        "L'Estro Armonico No. 6 in A Minoru2014III. Presto",
        "L'Estro Armonico No. 6 in A Minoru2014I. Allegro",
        "L'Estro Armonico No. 1 in D majoru2014I. Allegro",
        "12 Concertos Op.3 u2014 L'estro Armonico u2014 Concerto No. 6 In A Minor For Solo Violin RV 356u2014Presto",
        "Ultimate Mozartu2014 The Essential Masterpieces",
        "Serenade in G K.525 Eine kleine Nachtmusiku20141. Allegro",
        "Vivaldiu2014 L'estro Armonico",
        "Academy of St. Martin in the Fields",
        "Are You With Me u2014 Reality"
        ]
    for i in items:
        textbox = Editor()
        textbox.setText(i)
        window.verticalLayout.addWidget(textbox)


app = QApplication([])
window = Window()
window.show()
addItems()
app.exec()

Note you will need Noto Serif for it to run correctly (and of course you can just replace it), and of course you need PyQt6.

In the example, the first seven textboxs all have one extra empty line at the bottom, and the last three don’t have it.

What caused the extra line and how to remove it?

Update:

I have to set Qt.WidgetAttribute.WA_DontShowOnScreen because if I don’t set it, calling .show() of QTextEdit will cause the QTextEdit show up in the middle of the screen and quickly disappear, and it’s annoying.

I have to call .show() because calling document().size() without show(), the values will all be 0 and I don’t know why it is like this.

Answers:

The problem comes from the fact that you’re trying to set the height too early. In fact, if you add a print(self.size()) just after show(), you’ll see that all will show a default size (probably, 256×192).

This depends on two aspects:

  1. when a widget is shown the first time, it’s not yet completely "mapped" in the OS window management, so it will use default sizes depending on many aspects;
  2. you’re setting the text before adding it to the layout, so the QTextEdit will know nothing about the required size of the parent;

Then another problem arises: if the window is resized, the contents will not adapt until the text is changed.

In order to properly set a vertical height based on the contents, you should set the document’s textWidth, and also call autoResize everytime the widget is resized.

class Editor(QTextEdit):
    def __init__(self):
        super().__init__()
        self.setReadOnly(True)
        self.setFont(font)
        self.textChanged.connect(self.autoResize)

    def autoResize(self):
        self.document().setTextWidth(self.viewport().width())
        margins = self.contentsMargins()
        height = int(self.document().size().height() + margins.top() + margins.bottom())
        self.setFixedHeight(height)

    def resizeEvent(self, event):
        self.autoResize()

Note that:

  • the margins should be dynamically accessed, not stored in the __init__;
  • mouseDoubleClickEvent is a function that is called on a mouse event, it’s not (nor it should) be a slot, so using the pyqtSlot decorator is pointless;
  • while conceptually fine for a "main" layout like in the case of a layout for the scroll area contents, setting the alignment of a layout doesn’t set the alignment of its items, but only that of the layout; while the result is often the same, in practice it’s very different (so the result is not always the same, especially if more layouts are added to the same parent layout);
  • double click in text fields is very commonly used for advanced selection (usually, select the word under the cursor), and choosing to prevent such action (thus changing a known UI convention) should be taken into careful consideration;
Answered By: musicamante
Categories: questions Tags: , , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.