Home > Software design >  How to scroll Qt widgets by keyboard?
How to scroll Qt widgets by keyboard?

Time:07-03

This is a portion of a Python class that I wrote:

    def keyPressEvent(self, event):
        if event.text() == 'n':
            self.widget.scroll(0, -self.height())
        elif event.text() == 'p':
            self.widget.scroll(0, self.height())

When the user presses n, the widget widget (which has a scrollbar), will scroll downwards by self.height() pixels (which is the entire height of the window; i.e. next page). Opposite is when p is pressed: returns to previous page.

But the problem is that:

  • Usually, a proper scroller will stop when it reaches page's top. But pressing p will keep going backwards towards infinity in the back, where there is an indefinitely deep void of nothingness!
  • Opposite is true for n; jumps into some unseen abyss into the distant future, beyond the communication horizon.

Question. How to make a proper scroller using keyboard bindings? A proper scroller is one where it saturates at page's boundaries.


Appendix

This is more code (still trimmed to be readable). My goal here is to show what kind of widgets I am using to address a question in a comment.

#!/usr/bin/python3

import sys
import random

try:
    from PySide6 import QtCore, QtWidgets
except ModuleNotFoundError:
    from PySide2 import QtCore, QtWidgets

# ... <omitted for brevity> ...

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

        # create ui's elements
        self.scroll = QtWidgets.QScrollArea()
        self.widget = QtWidgets.QWidget()
        self.hbox   = QtWidgets.QHBoxLayout()

        # link ui's elements
        self.setCentralWidget(self.scroll)
        self.scroll.setWidget(self.widget)
        self.widget.setLayout(self.hbox)

        # configure ui's elements
        self.scroll.setWidgetResizable(True)
        self.scroll.setVerticalScrollBarPolicy(
            QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.scroll.setHorizontalScrollBarPolicy(
            QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)

        self.columns = []
        self.add_column()

        self.show()

    # ... <omitted for brevity> ...


    def add_column(self):
        # ... <omitted for brevity> ...

    # ... <omitted for brevity> ...

    def keyPressEvent(self, event):
        # ... <omitted for brevity> ...
        if event.text() == c['key_next_page']:
            self.widget.scroll(0, -self.height())
        elif event.text() == c['key_prev_page']:
            self.widget.scroll(0, self.height())

    # ... <omitted for brevity> ...

CodePudding user response:

The scroll() function rarely needs to be directly used.

QScrollBar already calls it internally, by overriding the virtual function of its inherited class (QAbstractScrollArea), scrollContentsBy(). As the documentation says:

Calling this function in order to scroll programmatically is an error, use the scroll bars instead (e.g. by calling QScrollBar::setValue() directly).

So, the solution is usually quite simple: instead of scrolling the widget, set the value of the scroll bar. Note that this will always work, even when scroll bars are hidden.

Before providing the final code, please note two aspects:

  • when checking for keys, it's usually better to use the key enumeration instead of the text() value of the key event; there are many reasons for this, but, most importantly, it's related to the fact that "keys" are usually mapped to numeric values, and using the actual internal value is generally more accurate and reliable than comparing strings;
  • you shall not override the keyPressEvent() of the parent (unless you really know what you're doing and why): if the parent has other widgets that only handle certain keys but not what you're looking for, you may end up in scrolling the area for the wrong reason; suppose you have complex layout that also has a QSpinBox outside of the scroll area: that control doesn't generally handle the P key, but the user might press it by mistake (trying to press 0), and they'll see an unexpected behavior: they typed the wrong key, and another, unrelated widget got scrolled;

The solution is to subclass QScrollArea and override its keyPressEvent() instead, then set the value of the scroll bar using its pageStep() property (which, for QScrollArea, equals to the width or height of the scrolled widget).

from PyQt5 import QtCore, QtWidgets

class KeyboardScrollArea(QtWidgets.QScrollArea):
    def keyPressEvent(self, event):
        if event.key() in (QtCore.Qt.Key_N, QtCore.Qt.Key_P):
            sb = self.verticalScrollBar()
            if (event.key() == QtCore.Qt.Key_P 
                and sb.value() > 0):
                    sb.setValue(sb.value() - sb.pageStep())
                    return
            elif (event.key() == QtCore.Qt.Key_N 
                and sb.value() < sb.maximum()):
                    sb.setValue(sb.value()   sb.pageStep())
                    return
        super().keyPressEvent(event)

if __name__ == '__main__':
    app = QtWidgets.QApplication([])
    scroll = KeyboardScrollArea()
    widget = QtWidgets.QWidget()
    scroll.setWidget(widget)
    scroll.setWidgetResizable(True)
    layout = QtWidgets.QVBoxLayout(widget)
    for i in range(50):
        layout.addWidget(QtWidgets.QPushButton(str(i   1)))
    scroll.show()
    app.exec_()

Note that I explicitly checked that the scroll bar value was greater than 0 (for "page up") and lass then the maximum. That is because when an event is not handled, it should normally be propagated to the parent. Suppose you have a custom scroll area inside another custom scroll area: you may want to be able to keep moving up/down the outer scroll area whenever the inner one cannot scroll any more.

Another possibility is to install an event filter on the scroll area, and listen for KeyPress events, then do the same as above (and, in that case, return True when the scroll actually happens). The concept is basically the same, the difference is that you don't need to subclass the scroll area (but a subclass will still be required, as the event filter must override eventFilter()).

  • Related