Skip to content
  • Categories
  • Recent
  • Tags
  • Popular
  • Users
  • Groups
  • Search
  • Get Qt Extensions
  • Unsolved
Collapse
Brand Logo
  1. Home
  2. Qt Development
  3. Qt for Python
  4. Cannot scroll to element in QListView backed by a custom QAbstractListModel when a lot of elements are present
Forum Updated to NodeBB v4.3 + New Features

Cannot scroll to element in QListView backed by a custom QAbstractListModel when a lot of elements are present

Scheduled Pinned Locked Moved Unsolved Qt for Python
2 Posts 1 Posters 288 Views
  • Oldest to Newest
  • Newest to Oldest
  • Most Votes
Reply
  • Reply as topic
Log in to reply
This topic has been deleted. Only users with topic management privileges can see it.
  • D Offline
    D Offline
    djsumdog
    wrote on last edited by
    #1

    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 within def mousePressEvent(self, event): after the check to see if self.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()
    
    1 Reply Last reply
    0
    • D Offline
      D Offline
      djsumdog
      wrote on last edited by
      #2

      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()
      
      1 Reply Last reply
      0

      • Login

      • Login or register to search.
      • First post
        Last post
      0
      • Categories
      • Recent
      • Tags
      • Popular
      • Users
      • Groups
      • Search
      • Get Qt Extensions
      • Unsolved