pythonpyqt5qt5

How do I get a callback for QListWidgetItem becoming visible in PyQT5


I created a simple MediaBrowser using a QListWidget in PyQT5:

class MediaBrowser(QListWidget):

    def __init__(self, database: Database, viewtab, dir_path):
        QListWidget.__init__(self)
        self.log = logging.getLogger('mediahug')
        self.database = database
        self.viewtab = viewtab

        self.setLayoutMode(QListView.Batched)
        self.setBatchSize(10000)
        self.setUniformItemSizes(True)

        self.current_directory = dir_path
        # self.current_file_widgets = []
        self.thumb_loader_thread = None
        self.itemSelectionChanged.connect(self.selection_change)

        # Should theoretically speed things up but it does not
        # self.setSizeAdjustPolicy(QListWidget.AdjustToContents)

        self.setSelectionMode(QAbstractItemView.SingleSelection)
        self.setViewMode(QListWidget.IconMode)
        self.setResizeMode(QListWidget.Adjust)
        self.setIconSize(QSize(THUMB_WIDTH, THUMB_HEIGHT))
        self.load_files(dir_path)

...
...

    def all_files(self):
        return self.findItems('*', Qt.MatchWildcard)

    def load_files(self, dir_path):
        if self.thumb_loader_thread and self.thumb_loader_thread.isRunning():
            self.log.info('Killing Previous Thumbnail Loading Thread')
            self.thumb_loader_thread.requestInterruption()
            self.thumb_loader_thread.wait(sys.maxsize)
            self.log.info('Previous Thumbnail Thread Done')

        self.clear()
        # Load New File widgets
        onlyfiles = [f for f in listdir(dir_path) if isfile(join(dir_path, f))]
        for f in onlyfiles:
            vid = join(dir_path, f)
            self.log.debug(f"Creating File/Thumb Widget {vid}")
            self.addItem(ThumbWidget(vid, self.database))

        self.thumb_loader_thread = ThumbLoaderThread(self.all_files(), dir_path)
        self.thumb_loader_thread.start()

The MediaBrowser which is a QListWidget, adds a bunch of ThumbWidget items (which are QListWidgetItem objects) when it starts:

class ThumbWidget(QListWidgetItem):

    def __init__(self, filename: str, database):
        QListWidgetItem.__init__(self)
        self.filename = filename
        self.database = database
        self.setText(basename(self.filename))
        standard_file_icon = QWidget().style().standardIcon(QStyle.SP_FileIcon)
        self.setIcon(standard_file_icon)
        self.setSizeHint(QSize(THUMB_WIDTH, THUMB_HEIGHT + FILENAME_MARGIN))

    def __str__(self):
        return f'Thumbnail for {self.filename}'

    def load_thumb(self):
        metadata = self.database.file_metadata(self.filename)
        img_thumb = metadata['thumb']
        if img_thumb:
            img = QPixmap()
            img.loadFromData(img_thumb, 'JPEG')
            self.setIcon(QIcon(img))

This takes a lot of time at startup. I'd like to only load a thumbnail for the item when it is scrolled into view. Elsewhere within my code, the MediaBrowser is within a QScrollArea.

        self.media_scroller = QScrollArea()
        self.media_scroller.setWidget(self._media_browser)
        self.media_scroller.setWidgetResizable(True)

Is there any way to get events to know when a particular QWidgetItem is current scrolled in/out of view? That way I can load and unload thumbnails, making for more efficient startup times.

The full code for this project can be found here:

https://gitlab.com/djsumdog/mediahug/-/tree/master/mediahug/gui/viewtab


