Search code examples
pythonpyqt5pyside2qscrollareaqprinter

QPrintPreviewWidget freezes the application when the horizontal scrollbar is shown


I am having a bug with QPrintPreviewWidget (PySide2 5.15.2.1, Windows 11). From the moment I made this post, I didn't found anything related online.

When I force the horizontal scrollbar to appear inside the inherited scroll area (by resizing the window), the application becomes unresponsive, freezing instantly until you luckly resize the widget and force the scrollbar to disappear.

Here's a example to force trigger the bug:

from PySide2.QtWidgets import QApplication, QWidget, QLabel, QHBoxLayout
from PySide2.QtGui import QTextDocument
from PySide2.QtPrintSupport import QPrinter, QPrintPreviewWidget

app = QApplication()

doc = QTextDocument()
doc.setPlainText('\n' * 200)

printer = QPrinter()
view = QPrintPreviewWidget(printer)
view.paintRequested.connect(doc.print_)

window = QWidget()
layout = QHBoxLayout()
layout.addWidget(view)
layout.addWidget(QLabel('Just to trigger the horizontal scrollbar'))
window.setLayout(layout)
window.show()

app.exec_()

I had to create a document with more than 1 page, to force the vertical scrollbar to appear. When resized horizontally, at some point the horizontal scrollbar will become visible and lag the application.

The only way I found so far to "fix" the error is to set a minimum width to the QPrintPreviewWidget, using QPrintPreviewWidget.setMinimumWidth. This prevents the widget from reaching a small width, dodging (but not fixing) the issue.

Do anyone know a way to solve this issue?


Solution

  • QPrintPreviewWidget implements an internal QGraphicsView, meaning that this issue is closely related to the fitInView() warning:

    Note though, that calling fitInView() from inside resizeEvent() can lead to unwanted resize recursion, if the new transformation toggles the automatic state of the scrollbars.

    The problem is caused by the fact that fitInView() may cause one scroll bar to appear, which reduces the available area for the viewport, possibly forcing another resize event for it and, thus, another fitInView() call.
    That call will then decrease the visible scene rect ratio so that the first scroll bar may not be required anymore, causing another fitInView() call; therefore, we get recursion.

    Long story short, there is no real fix for this.

    Similar issues may be worked around for QGraphicsViews on which we have direct control (for example, by delaying fitInView() calls and using smart, accurate recursion checks), but that's not feasible for an object internally created by Qt like in this case.

    The only viable and always safe solution is to make the scroll bar always visible; it may be a bit ugly, but it works:

        graphicsView = view.findChild(QGraphicsView)
        graphicsView.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
    

    If you don't like to see a non functioning scroll bar, then the only alternative is to create a "virtual" scroll bar that will eventually be visible only when necessary, while still occupying its required space. This means that you will always get an empty gap at the bottom of the view.

    Here is a possible implementation:

    class PrintPreviewFix(QPrintPreviewWidget):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            view = self.findChild(QGraphicsView)
            view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
            self.realScrollBar = view.horizontalScrollBar()
    
            self.virtualScrollBar = QScrollBar(Qt.Horizontal, self)
            pol = self.virtualScrollBar.sizePolicy()
            pol.setRetainSizeWhenHidden(True)
            self.virtualScrollBar.setSizePolicy(pol)
            layout = self.layout()
            layout.setSpacing(0)
            layout.addWidget(self.virtualScrollBar)
    
            self.realScrollBar.rangeChanged.connect(self.updateScrollBar)
            self.realScrollBar.valueChanged.connect(self.updateScrollBar)
            self.virtualScrollBar.valueChanged.connect(self.realScrollBar.setValue)
    
        def updateScrollBar(self):
            minimum = self.realScrollBar.minimum()
            maximum = self.realScrollBar.maximum()
            with QSignalBlocker(self.virtualScrollBar):
                self.virtualScrollBar.setRange(minimum, maximum)
                self.virtualScrollBar.setValue(self.realScrollBar.value())
            self.virtualScrollBar.setVisible(minimum != maximum)
    
        def resizeEvent(self, event):
            super().resizeEvent(event)
            self.updateScrollBar()
    

    Note that the above could also be theoretically achieved just by using the existing scroll bar and toggling setUpdatesEnabled() on it depending on the scroll bar range (whether minimum and maximum are the same), but it's possible that this approach would eventually result in some graphics artifacts caused by buffering.

    Also note that QStyles implement scroll areas in different ways; most importantly:

    • the style may show the "viewport frame" within the area eventually occupied by scroll bars, while others show the scroll bars inside it;
    • the style may use transient scroll bars, which are not considered for the viewport size and only become visible and "overlaid" inside it when the mouse cursor hovers them or temporarily when a scrolling action is triggered;

    A proper implementation of the above should always consider these aspects: for instance, if transient scroll bars are used, this means that the issue will not exist at all in the first place, because visibility changes of the scroll bars won't alter the available area of the viewport.