Smooth lazy loading and unloading of custom IndexWidget in QListView - python

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)

Related

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

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

Make an Item in a QTableView draggable but not selectable

A bit of background (not fully necessary to understand the problem):
In a PySide2 application I developed earlier this year, I have a QTableView that I'm using with a custom model.
Some items in there can be selected in order to then click a button to perform a bulk action on the selected items.
Some other items are there as a visual indication that the data exists, but the function that does the bulk action woudn't know how to handle them, so I made them non-selectable.
I have another area in my program that can accept drops, either from external files or from the items in my TableView. In that case, every single one of the items in the table should be able to be dragged and then dropped in the other area.
The problem:
When an Item is not selectable, it seems to also not be draggable.
What I have tried:
Not much in terms of code, as I don't really know how to approach that one.
I tried a lot of combinations of Flags with no success.
I have been googling and browsing StackOverflow for a few hours, but can't find anything relevant, I'm just inundated by posts of people asking how to re-order a table by drag and dropping.
Minimum reproduction code:
from PySide2 import QtCore, QtWidgets, QtGui
class MyWindow(QtWidgets.QWidget):
def __init__(self):
QtWidgets.QWidget.__init__(self)
self.setGeometry(300, 200, 570, 450)
self.setWindowTitle("Drag Example")
model = QtGui.QStandardItemModel(2, 1)
table_view = QtWidgets.QTableView()
selectable = QtGui.QStandardItem("I'm selectable and draggable")
selectable.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsDragEnabled)
model.setItem(0, 0, selectable)
non_selectable = QtGui.QStandardItem("I'm supposed to be draggable, but I'm not")
non_selectable.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsDragEnabled)
model.setItem(1, 0, non_selectable)
table_view.setModel(model)
table_view.setDragDropMode(table_view.DragOnly)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(table_view)
self.setLayout(layout)
app = QtWidgets.QApplication([])
win = MyWindow()
win.show()
app.exec_()
Does anyone have a suggestion as to how to make an Item drag-enabled but selection-disabled?
Thanks
I found a workaround, though not necessarily a perfect answer, it does the trick for me.
Instead of making the item non selectable, I make it visually look like it doesn't select when selecting it, using a custom data role.
I use that same data flag in my bulk function to filter out the selection.
To make it look like the items are not selected even though they are, I used a custom styled item delegate:
class MyDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, parent=None):
super(MyDelegate, self).__init__(parent)
def paint(self, painter, option, index):
if option.state & QtWidgets.QStyle.State_Selected:
if index.data(Qt.UserRole) == 2: # Some random flag i'm using
text_brush = option.palette.brush(QtGui.QPalette.Text)
if index.row() % 2 == 0:
bg_brush = option.palette.brush(QtGui.QPalette.Base)
else:
bg_brush = option.palette.brush(QtGui.QPalette.AlternateBase)
# print brush
option.palette.setBrush(QtGui.QPalette.Highlight, bg_brush)
option.palette.setBrush(QtGui.QPalette.HighlightedText, text_brush)
super(MyDelegate, self).paint(painter, option, index)
and in the table I set:
delegate = MyDelegate()
table_view.setItemDelegate(delegate)
I adapted this solution from this post: QTreeView Item Hover/Selected background color based on current color

PyQt4: How to reorder child widgets?

I want to implement a GUI program like the blueprint editor in the Unreal game engine with PyQt4. Here is an example of the blueprint editor:
First I create a simple container widget to place all the components(The rectangles). Then I use the QPainterPath widget to draw lines to connect the components. Since the users can place the components wherever they want by drag and drop, I choose absolute positioning in my container widget. Here is the sample code:
class Component(QtGui.QWidget):
"""A simple rectangle."""
def __init__(self, type_, parent=None):
super(Component, self).__init__(parent)
...
class BezierPath(QtGui.QWidget):
def __init__(self, start, end, parent=None):
super(BezierPath, self).__init__(parent)
self.setMinimumSize(300, 500)
self._start = start
self._end = end
self._path = self._generate_path(self._start, self._end)
self.setMinimumSize(
abs(start.x()-end.x()), abs(start.y()-end.y()))
def _generate_path(self, start, end):
path = QtGui.QPainterPath()
path.moveTo(start)
central_x = (start.x()+end.x())*0.5
path.cubicTo(
QtCore.QPointF(central_x, start.y()),
QtCore.QPointF(central_x, end.y()),
end)
return path
def paintEvent(self, event):
painter = QtGui.QPainter()
painter.begin(self)
pen = QtGui.QPen()
pen.setWidth(3)
painter.setPen(pen)
painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
painter.drawPath(self._path)
painter.end()
class Container(QtGui.QWidget):
def add_component(self, component_type, position):
component = Component(component_type, self)
component.move(position)
def add_connection(self, component_a, component_b):
bezier_path = BezierPath(component_a.pos(), component_b.pose(), self)
The problem is I want to lines show underneath the components, but the components are created first. Is there a way I can reorder the child widgets of the Container of should I use a better way to organize the components?
I found a solution for reordering the child widgets myself. I'll mark this answer as accepted for now. It's not a perfect solution so if there is a better answer I will accept it then. Anyway here is the solution:
In qt4 the widget has a method raise_(), here I quote:
to raises this widget to the top of the parent widget's stack. After
this call the widget will be visually in front of any overlapping
sibling widgets.
So if you want to reorder all your widgets, first you keep the references of all the child widgets in you own list or what container you choose. Reorder all the widgets in you own container, then call raise_() for each widget in reverse order.

