Cannot scroll to element in QListView backed by a custom QAbstractListModel when a lot of elements are present
-
This one has been racking my brain for a while. I've created a media viewer that uses a QListView to display videos/image thumbnails that can be selected. I recently added a menu to allow sorting by Name/Created/etc. and want the currently selected item to be maintained between changes in sort. When I try to re-select the current item after a data change, sometimes it selects the right element, and sometimes it selects nothing.
I created a Stack Overflow question if anyone knows the solution and wants those sweet Internet points. It's already been downvoted because I didn't have a minimal example, and then got downvoted again (probably because the minimal example I put in was fairly large; this is a complex issue).
The original place in my code I've attempted to implement this was here: https://gitlab.com/djsumdog/mediahug/-/blob/be7ff2fded26bcd8643035c588942983b7b1b4ff/mediahug/gui/viewtab/viewtab.py#L73
and I attempted to use
createIndex()
to get a new index off the refreshed model and scroll to/select it.idx = self._media_browser.get_file_index(self.current_file) q_idx = self._media_browser.model().createIndex(idx, 0) if not q_idx.isValid(): self.log.error('Index is invalid') self._media_browser.scrollTo(q_idx) self._media_browser.setCurrentIndex(q_idx)
I've created as minimal example as I can. This does require opencv (with Python bindings) and uses PyQt6. It loads images/videos from
/tmp/media
. Left click selects and right click shuffles. If you only have 4 or 5 media files in the folder, it works correctly. The original selection is maintained after each shuffle. Add in 30 or so (enough you have to scroll) and sometimes it maintains the selection, sometimes it doesn't. In the minimal example, the selection is happening withindef mousePressEvent(self, event):
after the check to see ifself.current_file
isn't null.I know it's a lot for a minimal example, but I think the fact that the model's icons are loading in a background thread is important here. If it were just text, the selection update would work fine. But the complex data model must be dealing with a race condition of some kind. Any insights are appreciated.
import sys from os import listdir from os.path import isfile, join, basename from functools import lru_cache from queue import Queue, Empty from random import shuffle from typing import Optional from PyQt6 import QtCore from PyQt6.QtCore import QSize, Qt, pyqtSlot, QModelIndex, QAbstractListModel, QVariant, QThread, pyqtSignal from PyQt6.QtGui import QIcon, QPixmap from PyQt6.QtWidgets import QApplication, QListView, QAbstractItemView, QListWidget, QWidget, QStyle, QMainWindow def video_thumb(filename: str, threshold=10, thumb_width=250, thumb_height=250): try: import cv2 vcap = cv2.VideoCapture(filename) # Jump to Middle of file total_frames = vcap.get(cv2.CAP_PROP_FRAME_COUNT) vcap.set(cv2.CAP_PROP_POS_FRAMES, total_frames / 2) # Make sure we don't get a blank frame res, im_ar = vcap.read() while im_ar.mean() < threshold and res: res, im_ar = vcap.read() # Create Thumbnail im_ar = cv2.resize(im_ar, (thumb_width, thumb_height), 0, 0, cv2.INTER_LINEAR) res, thumb_buf = cv2.imencode('.jpeg', im_ar) bt = thumb_buf.tobytes() return bt # noqa except Exception as e: print(f'Could not generate thumbnail for {filename}. Error {e}') return None class ThumbLoaderThread(QThread): thumbnail_loaded = pyqtSignal(QModelIndex, QIcon) def __init__(self): QThread.__init__(self) self.thumbnail_queue = Queue() self.error_icon = QWidget().style().standardIcon(QStyle.StandardPixmap.SP_DialogCancelButton) def add_thumbnail(self, index: QModelIndex, filename: str): self.thumbnail_queue.put({'index': index, 'filename': filename}) def run(self) -> None: print('Starting Thumbnailer Thread') while not self.isInterruptionRequested(): try: item = self.thumbnail_queue.get(timeout=1) thumb = self.__load_thumb(item['filename']) if thumb: self.thumbnail_loaded.emit(item['index'], thumb) else: self.thumbnail_loaded.emit(item['index'], self.error_icon) except Empty: ... @lru_cache(maxsize=5000) def __load_thumb(self, filename): print(f'Loading Thumbnail For {filename}') thumb = video_thumb(filename, 10, 250, 250) img = QPixmap() img.loadFromData(thumb, 'JPEG') return QIcon(img) class FileListModel(QAbstractListModel): numberPopulated = pyqtSignal(int) def __init__(self, dir_path: str): super().__init__() self.thumbnail_thread = ThumbLoaderThread() self.thumbnail_thread.thumbnail_loaded.connect(self.thumbnail_generated) self.thumbnail_thread.start() self.files = [] self.loaded_file_count = 0 self.set_dir_path(dir_path) def thumbnail_generated(self, index: QModelIndex, thumbnail: QIcon): self.files[index.row()]['thumbnail'] = thumbnail self.dataChanged.emit(index, index) 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))] # The full program has sorting # In this minimal example, we'll just shuffle the order shuffle(only_files) for f in only_files: vid = join(dir_path, f) self.files.append({'filename': vid, 'thumbnail': None}) self.endResetModel() def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): if not index.isValid(): return QVariant() if index.row() >= len(self.files) or index.row() < 0: return QVariant() filename = self.files[index.row()]['filename'] thumbnail = self.files[index.row()]['thumbnail'] if role == Qt.ItemDataRole.DisplayRole: return QVariant(basename(filename)) if role == Qt.ItemDataRole.DecorationRole: if thumbnail: return thumbnail else: self.thumbnail_thread.add_thumbnail(index, filename) return QWidget().style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload) if role == Qt.ItemDataRole.SizeHintRole: return QSize(250, 250 + 25) 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: print("No More Items to Fetch") return print(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 get_file_index(self, filename: str) -> Optional[int]: for i, file in enumerate(self.files): if file['filename'] == filename: return i def canFetchMore(self, parent: QModelIndex) -> bool: if parent.isValid(): return False can_fetch = self.loaded_file_count < len(self.files) return can_fetch class MediaBrowser(QListView): def __init__(self, dir_path): QListView.__init__(self) self.setLayoutMode(QListView.LayoutMode.Batched) self.setBatchSize(10) self.setUniformItemSizes(True) self.current_directory = dir_path self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.setViewMode(QListWidget.ViewMode.IconMode) self.setResizeMode(QListWidget.ResizeMode.Adjust) self.setIconSize(QSize(250, 250)) self.file_list_model = FileListModel(dir_path) self.setModel(self.file_list_model) self.selectionModel().selectionChanged.connect(self.selection_change) self.current_file = None def mousePressEvent(self, event): """Prevent context menu from also selecting a file""" if event.type() == QtCore.QEvent.Type.MouseButtonPress: if event.button() == Qt.MouseButton.RightButton: # In our minimalistic example, right click # Means we will shuffle self.chdir(self.current_directory) if self.current_file: idx = self.model().get_file_index(self.current_file) print(f'Attempting to select and scroll to {self.current_file} at index {idx}') q_idx = self.model().createIndex(idx, 0) if not q_idx.isValid(): print('Index is invalid') self.scrollTo(q_idx) self.setCurrentIndex(q_idx) else: super(MediaBrowser, self).mousePressEvent(event) def chdir(self, directory: str): print(f'Change Directory {directory}.') self.current_directory = directory self.load_files(directory) @pyqtSlot() def selection_change(self): selected = self.selectionModel().selectedIndexes() if len(selected) != 1: print(f'Invalid Selection {selected}') else: s = selected[0] print(f'Item Selection {s}') self.current_file = self.get_model_filename(s.row()) def showEvent(self, event): super().showEvent(event) QApplication.processEvents() def all_files(self): return self.file_list_model.files def get_model_filename(self, index): return self.all_files()[index]['filename'] def load_files(self, dir_path): try: self.file_list_model.set_dir_path(dir_path) except PermissionError as e: print(f'{e.strerror}') class MainWindow(QMainWindow): def __init__(self): QMainWindow.__init__(self) browser = MediaBrowser("/tmp/media") self.setCentralWidget(browser) def main(): app = QApplication(sys.argv) window = MainWindow() window.show() sys.exit(app.exec()) if __name__ == '__main__': main()