Search code examples
qtpysidepyside6

PySide6 Qt: Scrollbar length is wrong after moving cursor in QPlainTextEdit


Here is a minimal example to showcase the problem:

import sys

from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import (
    QApplication,
    QMainWindow,
    QPlainTextEdit,
)

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setGeometry(100, 100, 600, 400)
        self.textEdit = QPlainTextEdit(self)

        self.textEdit.setPlainText(
            f"Line 1 {'aaa ' * 1000}\n"
            "Line 2\n"
            "Line 3\n"
            "Line 4\n"
            "Line 5\n"
            f"Line 6 {'aaa ' * 1000}\n"
        )

        cursor = self.textEdit.textCursor()
        cursor.movePosition(QTextCursor.Start)
        # Move cursor to line 3
        cursor.movePosition(QTextCursor.Down, QTextCursor.MoveAnchor, 3 - 1)
        self.textEdit.setTextCursor(cursor)

        # scrollbarPosition = self.textEdit.verticalScrollBar().value()
        # self.textEdit.verticalScrollBar().setValue(0)
        # self.textEdit.verticalScrollBar().setValue(scrollbarPosition)

        self.setCentralWidget(self.textEdit)

app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

When I run this on Windows 10, I get:

image 1

As you can see, the cursor starts on line 3 as intended. But now if I scroll even a little bit up using my mouse, the following happens:

image 2

As you can see, the scrollbar got shorter and the scroll view jumped all the way to the top of the text field. It's as if Qt didn't know that lines 1 and 2 were there (or more precisely, it knew the lines were there but didn't expect line 1 to be so long), and when I scrolled up it figured out that there was text above line 3, and then loaded them up, and then jumped all the way to the top. The cursor remains on line 3, however (which is good).

If I uncomment just the line

self.textEdit.verticalScrollBar().setValue(0)

from above, then the scrollbar's length is correct, but of course the scroll view starts at the top rather than on line 3.

If I uncomment all three of:

scrollbarPosition = self.textEdit.verticalScrollBar().value()
self.textEdit.verticalScrollBar().setValue(0)
self.textEdit.verticalScrollBar().setValue(scrollbarPosition)

to try to remember the initial scrollbar position, go to the top (hopefully to load the content at the top), then scroll back down to line 3, then the result is exactly the same as in the initial program -- the scrollbar's length is now incorrect!

How can I get it so that the scrollbar's length doesn't change when scrolling, and the program starts on line 3, and if I scroll up a bit, it just shows line 2 and the last bit of line 1 (rather than jumping all the way to the beginning of the text field)?


Solution

  • The problem is caused by the fact that you're moving the cursor when the widget is not being shown yet.

    While, in normal circumstances, that may work fine, QPlainTextEdit has its own way of managing the text layout and, therefore, scroll bar behavior. This doesn't work well with the event queue, because scroll area require some processing before finally display and "scroll" their contents, and the special behavior of QPlainTextEdit doesn't work well with that.

    If your issue is to move the cursor on top, displaying a specific line, the only way you have to do that is to ensure that it's done only as soon as the widget is finally mapped ("shown") the first time, so that Qt has enough "time" to properly resize its contents and finally update both the cursor and scroll bars.

    Also, be aware that using QTextCursor.Down is not appropriate: you clearly want to move to the next line (of the text), so the more correct enum is QTextCursor.NextBlock.

    One way to achieve this is to use an internal flag that will eventually be checked within the showEvent(), so that whatever is needed is only done once.

    class MainWindow(QMainWindow):
        _firstShown = False
        def __init__(self):
            super().__init__()
    
            self.setGeometry(100, 100, 600, 400)
            self.textEdit = QPlainTextEdit(self)
    
            self.textEdit.setPlainText(
                f"Line 1 {'aaa ' * 1000}\n"
                "Line 2\n"
                "Line 3\n"
                "Line 4\n"
                "Line 5\n"
                f"Line 6 {'aaa ' * 1000}\n"
            )
    
            # everything is as above, but I removed the QTextCursor stuff
    
            self.setCentralWidget(self.textEdit)
            self._targetLine = 3
    
        def showEvent(self, event):
            super().showEvent(event)
            if not self._firstShown:
                # reset the flag
                self._firstShown = True
    
                # scroll to the bottom, so that the cursor will eventually be 
                # shown on top when moved
                self.textEdit.verticalScrollBar().setValue(
                    self.textEdit.verticalScrollBar().maximum())
    
                cursor = self.textEdit.textCursor()
    
                # going to the third line means moving to the next block twice
                for i in range(self._targetLine - 1):
                    cursor.movePosition(QTextCursor.NextBlock)
    
                self.textEdit.setTextCursor(cursor)
    

    A more appropriate implementation should probably use a QPlainTextEdit subclass that accepts or uses that _targetLine value and implement its own showEvent().

    Finally, be aware that, due to the special implementation of QPlainTextEdit (and its QPlainTextDocumentLayout) explained above, resizing and scrolling will always be somehow unreliable to some extent when having very long lines. This is an unfortunate drawback of its implementation, which aims more to UI performance (prevent freezing as much as possible) than UI reliability.