QPlainTextEdit pros and cons
The problem with QPlainTextEdit is that, due to its optimizations, it has some peculiar aspects when dealing with vertical scrolling and text positioning.
In fact, its default QTextDocument uses a special QAbstractTextDocumentLayout subclass (QPlainTextDocumentLayout), which:
[...] does not operate on vertical pixels, but on paragraphs (called blocks) instead. The height of a document is identical to the number of paragraphs it contains.
The vertical scroll bar itself has a different behavior, and when it's triggered it actually scrolls the contents based on the position of its block and their lines, instead of working in pixel deltas (as opposed to what a normal scroll area would, including QTextEdit), and eventually adjusts the scroll bar value accordingly.
It practically works in a way similar to the default scrollMode
of item views: ScrollPerItem
(aka, "scroll per line") instead of ScrollPerPixel
.
Its maximum value, in fact, is the total line number minus the visible line count (including line wrapping for both): if you have 10 lines and you can see 6 full lines, the scroll bar range is only of 4.
The bottom margin that you see is caused by the fact that QPlainTextEdit always shows the first visible line completely, even if that results in displaying that bottom margin way beyond the height of the last line: for this class, the priority is to show any top line in its full height.
Unfortunately, working around this may be a bit complex due to the altered behavior above. Also, that same behavior results in some cases for which the valueChanged
signal of the scroll bar is never emitted at all, because the value adjustment explained above is done with a QSignalBlocker context that prevents any connected function to receive its changes.
A QTextEdit substitute
As long as the contents of the editor are not that big (which is the common reason for using QPlainTextEdit), there is a simpler solution: use a QTextEdit instead and fix the scroll bar position while it's being updated.
This is possible exactly because QTextEdit uses pixel precision (instead of lines), meaning that changing the scroll bar value by 1 results in a single pixel scrolling of the viewport.
In order to achieve the above, we have to remember that widgets that inherit from QAbstractSlider (like QSlider and QScrollBar) have two different properties that indicate their values:
sliderPosition
: the "visible" slider position;
value
: the "final" value exposed by the slider, which is what scroll areas actually use to scroll the viewport;
Even if, for normal usage, both values normally coincide (unless tracking
is disabled), internally they are not updated at the same time: the slider is "moved" first, but the value is updated after.
The actionTriggered
signal is triggered exactly when "something" tells the slider to move (by using the mouse, the wheel or the arrow buttons), but the value has not changed yet; as the documentation explains:
When the signal is emitted, the sliderPosition has been adjusted according to the action, but the value has not yet been propagated (meaning the valueChanged() signal was not yet emitted), and the visual display has not been updated. In slots connected to this signal you can thus safely adjust any action by calling setSliderPosition() yourself, based on both the action and the slider's value.
The final line is what interests us more: we can fix up the slider position before its value has been propagated, so that when it will be actually applied, it will use the final value we set in setSliderPosition()
.
Considering that the scroll bar moves the viewport in unit/pixels, we assume that the value would need to be an exact multiple of the line height.
We just take the current slider position, use a floor division (//
) with the font metrics' height()
to get the line count, and multiply it back by the height again. The divide/multiply is necessary to get a precise multiple of the line height so that it "snaps" at the correct position.
For instance, assuming we have a line height of 15 pixels and the slider position at 40, the resulting formula would be the following:
lineCount = 40 // 15 # 2 -> the third line
newPos = 2 * 15 # 30
Alternatively, for extremely accurate scrolling precision movement, you could check whether the modulo division returns a rest that is higher than half the font height and eventually add a further line; divmod(40, 15)
would return 2, 10
, so lineCount
would actually be 3, meaning that the scrolling to the next line would happen a few "scroll bar pixels" before.
Finally, we need to consider some further factors:
- we should only do this for actions that actually move the vertical scroll bar within its range (ignoring scrolling to minimum/maximum or the
SliderNoAction
);
- starting from the second line, scrolling should consider the document margin, so that lines are always shown on top (without displaying part of the previous line);
- if the
sliderPosition()
is equal to the maximum, we should just scroll to the bottom (so, without doing any of the above);
- moving the cursor (while typing or using keyboard navigation) might result in not properly showing the top or bottom edge of the document; while QTextEdit is smart enough to scroll the contents enough to make lines visible when the cursor is in it, it may be visually annoying to see that there's still some pixels available on top or bottom;
- the fix-up should also be done when the widget is resized, due to special cases of resizing when word wrapping is enabled;
- the document should always comply to the requirements of a QPlainTextEdit: the font must always be the same, and there should never be any advanced layout content that goes beyond lists (see the QPlainTextDocumentLayout documentation linked above): while it may probably be done for different fonts and nested contents too, line positioning would become extremely more complex (like "snapping" to lines that belong to table cells having different line counts within the same row) and much less efficient; since the purpose of this question is intended to be a QPlainTextEdit substitute, that shouldn't be a problem;
- to prevent accidental copy/paste of rich text contents that would create problems when using different fonts or nested objects (as explained above),
acceptRichText
must be set to False
;
Actual example
Here is a possible implementation of the subclass:
class FakePlainTextEdit(QTextEdit):
SnapActions = (
QAbstractSlider.SliderSingleStepAdd,
QAbstractSlider.SliderSingleStepSub,
QAbstractSlider.SliderPageStepAdd,
QAbstractSlider.SliderPageStepSub,
QAbstractSlider.SliderMove,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setAcceptRichText(False)
self.verticalScrollBar().actionTriggered.connect(self._maybeSnap)
self.cursorPositionChanged.connect(self._maybeScrollToEdge)
def _maybeScrollToEdge(self):
if not self.verticalScrollBar().maximum():
return
tc = self.textCursor()
block = tc.block()
if block == self.document().firstBlock():
blockLine = block.layout().lineForTextPosition(
tc.positionInBlock()).lineNumber()
if blockLine == 0:
self.verticalScrollBar().setValue(0)
elif block == self.document().lastBlock():
blockLine = block.layout().lineForTextPosition(
tc.positionInBlock()).lineNumber()
if blockLine == block.layout().lineCount() - 1:
self.verticalScrollBar().setValue(
self.verticalScrollBar().maximum())
def _maybeSnap(self, action):
if action in self.SnapActions:
self.snapToLine()
def snapToLine(self):
vb = self.verticalScrollBar()
if vb.sliderPosition() < vb.maximum():
snapSize = QFontMetrics(self.document().defaultFont()).height()
lineCount = vb.sliderPosition() // snapSize
newPos = lineCount * snapSize
if lineCount:
newPos += self.document().documentMargin()
vb.setSliderPosition(newPos)
def resizeEvent(self, event):
super().resizeEvent(event)
# necessary for some fringe cases when using word wrapping
self.snapToLine()
Note that, for obvious reasons, you can still call setValue()
on the scroll bar using arbitrary values that do not match lines precisely.