Solution

  • Thanks for all the comments on the question. It's interesting the linked documentation was to the Python section of QT's website, but the code on it was C++. I've used it to create a model for handling files in PyQT5. There are some things like database and a thumbnailer I implement elsewhere, but it should be easy to adapt for anyone looking for a Python implementation of a QAbstractListModel that handles files:

    import logging
    from functools import lru_cache
    from os import listdir
    from os.path import isfile, join, basename
    
    from PyQt5 import QtCore
    from PyQt5.QtCore import QAbstractListModel, QModelIndex, QVariant, Qt, QSize, pyqtSignal
    from PyQt5.QtGui import QIcon, QPixmap
    from PyQt5.QtWidgets import QStyle, QWidget
    
    
    
    class FileListModel(QAbstractListModel):
    
        numberPopulated = pyqtSignal(int)
    
        def __init__(self, database: Database, dir_path: str):
            super().__init__()
            self.log = logging.getLogger('<your app name>')
            self.database = database
            self.files = []
            self.loaded_file_count = 0
            self.set_dir_path(dir_path)
    
        def rowCount(self, parent: QModelIndex = QtCore.QModelIndex()) -> int:
            return 0 if parent.isValid() else self.loaded_file_count
    
        def set_dir_path(self, dir_path: str):
            self.beginResetModel()
            self.files = []
            self.loaded_file_count = 0
            only_files = [f for f in listdir(dir_path) if isfile(join(dir_path, f))]
            for f in only_files:
                vid = join(dir_path, f)
                self.files.append(vid)
            self.endResetModel()
    
        def data(self, index: QModelIndex, role: int = Qt.DisplayRole):
            if not index.isValid():
                return QVariant()
    
            if index.row() >= len(self.files) or index.row() < 0:
                return QVariant()
    
            if role == Qt.DisplayRole:
                filename = basename(self.files[index.row()])
                return QVariant(filename)
    
            if role == Qt.DecorationRole:
                thumb = self.__load_thumb(self.files[index.row()])
                if thumb:
                    return thumb
                else:
                    return QWidget().style().standardIcon(QStyle.SP_FileIcon)
    
            if role == Qt.SizeHintRole:
                return QSize(THUMB_WIDTH, THUMB_HEIGHT + FILENAME_MARGIN)
    
            return QVariant()
    
        def fetchMore(self, parent: QModelIndex) -> None:
            if parent.isValid():
                return
    
            remainder = len(self.files) - self.loaded_file_count
            items_to_fetch = min(100, remainder)
            if items_to_fetch <= 0:
                self.log.debug("No More Items to Fetch")
                return
    
            self.log.debug(f'Loaded Items: {self.loaded_file_count} / Items to Fetch: {items_to_fetch}')
    
            self.beginInsertRows(QModelIndex(), self.loaded_file_count, self.loaded_file_count + items_to_fetch - 1)
            self.loaded_file_count += items_to_fetch
            self.endInsertRows()
            self.numberPopulated.emit(items_to_fetch)
    
        def canFetchMore(self, parent: QModelIndex) -> bool:
            if parent.isValid():
                return False
            can_fetch = self.loaded_file_count < len(self.files)
            self.log.debug(f'Can Fetch More? {can_fetch}')
            return can_fetch
    
        @lru_cache(maxsize=5000)
        def __load_thumb(self, filename):
            self.log.debug(f'Loading Thumbnail For {filename}')
            metadata = self.database.file_metadata(filename)
            img_thumb = metadata['thumb']
            if img_thumb:
                img = QPixmap()
                img.loadFromData(img_thumb, 'JPEG')
                return QIcon(img)
    

    In the above example, I'm loading thumbnails from my database layer and added an lru_cache annotation around it. This works for now, but there will be a delay on large folders while scrolling, if thumbnails haven't been generated/cached yet.

    A future improvement and speed up would involve changing the if role == Qt.DecorationRole section of data() to always return a loading icon if the thumbnail is not immediately available in memory. Then have a background tasks, thread or delegate that then loads or generates the thumbnail in the background, and use the dataChanged() call on the model to indicate the thumbnail is now ready.

    I also changed the QListWidget to a QListView and change the following in the constructor:

            self.setLayoutMode(QListView.Batched)
            self.setBatchSize(10)
            self.setUniformItemSizes(True)
    
            self.file_list_model = FileListModel(database, dir_path)
            self.setModel(self.file_list_model)
            self.selectionModel().selectionChanged.connect(self.selection_change)