How to properly remove a row from a QAbstractListModel attached to a QListView without a delay?

230 Views Asked by At

I'm trying to build an application with PyQT6 that allows users to browse through a list of images with thumbnails and display the selected image in an image viewer. The application can also add and delete images. Adding images seems to work fine, but when I delete an image from the model the row in the QListView suddenly displays the data from the next row in the list. After a random interval of anywhere between half a second and about five seconds the row will actually be removed, and the list will display the proper file ordering. The fact that this behavior occurs makes me think I'm not removing the item from the model properly, and ideally I'd like the deletion of a row to be instantaneous.

Here is my minimum reproducible example:

import PyQt6 as qt
import PyQt6.QtCore as QtCore
from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
from PyQt6.QtGui import *
from PyQt6.QtWidgets import *
import os
import sys
import traceback



class ImageDataModel(QAbstractListModel):
    def __init__(self, images=None):
        super(ImageDataModel, self).__init__()
        if images is None:
            self.images = []
        else:
            self.images = images
            self.thumbnails = []
            for img_path in self.images:
                icon = QPixmap(img_path).scaledToHeight(20)
                self.thumbnails.append(icon)
            
    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            img_path = self.images[index.row()]
            return img_path
        if role == Qt.ItemDataRole.DecorationRole:
            thumbnail = self.thumbnails[index.row()]
            return thumbnail
        
    def rowCount(self, index):
        return len(self.images)
    
    def removeRow(self, index):
        self.images.pop(index)
        self.thumbnails.pop(index)
        
class myListView(QListView):
    def __init__(self, parent=None):
        super().__init__()
        self.parent = parent
        self.setSelectionMode(QListView.SelectionMode.ExtendedSelection)
        
    def currentChanged(self, current: QtCore.QModelIndex, previous: QtCore.QModelIndex) -> None:
        if (current.row() >= 0):
            self.parent.get_selection(current) # this method simply displays the selected image
        return super().currentChanged(current, previous)
    
class MyMenu(QMainWindow):
    def __init__(self):
        super().__init__()
        self.layout = QHBoxLayout()
        self.list = myListView(self)
        try:
            image_file_list = [x for x in os.listdir('path/to/image/directory') if x.lower().endswith(".png")]
        except:
            image_file_list = []

        image_file_list.sort()
        self.model = ImageDataModel(image_file_list)
        self.list.setModel(self.model)
        self.list.clicked.connect(self.get_selection) # this method simply displays the selected image
        self.list.setCurrentIndex(self.model.index(0,0))
        
        self.layout.addWidget(self.list)
        
        self.widget = QWidget()
        self.widget.setLayout(self.layout)
        self.setCentralWidget(self.widget)

    # Deletes the currently displayed image and annotation from the dataset
    def delete_image(self):
        # Determine what to set the new index to after deletion
        if self.list.currentIndex().row() != 0:
            new_index = self.list.currentIndex().row() - 1
        else:
            new_index = 0

        # Attempt to remove the row and delete the file
        try:
            self.list.model().removeRow(self.list.currentIndex().row())
            os.remove(self.img_path)
            
            # Set index row to the image immediately preceding the deleted image
            index = self.model.createIndex(new_index, 0)
            self.list.setCurrentIndex(index)
        except:
            traceback.print_exc()
            
    # Replaced display code for brevity
    def get_selection(self, item):
        print(item.row())
        
    # Handles keypresses
    def keyPressEvent(self, e) -> None:
        global model_enabled
        if (e.key() == Qt.Key.Key_Escape):
            app.quit()
 
        if (e.key() == Qt.Key.Key_Delete):
            self.delete_image()
        
def main():
    app = QApplication(sys.argv)
    window = MyMenu()
    window.show()
    app.exec()
    
main()

1

There are 1 best solutions below

0
musicamante On

Any change in the size, order/layout and data of a model should always be done using the proper function calls so that the views linked to the model get proper notifications about those changes.

For size and layout changes, it's important to always call the begin* and end* functions, which allows the view to be notified about the upcoming change, so they can keep a persistent list of the current items (including selection) and restore it when the change is completed.

Row removal is achieved using beginRemoveRows() and endRemoveRows().

In your case:

    def removeRow(self, index):
        self.beginRemoveRows(QModelIndex(), index, index)
        self.images.pop(index)
        self.thumbnails.pop(index)
        self.endRemoveRows()
        return True # <- the function expects a bool in return

Note that the correct way to implement row removal is done by implementing removeRows(), not removeRow() (singular), which internally calls removeRows anyway. So, you can leave the existing removeRow() call, do not override removeRow() and implement removeRows() instead.

    def removeRows(self, row, count, parent=QModelIndex()):
        if row + count >= len(self.images) or count < 1:
            return False

        self.beginRemoveRows(parent, row, row + count - 1)
        del self.images[row:row+count]
        del self.thumbnails[row:row+count]
        self.endRemoveRows()
        return True

A similar concept should always be done when adding new items after a view is linked to the model; in that case, implement insertRows() and there you'll call beginInsertRows() insert the new data and finally call endInsertRows().

Note that your code will throw an exception if the images is None, as it doesn't create the thumbnails object.