On Stack Overflow, my question was closed due to "debugging details." I made a bunch of changes based on the comments to that questions and have a smaller example that strips out the thumbnail generation but still has the same issue.
https://stackoverflow.com/questions/79504092/with-pyqt6-calling-setcurrentindex-and-scrollto-is-inconsistant-with-a-custom-ql
Here's the new minimal example if anyone wants to take a stab at it:
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 QImage, QPixmap
from PyQt6.QtWidgets import QApplication, QListView, QAbstractItemView, QListWidget, QWidget, QStyle, QMainWindow
class ThumbLoaderThread(QThread):
thumbnail_loaded = pyqtSignal(str, QImage)
def __init__(self):
super().__init__()
self.thumbnail_queue = Queue()
self.error_icon = QWidget().style().standardIcon(QStyle.StandardPixmap.SP_DialogCancelButton).pixmap(250, 250).toImage()
def add_thumbnail(self, filename: str):
self.thumbnail_queue.put(filename)
def run(self) -> None:
print('Starting Thumbnailer Thread')
while not self.isInterruptionRequested():
try:
filename = self.thumbnail_queue.get(timeout=1)
thumb = self.__load_thumb(filename)
if thumb:
self.thumbnail_loaded.emit(filename, thumb)
else:
self.thumbnail_loaded.emit(filename, self.error_icon)
except Empty:
...
@lru_cache(maxsize=5000)
def __load_thumb(self, filename):
print(f'Loading Thumbnail For {filename}')
# In the real application, I use openCV to create a thumbnail here
# For right now, we're just using a standard image
return QWidget().style().standardIcon(QStyle.StandardPixmap.SP_FileIcon).pixmap(250, 250).toImage()
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, filename: str, thumbnail: QImage):
idx = self.index_for_filename(filename)
if idx >= 0:
q_idx = self.createIndex(idx, 0)
self.files[idx]['thumbnail'] = QPixmap.fromImage(thumbnail)
self.dataChanged.emit(q_idx, q_idx)
def index_for_filename(self, filename) -> int:
for index, item in enumerate(self.files):
if item.get('filename') == filename:
return index
return -1
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(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):
super().__init__()
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.setCurrentIndex(q_idx)
self.scrollTo(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):
super().__init__()
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()