1
from PyQt5 import QtCore, QtGui, QtWidgets


class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
        MainWindow.resize(800, 130)
        self.centralwidget = QtWidgets.QWidget(MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.gridLayout = QtWidgets.QGridLayout(self.centralwidget)
        self.gridLayout.setObjectName("gridLayout")
        self.plainTextEdit = QtWidgets.QPlainTextEdit(self.centralwidget)
        self.plainTextEdit.setStyleSheet("padding:0px;")
        self.plainTextEdit.setObjectName("plainTextEdit")
        self.gridLayout.addWidget(self.plainTextEdit, 0, 0, 1, 1)
        MainWindow.setCentralWidget(self.centralwidget)
        self.menubar = QtWidgets.QMenuBar(MainWindow)
        self.menubar.setGeometry(QtCore.QRect(0, 0, 800, 21))
        self.menubar.setObjectName("menubar")
        MainWindow.setMenuBar(self.menubar)
        self.statusbar = QtWidgets.QStatusBar(MainWindow)
        self.statusbar.setObjectName("statusbar")
        MainWindow.setStatusBar(self.statusbar)

        self.retranslateUi(MainWindow)
        QtCore.QMetaObject.connectSlotsByName(MainWindow)

    def retranslateUi(self, MainWindow):
        _translate = QtCore.QCoreApplication.translate
        MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))
        self.plainTextEdit.setPlainText(_translate("MainWindow", "1\n"
"1\n"
"1\n"
"1\n"
"1\n"
"1\n"
"1\n"
"1\n"
"1\n"
"1\n"
"1\n"
"1\n"
"last line."))


if __name__ == "__main__":
    import sys
    app = QtWidgets.QApplication(sys.argv)
    MainWindow = QtWidgets.QMainWindow()
    ui = Ui_MainWindow()
    ui.setupUi(MainWindow)
    MainWindow.show()
    sys.exit(app.exec_())

Result:

enter image description here

Some work-arounds:

            #self.main_self.ui_scheduled_transmitions_create_window.review_text.setPlainText("")
            self.main_self.ui_scheduled_transmitions_create_window.review_text.setPlainText(review_text)
            self.main_self.ui_scheduled_transmitions_create_window.review_text.setDocumentTitle("123")
            #if self.main_self.ui_scheduled_transmitions_create_window.review_text.height()<800:
            #   self.main_self.ui_scheduled_transmitions_create_window.review_text.setFixedHeight(self.main_self.ui_scheduled_transmitions_create_window.review_text.height())
            #else:
            #   self.main_self.ui_scheduled_transmitions_create_window.review_text.setFixedHeight(800)


            self.main_self.ui_scheduled_transmitions_create_window.review_text.document().setDocumentMargin(0)
            
            
            #self.main_self.ui_scheduled_transmitions_create_window.review_text.setCenterOnScroll(False)
            m =  self.main_self.ui_scheduled_transmitions_create_window.review_text.fontMetrics()
            RowHeight = m.lineSpacing()
            nRows = self.main_self.ui_scheduled_transmitions_create_window.review_text.document().blockCount()
            text_height = RowHeight*nRows
            
            document_format = self.main_self.ui_scheduled_transmitions_create_window.review_text.document().rootFrame().frameFormat()
            document_format.setBottomMargin(0)
            document_format.setHeight(text_height)
            self.main_self.ui_scheduled_transmitions_create_window.review_text.document().rootFrame().setFrameFormat(document_format)
            scrollBarHeight=self.main_self.ui_scheduled_transmitions_create_window.review_text.horizontalScrollBar().sizeHint().height()
            #scrollBarHeight=0
            if text_height+scrollBarHeight>800:
                self.main_self.ui_scheduled_transmitions_create_window.review_text.setFixedHeight(600)
            else:
                self.main_self.ui_scheduled_transmitions_create_window.review_text.setFixedHeight(text_height+scrollBarHeight)
musicamante
  • 41,230
  • 6
  • 33
  • 58
Chris P
  • 2,059
  • 4
  • 34
  • 68
  • 1
    The document layout engine and behavior of QPlainTextEdit is a bit peculiar, and has some big limitations for customization like this. For instance, the `valueChanged` signal is not sent from the vertical scroll bar when triggering page up/down actions. Now, while I understand the need for this, is using QPlainTextEdit a necessary requirement (which would probably be the fact that you have *extremely large* text content)? Otherwise, just use a QTextEdit, which actually provides a more precise scrolling. If you then need precise "line scrolling" as QPlainTextEdit does, that could be achieved. – musicamante Jul 19 '23 at 20:52
  • See also: https://forum.qt.io/topic/134761/qplaintextedit-has-excess-space-at-the-bottom-of-the-document One permant solution is: because the lines are fixed (allways 24). I can use the setFixedHeight and set QScrollBars allways off. – Chris P Jul 20 '23 at 05:24
  • That is not a solution, it is a work around that may be fine only for that specific requirement of *known* fixed lines (which you didn't express in the post, by the way), but it won't work if lines become longer than the widget width: if you allow word wrapping, you'll end up with invisible and unaccessible content (since you are disabling the scroll bars), and if you don't you should take into account the horizontal scroll bar which may or may not be necessary, thus leaving an even bigger space anyway. – musicamante Jul 20 '23 at 14:25

1 Answers1

0

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.

musicamante
  • 41,230
  • 6
  • 33
  • 58