Search code examples
qtpyside6pyqt6

In Qt framework, how to make an autoexpanding QPlainTextEdit widget?


I would like to have a QPlainTextEdit widget that initially has height of one line (when it is empty). When user adds more text to the widget, it should automatically change its height to avoid scrolling.

How can I do that? I myself use PySide6, but C++ code is fine, too.


Solution

  • Allow me to answer since you accept C++ code. Editing my initial answer to simplify the code (thanks @musicamante for the suggestion; it was even more simple than you commented) + address 2 flaws of my previous answer:

    • Getting the width of the plain text edit was not detailed (which is not easy to do when it is first shown on screen).
    • I overlooked the posibility that the plain text edit width could be changed (making more or less text fit in each line).

    Step 1: Event filter

    In Qt, getting the size widgets before they are shown on screen is hard; instead, we aim at getting the size when the plain text edit is shown for the first time.

    To do that and to keep track of subsequent size changes, we first need an event filter class.

    #include <QtCore/QObject.h>
    #include <QtGui/QEvent.h>
    
    class SizeEventFilter : public QObject {
    Q_OBJECT
    public:
        SizeEventFilter(QObject* parent) : QObject(parent) {}
    protected:
        bool eventFilter(QObject* obj, QEvent* event)
        {
            if (event->type() == QEvent::Resize)
                emit sizeChanged(obj);
            return false;
        }
    signals:
        void sizeChanged(QObject* object);
    };
    

    Step 2: Height calculation lambda

    We use a lambda to recalculate the size of plainTextEdit. The lambda uses 2 additional variables in its capture list, the definition of which I included below.

    auto margins = plainTextEdit->contentsMargins();
    auto margin = plainTextEdit->document()->rootFrame()->frameFormat().margin();
    qreal verticalmarginSize = 2 * margin + margins.top() + margins.bottom();
    
    QFont font = plainTextEdit->font();
    QFontMetrics fontMetrics(font);
    int lineHeight = fontMetrics.lineSpacing();
    
    auto sizeCalculation = [plainTextEdit, lineHeight, verticalmarginSize]() {
        auto document = plainTextEdit->document();
        auto size = document->size();
        int height = lineHeight * size.height() + verticalmarginSize + 1;
        if (height != plainTextEdit->height())
            plainTextEdit->setFixedHeight(height);
    };
    

    Step 3: Making everything work together

    Right after the above code (step 2), insert the event filter, the connections and some tweaking of plainTextEdit as shown below.
    If you want to change the font of plainTextEdit, as it is used in the capture list of the lambda, you must add the line before calling QFont font = plainTextEdit->font();.

    QObject::connect(plainTextEdit, &QPlainTextEdit::textChanged, sizeCalculation);
    
    SizeEventFilter* sizeEventFilter = new SizeEventFilter(plainTextEdit);
    plainTextEdit->installEventFilter(sizeEventFilter);
    QObject::connect(sizeEventFilter, &SizeEventFilter::sizeChanged, plainTextEdit, sizeCalculation, Qt::QueuedConnection);
    
    plainTextEdit->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
    plainTextEdit->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
    

    A problem remains...

    In a more complete example, there are situations where several QPlainTextEdit in a layout interfere with one another.

    The scenario is as follow:

    1. Several QPlainTextEdit are added to a QScrollArea. The verticalBarPolicy property is set to Qt::ScrollBarAsNeeded.
      Due to their text, the scrollbar is hidden but with just 1 more line in any of them, it would need to be shown.

    When the window is opened

    1. A line is added to one of the QPlainTextEdit. The scrollbar appears, adding an additional line break elsewhere.

    1 line is added; a scrollbar appears.

    1. The line is later removed, however, the state does not revert to what it was on step 1: the additional line break does not disappear, that is unless we put in some extra effort.

    The inserted line is removed but the scrollbar remains.

    I have put up the below full example to demonstrate this effect.
    The code uses what I presented above and behaves like the way shown in the screenshots.
    If you remove the portion I marked with comments and uncomment the lines under that portion, you will get a more correct behavior, where the layout is recalculated without the vertical scrollbar just to see if it fits.

    #include <QtGui/QFontMetrics>
    #include <QtGui/QTextDocument>
    #include <QtGui/QTextFrame>
    
    #include <QtWidgets/QApplication>
    #include <QtWidgets/QBoxLayout>
    #include <QtWidgets/QScrollArea>
    #include <QtWidgets/QPlainTextEdit>
    
    #include "SizeEventFilter.h"
    
    int main(int argc, char** argv) {
        QApplication app(argc, argv);
        app.setQuitOnLastWindowClosed(true);
    
        auto main_widget = new QWidget();
        main_widget->setMinimumSize(800, 410);
    
        QScrollArea* scroll_area = new QScrollArea();
        QWidget* scroll_widget = new QWidget();
        QVBoxLayout* scroll_vbox = new QVBoxLayout(scroll_widget);
    
        scroll_vbox->setContentsMargins(0, 0, 0, 0);
        scroll_vbox->setSpacing(0);
        scroll_widget->setLayout(scroll_vbox);
    
    
        scroll_area->setWidget(scroll_widget);
        scroll_area->setWidgetResizable(true);
    
        auto vbox0 = new QVBoxLayout(main_widget);
        main_widget->setLayout(vbox0);
        vbox0->addWidget(scroll_area);
    
        QPlainTextEdit
            * plainTextEdit0 = new QPlainTextEdit(),
            * plainTextEdit1 = new QPlainTextEdit(),
            * plainTextEdit2 = new QPlainTextEdit();
        plainTextEdit1->setPlainText("A looooooooooooooooong text carefully tailored to fit exactly in 1 line of text, and without leaving enough space for the scrollbar exactly here.");
        plainTextEdit2->setPlainText("line 1\nline 2\r\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20");
    
        for (auto plainTextEdit : { plainTextEdit0, plainTextEdit1, plainTextEdit2 }) {
            scroll_vbox->addWidget(plainTextEdit);
    
    
            auto margins = plainTextEdit->contentsMargins();
            auto margin = plainTextEdit->document()->rootFrame()->frameFormat().margin();
            qreal verticalmarginSize = 2 * margin + margins.top() + margins.bottom();
    
            QFont font = plainTextEdit->font();
            QFontMetrics fontMetrics(font);
            int lineHeight = fontMetrics.lineSpacing();
            
    
            // Portion with the incorrect behavior -> To be removed
            auto sizeCalculation = [plainTextEdit, lineHeight, verticalmarginSize]() {
                auto document = plainTextEdit->document();
                auto size = document->size();
                int height = lineHeight * size.height() + verticalmarginSize + 1;
                if (height != plainTextEdit->height())
                    plainTextEdit->setFixedHeight(height);
            };
            QObject::connect(plainTextEdit, &QPlainTextEdit::textChanged, sizeCalculation);
    
            SizeEventFilter* sizeEventFilter = new SizeEventFilter(plainTextEdit);
            plainTextEdit->installEventFilter(sizeEventFilter);
            QObject::connect(sizeEventFilter, &SizeEventFilter::sizeChanged, plainTextEdit, sizeCalculation, Qt::QueuedConnection);
            // End of Portion with the incorrect behavior
            
            //// Better behavior -> To be uncommented
            //auto sizeCalculation1 = [plainTextEdit, scroll_vbox, lineHeight, verticalmarginSize]()
            //{
            //    auto document = plainTextEdit->document();
            //    auto size = document->size();
            //    int height = lineHeight * size.height() + verticalmarginSize + 1;
            //    if (height != plainTextEdit->height())
            //        plainTextEdit->setFixedHeight(height);
            //    scroll_vbox->invalidate();
            //    scroll_vbox->activate();
            //};
            //auto sizeCalculation2 = [plainTextEdit, scroll_area, scroll_vbox, lineHeight, verticalmarginSize]()
            //{
            //    auto document = plainTextEdit->document();
            //    auto size = document->size();
            //    int height = lineHeight * size.height() + verticalmarginSize + 1;
            //    bool tryWithoutScrollBar = height < plainTextEdit->height() && scroll_area->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded;
    
            //    if (tryWithoutScrollBar) {
            //        scroll_area->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
            //        scroll_vbox->invalidate();
            //        //scroll_vbox->update();
            //        scroll_vbox->activate();
            //        document = plainTextEdit->document();
            //        size = document->size();
            //        height = lineHeight * size.height() + verticalmarginSize + 1;
            //        plainTextEdit->setFixedHeight(height);
            //        scroll_area->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
            //        scroll_vbox->invalidate();
            //        //scroll_vbox->update();
            //        scroll_vbox->activate();
            //    }
            //    else if (height != plainTextEdit->height())
            //        plainTextEdit->setFixedHeight(height);
            //};
    
            //QObject::connect(plainTextEdit, &QPlainTextEdit::textChanged, sizeCalculation2);
    
            //SizeEventFilter* sizeEventFilter = new SizeEventFilter(plainTextEdit);
            //plainTextEdit->installEventFilter(sizeEventFilter);
            //QObject::connect(sizeEventFilter, &SizeEventFilter::sizeChanged, plainTextEdit, sizeCalculation1, Qt::QueuedConnection);
            //QObject::connect(sizeEventFilter, &SizeEventFilter::sizeChanged, plainTextEdit, sizeCalculation2, Qt::DirectConnection);
            //// End of better behavior
    
            plainTextEdit->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
            plainTextEdit->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
        }
        scroll_vbox->addSpacerItem(new QSpacerItem(0, 0, QSizePolicy::Minimum, QSizePolicy::MinimumExpanding));
    
        main_widget->show();
    
        return app.exec();
    }
    

    This turned out to be a lengthy answer, sorry for that...