If you're working with a QTextEdit widget and updating its contents frequently, you've probably noticed an annoying behavior: every time you call setText(), the scroll position jumps back to the top. This means your users lose their place in the text every time an update happens.
In this tutorial, we'll look at three practical ways to preserve the user's position when updating text in a QTextEdit: saving and restoring the cursor position, saving and restoring the scroll (viewport) position, or doing both at once.
Why does the scroll reset?
When you call setText() on a QTextEdit, Qt replaces the entire document content. This resets the internal text cursor to position zero and moves the scrollbar back to the top. It makes sense from Qt's perspective—the old content is gone, so the old position is meaningless—but it's frustrating when the new content is similar to the old content and you want the user to stay where they were.
The solution is to read the current position before updating, then restore it afterwards.
Preserving the cursor position
The first approach focuses on restoring the text cursor—the blinking caret that shows where the user is typing or has clicked. This is useful if your users interact with the text and you want their selection or insertion point to remain intact after an update.
Here's a complete working example:
from PyQt5.QtGui import QTextCursor
from PyQt5.QtWidgets import QPushButton, QTextEdit, QApplication, QVBoxLayout, QWidget
import random
app = QApplication([])
window = QWidget()
layout = QVBoxLayout()
text_edit = QTextEdit()
button = QPushButton("Update Text")
layout.addWidget(text_edit)
layout.addWidget(button)
window.setLayout(layout)
def generate_random_text():
"""Generate some random placeholder text."""
return " ".join(
"".join(chr(random.randint(ord("a"), ord("z"))) for _ in range(6))
for __ in range(1555)
)
def preserve_cursor():
# Save the current cursor position and anchor (start of selection).
cursor = text_edit.textCursor()
position = cursor.position()
anchor = cursor.anchor()
# Update the text — this resets everything.
text_edit.setText(generate_random_text())
# Restore the cursor position.
cursor.setPosition(anchor)
if position > anchor:
direction = QTextCursor.NextCharacter
else:
direction = QTextCursor.PreviousCharacter
cursor.movePosition(
direction, QTextCursor.KeepAnchor, abs(position - anchor)
)
text_edit.setTextCursor(cursor)
button.clicked.connect(preserve_cursor)
window.show()
app.exec_()
The QTextCursor object has two important values: the position (where the cursor currently is) and the anchor (the other end of a text selection, if any). If nothing is selected, the position and anchor are the same.
After calling setText(), we rebuild the cursor by first setting it to the anchor point, then moving it character by character back to the original position. The QTextCursor.KeepAnchor flag means "extend the selection as you move," which restores any text selection the user had.
One thing to be aware of: if the new text is shorter than the old text, the saved position might be beyond the end of the new content. In a real application, you'd want to clamp the values to the length of the new text.
Purchasing Power Parity
Developers in [[ country ]] get [[ discount.discount_pc ]]% OFF on all books & courses with code [[ discount.coupon_code ]]Preserving the viewport (scroll) position
The second approach is often more practical. Instead of worrying about the cursor, it preserves where the user was scrolled to. This is ideal when the text content changes but has roughly the same length, and you just want the view to stay put.
from PyQt5.QtWidgets import QPushButton, QTextEdit, QApplication, QVBoxLayout, QWidget
import random
app = QApplication([])
window = QWidget()
layout = QVBoxLayout()
text_edit = QTextEdit()
button = QPushButton("Update Text")
layout.addWidget(text_edit)
layout.addWidget(button)
window.setLayout(layout)
def generate_random_text():
"""Generate some random placeholder text."""
return " ".join(
"".join(chr(random.randint(ord("a"), ord("z"))) for _ in range(6))
for __ in range(1555)
)
def preserve_viewport():
scrollbar = text_edit.verticalScrollBar()
# Calculate the scroll position as a ratio (0.0 to 1.0).
# The "or 1" prevents division by zero if the scrollbar has no range.
old_ratio = scrollbar.value() / (scrollbar.maximum() or 1)
# Update the text.
text_edit.setText(generate_random_text())
# Restore the scroll position using the saved ratio.
scrollbar.setValue(round(old_ratio * scrollbar.maximum()))
button.clicked.connect(preserve_viewport)
# Set some initial text so we have something to scroll through.
text_edit.setText(generate_random_text())
window.show()
app.exec_()
The trick here is to store the scroll position as a ratio rather than an absolute pixel value. We divide the current scrollbar value by its maximum to get a number between 0 and 1. After the text is replaced, we multiply that ratio by the new maximum to find the equivalent position.
This ratio-based approach works well even if the new text is a slightly different length than the old text—the user will end up at approximately the same proportional location in the document.
Notice the (scrollbar.maximum() or 1) expression. This is a guard against division by zero, which would happen if the text is short enough that no scrollbar is needed (i.e., the maximum is 0).
Preserving both cursor and viewport
For the most complete solution, you can combine both approaches. This restores the cursor position and the scroll location, giving the smoothest experience:
from PyQt5.QtGui import QTextCursor
from PyQt5.QtWidgets import QPushButton, QTextEdit, QApplication, QVBoxLayout, QWidget
import random
app = QApplication([])
window = QWidget()
layout = QVBoxLayout()
text_edit = QTextEdit()
button = QPushButton("Update Text")
layout.addWidget(text_edit)
layout.addWidget(button)
window.setLayout(layout)
def generate_random_text():
"""Generate some random placeholder text."""
return " ".join(
"".join(chr(random.randint(ord("a"), ord("z"))) for _ in range(6))
for __ in range(1555)
)
def full_preserve():
# Save cursor state.
cursor = text_edit.textCursor()
position = cursor.position()
anchor = cursor.anchor()
# Save scroll state.
scrollbar = text_edit.verticalScrollBar()
old_ratio = scrollbar.value() / (scrollbar.maximum() or 1)
# Update the text.
text_edit.setText(generate_random_text())
# Restore cursor.
cursor.setPosition(anchor)
if position > anchor:
direction = QTextCursor.NextCharacter
else:
direction = QTextCursor.PreviousCharacter
cursor.movePosition(
direction, QTextCursor.KeepAnchor, abs(position - anchor)
)
text_edit.setTextCursor(cursor)
# Restore scroll position.
scrollbar.setValue(round(old_ratio * scrollbar.maximum()))
button.clicked.connect(full_preserve)
# Set initial text.
text_edit.setText(generate_random_text())
window.show()
app.exec_()
The order matters here: restore the cursor before the scroll position. Setting the text cursor can itself cause the viewport to scroll (Qt scrolls to make the cursor visible), so restoring the scrollbar value last ensures it sticks.
Which approach should you use?
It depends on your use case:
preserve_cursor— Use this when the user is actively editing or selecting text, and you want their cursor/selection to remain after a background update.preserve_viewport— Use this when the user is reading (not editing) and you just want them to stay at the same place in the document. This is the most commonly needed solution.full_preserve— Use this when you want the most seamless experience and the user might be both reading and interacting with the text.
For most applications where you're updating displayed text on a timer or from a background process, preserve_viewport is the simplest and most effective choice. If you're updating from a background thread, make sure you're handling the signals and slots correctly to avoid cross-thread GUI updates.
What about QScrollArea?
If you're using a QTextEdit directly (without wrapping it in a separate QScrollArea), you can use the techniques above—QTextEdit has its own built-in scrollbar accessible via verticalScrollBar().
If you are using a QScrollArea to wrap another widget, the same scrollbar approach works. Just access the scroll area's scrollbar instead:
scrollbar = scroll_area.verticalScrollBar()
old_ratio = scrollbar.value() / (scrollbar.maximum() or 1)
# ... update your widget content ...
scrollbar.setValue(round(old_ratio * scrollbar.maximum()))
The concept is identical—save the position as a ratio, update the content, then restore. For more on organizing widgets within your window, see the PyQt5 layouts tutorial. If you're working with PyQt5's basic widgets and want to understand how QTextEdit fits alongside other input widgets, that's a good place to start.
Create GUI Applications with Python & Qt6 by Martin Fitzpatrick — (PyQt6 Edition) The hands-on guide to making apps with Python — Save time and build better with this book. Over 15K copies sold.