Pyqt5 detect scrolling by clicking below handle - python

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)

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()

Related

How to scroll Qt widgets by keyboard?

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> ...
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()).

Kivy drawn items 'disappearing' (or possible render order problem)

(this is a repost from https://groups.google.com/forum/?utm_medium=email&utm_source=footer#!msg/kivy-users/0B5GYWkTIwE/-OQYp_ykCQAJ)
Very occasionally our kivy-based UI glitches in a very odd way: the system remains responsive and the displays of buttons and text work but it seems that all "drawn" items disappear, i.e. items from kivy.graphics.
Here is the main control screen in it's proper state:
and when it goes wrong it changes to:
All the items rendered with the kivy.graphics objects have gone!
This is using 1.9.1-1build3 (in Ubuntu) and 1.9.1-dev (from kivypie on an RPi)
Does anyone have a clue as to what might be causing this or how to track it down? It seems to happen on touch events, but it is so rare it's been impossible so far to find any commonality and there is no error report output to stdout/stderr or the .kivy/log/
Here's a section of code showing how elements are being drawn:
class LineWidget(GUIWidget):
"""A line widget. A simple sequence of points"""
def __init__(self, points, width, **kwargs):
GUIWidget.__init__(self, **kwargs)
self.points = points
with self.canvas:
self.line_color = theme.system.line.Color()
self.line = Line(width=width)
self.bind(size=self.update_rect, pos=self.update_rect)
def update_rect(self, instance, value):
"""Update the point positions."""
self.pos = instance.pos
if self.points:
self.line.points = self.get_points(self.points)
def set_color(self, color):
"""Change the color"""
self.line_color.rgb = color
and the blue/grey background is rendered:
with self.canvas.before:
if background_color is None:
background_color = theme.primary.black
self._background_color = Color(*background_color)
self._background_rectangle = Rectangle(size=self.size, pos=self.pos)
In my post to the kivy group, it has been suggested that maybe some race condition has happened resulting in the native kivy objects being rendered on top of mine and to try using canvas.after, but unless I can find a way to provoke the problem I'll never know if this has worked.
Is it possible that the render order (Z-index) has changed?
Is it possible there is some race condition?
The problem happens so rarely it has been impossible so far to
provoke the problem (100s of random clicks on widgets is the only
way) - is there some programmatic method to try and force the issue?
what debug could I put in to try and find out what might be
happening?
Thanks,

How to Autoresize QLabel pixmap keeping ratio without using classes?

We are making a GUI using PyQt and Qt Designer. Now we need that an image(pixmap) placed in a QLabel rescales nicely keeping ratio when the window is resized.
I've been reading other questions/answers but all of them use extended classes. As we are making constant changes in our UI, and it's created with Qt Creator, the .ui and (corresponding).py files are automatically generated so, if I'm not wrong, using a class-solution is not a good option for us because we should manually change the name of the class each time we update the ui.
Is there any option to autoresize the pixmap in a QLAbel keeping the ratio and avoiding using extended clases?
Thanks.
There are a couple of ways to do this.
Firstly, you can promote your QLabel in Qt Designer to a custom subclass that is written in python. Right-click the QLabel and select "Promote to...", then give the class a name (e.g. "ScaledLabel") and set the header file to the python module that the custom subclass class will be imported from (e.g. 'mylib.classes').
The custom subclass would then re-implement the resizeEvent like this:
class ScaledLabel(QtGui.QLabel):
def __init__(self, *args, **kwargs):
QtGui.QLabel.__init__(self)
self._pixmap = QtGui.QPixmap(self.pixmap())
def resizeEvent(self, event):
self.setPixmap(self._pixmap.scaled(
self.width(), self.height(),
QtCore.Qt.KeepAspectRatio))
For this to work properly, the QLabel should have its size policy set to expanding or minimumExpanding, and the minimum size should be set to a small, non-zero value (so the image can be scaled down).
The second method avoids using a subclass and uses an event-filter to handle the resize events:
class MainWindow(QtGui.QMainWindow):
def __init__(self):
...
self._pixmap = QtGui.QPixmap(self.label.pixmap())
self.label.installEventFilter(self)
def eventFilter(self, widget, event):
if (event.type() == QtCore.QEvent.Resize and
widget is self.label):
self.label.setPixmap(self._pixmap.scaled(
self.label.width(), self.label.height(),
QtCore.Qt.KeepAspectRatio))
return True
return QtGui.QMainWindow.eventFilter(self, widget, event)
Set background-image:, background-repeat: and background-position QSS properties for your label. You may do it via Forms editor or in code QWidget::setStyleSheet.
A good starting point for QSS (with examples) - http://doc.qt.io/qt-5/stylesheet-reference.html
One way is to create a QWidget/QLabel subclass and reimplement the resizeEvent.
void QWidget::resizeEvent(QResizeEvent * event) [virtual protected]
This event handler can be reimplemented in a subclass to receive widget resize events which are passed in the event parameter. When resizeEvent() is called, the widget already has its new geometry. The old size is accessible through QResizeEvent::oldSize().
The widget will be erased and receive a paint event immediately after processing the resize event. No drawing need be (or should be) done inside this handler.
This would need to be done in C++ though, not PyQt.
Having that done, you could add your custom widget to the QtDesigner as follows:
Using Custom Widgets with Qt Designer
Incredibly, after seven years #ekhumoro's excellent answer is still pretty much the only working Python implementation that can be found around; everyone else tells what to do, but nobody gives the actual code.
In spite of this, it did not work at first for me, because I happened to have the pixmap generation somewhere else in the code - specifically, my pixmap was generated inside a function which was only activated when clicking on a button, so not during the window intialization.
After figuring out how #ekhumoro's second method worked, I edited it in order to accomodate this difference. In pratice I generalised the original code, also because I did not like (for efficiency reasons) how it added a new _pixmap attribute to the label, which seemed to be nothing more than a copy of the original pixmap.
The following his is my version; mind that I have not fully tested it, but since it is a shorter version of my original working code, it too should work just fine (corrections are welcome, though):
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
# Initialize stuff here; mind that no pixmap is added to the label at this point
def eventFilter(self, widget, event):
if event.type() == QEvent.Resize and widget is self.label:
self.label.setPixmap(self.label.pixmap.scaled(self.label.width(), self.label.height(), aspectRatioMode=Qt.KeepAspectRatio, transformMode=Qt.SmoothTransformation))
return True
return QMainWindow.eventFilter(self, widget, event)
def apply_pixelmap(self, image): # This is where the pixmap is added. For simplicity, suppose that you pass a QImage as an argument to this function; however, you can obtain this in any way you like
pixmap = QPixmap.fromImage(image).scaled(new_w, new_h, aspectRatioMode=Qt.KeepAspectRatio, transformMode=Qt.SmoothTransformation)
self.label.setPixmap(pixmap)
self.label.pixmap = QPixmap(pixmap) # I am aware that this line looks like a redundancy, but without it the program does not work; I could not figure out why, so I will gladly listen to anyone who knows it
self.label.installEventFilter(self)
return
This works by setting the ScaledContents property to False and the SizePolicy to either Expanding or Ignored. Note that it might not work if the label containing the image is not set as the central widget (self.setCentralWidget(self.label), where self refers to MainWindow).

Smooth lazy loading and unloading of custom IndexWidget in QListView

I am writing an application that makes use of a custom QWidget in place of regular listitems or delegates in PyQt. I have followed the answer in Render QWidget in paint() method of QWidgetDelegate for a QListView -- among others -- to implement a QTableModel with custom widgets. The resulting sample code is at the bottom of this question. There are some problems with the implementation that I do not know how to solve:
Unloading items when they are not being displayed. I plan to build my application for a list that will have thousands of entries, and I cannot keep that many widgets in memory.
Loading items that are not yet in view or at least loading them asynchronously. Widgets take a moment to render, and the example code below has some obvious lag when scrolling through the list.
When scrolling the list in the implementation below, each newly loaded button when loading shows up at the top left corner of the QListView for a split second before bouncing into position. How can that be avoided?
--
import sys
from PyQt4 import QtGui, QtCore
from PyQt4.QtCore import Qt
class TestListModel(QtCore.QAbstractListModel):
def __init__(self, parent=None):
QtCore.QAbstractListModel.__init__(self, parent)
self.list = parent
def rowCount(self, index):
return 1000
def data(self, index, role):
if role == Qt.DisplayRole:
if not self.list.indexWidget(index):
button = QtGui.QPushButton("This is item #%s" % index.row())
self.list.setIndexWidget(index, button)
return QtCore.QVariant()
if role == Qt.SizeHintRole:
return QtCore.QSize(100, 50)
def columnCount(self, index):
pass
def main():
app = QtGui.QApplication(sys.argv)
window = QtGui.QWidget()
list = QtGui.QListView()
model = TestListModel(list)
list.setModel(model)
list.setVerticalScrollMode(QtGui.QAbstractItemView.ScrollPerPixel)
layout = QtGui.QVBoxLayout(window)
layout.addWidget(list)
window.setLayout(layout)
window.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
You can use a proxy model to avoid to load all widgets. The proxy model can calculate the row count with the heights of the viewport and the widget. He can calculate the index of the items with the scrollbar value.
It is a shaky solution but it should work.
If you modify your data() method with:
button = QtGui.QPushButton("This is item #%s" % index.row())
self.list.setIndexWidget(index, button)
button.setVisible(False)
The items will not display until they are moved at their positions (it works for me).
QTableView only requests data to the model for items in its viewport, so the size of your data doesn't really affect the speed. Since you already subclassed QAbstractListModel you could reimplement it to return only a small set of rows when it's initialized, and modify its canFetchMore method to return True if the total amount of records hasn't been displayed. Although, with the size of your data, you might want to consider creating a database and using QSqlQueryModel or QSqlTableModel instead, both of them do lazy loading in groups of 256.
To get a smoother load of items you could connect to the valueChanged signal of your QTableView.verticalScrollBar() and depending on the difference between it's value and maximum have something like:
while xCondition:
if self.model.canFetchMore():
self.model.fetchMore()
Using setIndexWidget is slowing up your application considerably. You could use a QItemDelegate and customize it's paint method to display a button with something like:
class MyItemDelegate(QtGui.QItemDelegate):
def __init__(self, parent=None):
super(MyItemDelegate, self).__init__(parent)
def paint(self, painter, option, index):
text = index.model().data(index, QtCore.Qt.DisplayRole).toString()
pushButton = QtGui.QPushButton()
pushButton.setText(text)
pushButton.setGeometry(option.rect)
painter.save()
painter.translate(option.rect.x(), option.rect.y())
pushButton.render(painter)
painter.restore()
And setting it up with:
myView.setItemDelegateForColumn(columnNumber, myItemDelegate)

How to access the info known by matplotlib.backends.backend_qt4agg.NavigationToolbar2QTAgg

I have a PyQt4 application with embedded matplotlib figures in it.
As you see I have added the navigation toolbar as a widget. I would like to be able to update the plot on the right based on the data displayed on the plot on the left. It seems the easiest way would be to hijack the info contained in whatever does the "zooming". Or at least know when a zoom has occurred so I can recalculate and redraw on the right. Anyone have a good approach to this (re-implementing from the pick_event level would be a last resort)?
The best way I came up with is creating a custom navigation toolbar with NavigationToolbar as the parent. Then I just added a signal to the custom version and overrode the draw method. In this way I could call the original draw method and emit my signal with whatever info I needed from the NavigationToolbar data structure.
class NavigationToolbarCustom(NavigationToolbar):
zoom_changed = pyqtSignal(float, float, name='zoomChanged')
def __init__(self, canvas, parent):
NavigationToolbar.__init__(self, canvas, parent)
#super(NavigationToolbarCustom, self).__init__(self, canvas, parent)
def draw(self):
super(NavigationToolbarCustom,self).draw()
if self._xypress == None:
home_view = self._views.home()
Xmin,Xmax = home_view[0][0:2]
else:
lastx, lasty, a, ind, lim, trans = self._xypress[0]
Xmin,Xmax=a.get_xlim()
self.zoom_changed.emit(Xmin,Xmax)

Categories