How to detect mouse click on images displayed in GUI created using PySide

Firstly, I'm new to Python, Qt and PySide so forgive me if this question seems too simple.
What I'm trying to do is to display a bunch of photos in a grid in a GUI constructed using PySide API. Further, when a user clicks on a photo, I want to be able to display the information corresponding to that photo. Additionally, I would like the container/widget used for displaying the photo to allow for the photo to be changed e.g. I should be able to replace any photo in the grid without causing the entire grid of photos to be created from scratch again.
Initially I tried to use QLabel to display a QPixmap but I realized (whether mistakenly or not) that I have no way to detect mouse clicks on the label. After some searching, I got the impression that I should subclass QLabel (or some other relevant class) and somehow override QWidget's(QLabel's parent class) mousePressEvent() to enable mouse click detection. Problem is I'm not sure how to do that or whether there is any alternative widget I can use to contain my photos other than the QLabel without having to go through subclass customization.
Can anyone suggest a more suitable container other than QLabel to display photos while allowing me to detect mouse clicks on the photo or provide some code snippet for subclassing QLabel to enable it to detect mouse clicks?
Thanks in advance for any replies.
I've added an example of how to emit a signal and connect to another slot. Also the docs are very helpful
from PySide.QtCore import *
from PySide.QtGui import *
import sys
class Main(QWidget):
def __init__(self, parent=None):
super(Main, self).__init__(parent)
layout = QHBoxLayout(self)
picture = PictureLabel("pic.png", self)
picture.pictureClicked.connect(self.anotherSlot)
layout.addWidget(picture)
layout.addWidget(QLabel("click on the picture"))
def anotherSlot(self, passed):
print passed
print "now I'm in Main.anotherSlot"
class PictureLabel(QLabel):
pictureClicked = Signal(str) # can be other types (list, dict, object...)
def __init__(self, image, parent=None):
super(PictureLabel, self).__init__(parent)
self.setPixmap(image)
def mousePressEvent(self, event):
print "from PictureLabel.mousePressEvent"
self.pictureClicked.emit("emit the signal")
a = QApplication([])
m = Main()
m.show()
sys.exit(a.exec_())
Even if the question has been answered, i want to provide an other way that can be used in different situations (see below) :
from PySide.QtCore import *
from PySide.QtGui import *
import sys
class Main(QWidget):
def __init__(self, parent=None):
super(Main, self).__init__(parent)
layout = QHBoxLayout(self)
picture = QLabel()
picture.setPixmap("pic.png")
layout.addWidget(picture)
layout.addWidget(QLabel("click on the picture"))
makeClickable(picture)
QObject.connect(picture, SIGNAL("clicked()"), self.anotherSlot)
def anotherSlot(self):
print("AnotherSlot has been called")
def makeClickable(widget):
def SendClickSignal(widget, evnt):
widget.emit(SIGNAL('clicked()'))
widget.mousePressEvent = lambda evnt: SendClickSignal(widget, evnt)
a = QApplication([])
m = Main()
m.show()
sys.exit(a.exec_())
This way doesn't imply subclassing QLabel so it can be used to add logic to a widget made with QtDeigner.
Pros :
Can be used over QTdesigner compiled files
Can be applied to any kind of widget (you might need to include a super call to the overrided function to ensure widget's normal behavior)
The same logic can be used to send other signals
Cons :
You have to use the QObject syntax to connect signals and slots

Categories