Search code examples
pythonuser-interfacepyqt5

Pyqt5 detect scrolling by clicking below handle


I am using pyqt5 to build an application where I have two plain text edit boxes on the main window.

I'm making it so when one scrolls the other does too, and I did it! using valuechanged signal, but whenever the user clicks below the handle, the signal isn't emitted for some reason even the handle scrolled down and so did the content of the text box.

Can anyone help?

I tried using all the signals that are listed in the pyqt5 documentation but to no avail.

Here's my code


class CustomLineEdit(QPlainTextEdit):

    scrolled = pyqtSignal(int) # signal to emit if scrolled

    def __init__(self) -> None:

        super(CustomLineEdit, self).__init__()

        self.verticalScrollBar().valueChanged.connect(self.on_scroll)
    
    def on_scroll(self, value):
        self.scrolled.emit(value)

Solution

  • QPlainTextEdit has a very peculiar behavior, which has been implemented in order to provide high performance even with very large documents.

    One of the aspects that change between the more "standard" QTextEdit (as also explained in the documentation) is that:

    [it] replaces a pixel-exact height calculation with a line-by-line respectively paragraph-by-paragraph scrolling approach

    In order to do so, the height of the document is always the line count (not the pixel size of the document), which requires special handling of the vertical scroll bar. Unfortunately, one of the drawback of this implementation is that some value changes in the scroll bar values are not emitted with the standard valueChanged signal, because QPlainTextEdit changes the range or value of the scroll bar using QSignalBlocker, preventing any connection to be notified.

    Luckily, QAbstractSlider (from which QScrollBar inherits) has a sliderChange() virtual function that is always called after the following changes happen:

    • SliderRangeChange (the minimum and maximum range has changed);
    • SliderOrientationChange (horizontal to vertical and vice versa, not useful to us);
    • SliderStepsChange (the "step" normally used for the arrow buttons, probably not useful too for this case);
    • SliderValueChange (the actual value change);

    Considering this, we can create a subclass of QSlider and override that to check for real changes, no matter if the signals have been blocked. The trick is to keep track of the previous scroll bar value, which we can normally assume as always being 0 during initialization.

    Then we directly connect the custom scroll bar signal with that of the CustomLineEdit and eventually connect that signal to whatever we need.

    class MyScrollBar(QScrollBar):
        oldValue = 0
        realValueChanged = pyqtSignal(int)
        def sliderChange(self, change):
            super().sliderChange(change)
            new = self.value()
            if self.oldValue != new:
                self.oldValue = new
                self.realValueChanged.emit(new)
    
    
    class CustomLineEdit(QPlainTextEdit):
        scrolled = pyqtSignal(int) # signal to emit if scrolled
        def __init__(self, *args, **kwargs) -> None:
            super(CustomLineEdit, self).__init__(*args, **kwargs)
            vbar = MyScrollBar(Qt.Vertical, self)
            self.setVerticalScrollBar(vbar)
            vbar.realValueChanged.connect(self.scrolled)
    

    Note that this might not be ideal when using the signal for another "sibling" QPlainTextEdit that doesn't share the same line count. For instance, you may be comparing two slightly different documents, or they have different sizes that may affect the vertical scroll bar depending on the word wrap setting.

    Another possibility would be to always use the value as a proportion of the maximum: proportional = self.value() / self.maximum(). This obviously requires to change the signal signature to float.

    In the following example I'm creating two CustomLineEdit instances and connect their respective signals respectively. Even using slightly different text contents will always keep the scrolling proportional, for both scroll bars.

    from PyQt5.QtCore import *
    from PyQt5.QtWidgets import *
    
    class MyScrollBar(QScrollBar):
        oldValue = 0
        realValueChanged = pyqtSignal(float)
        def sliderChange(self, change):
            super().sliderChange(change)
            new = self.value()
            if new:
                new /= self.maximum()
            if self.oldValue != new:
                self.oldValue = new
                self.realValueChanged.emit(new)
    
    
    class CustomLineEdit(QPlainTextEdit):
        scrolled = pyqtSignal(float) 
        def __init__(self, *args, **kwargs) -> None:
            super(CustomLineEdit, self).__init__(*args, **kwargs)
            vbar = MyScrollBar(Qt.Vertical, self)
            self.setVerticalScrollBar(vbar)
            vbar.realValueChanged.connect(self.scrolled)
    
        def scroll(self, value):
            if self.verticalScrollBar().maximum():
                # NOTE: it's *very* important to use round() and not int()
                self.verticalScrollBar().setValue(
                    round(value * self.verticalScrollBar().maximum()))
    
    app = QApplication([])
    edit1 = CustomLineEdit('\n'.join('line {}'.format(i+1) for i in range(20)))
    edit2 = CustomLineEdit('\n'.join('line {}'.format(i+1) for i in range(21)))
    
    # connect the signal of each edit to the other
    edit1.scrolled.connect(edit2.scroll)
    edit2.scrolled.connect(edit1.scroll)
    
    test = QWidget()
    lay = QHBoxLayout(test)
    lay.addWidget(edit1)
    lay.addWidget(edit2)
    test.show()
    app.exec()