Search code examples
pythonarchitecturepyqtpyqt5

Can a QAbstractItemModel trigger a layout change whenever underyling data is changed?


The following is a slightly modified version of the Model/View To-Do List tutorial.

I have a class Heard that is composed of a list of Animal. The Heard serves as the underlying data for a HeardModel which is displayed in a ListView in my interface.

In my MainWindow, I've created a function called add_animal_to_heard which:

  1. Creates a new Animal using the user input
  2. Uses the Heard class's add_animal method to add the new Animal to the Heard
  3. Tells the HeardModel to update the view using layoutChanged.emit()

It's this last point that concerns me. To manage increasing complexity in the app, shouldn't the HeardModel know to trigger a layout change whenever the underlying Heard data is changed? Is this possible, and if so, is there any reason that wouldn't be desireable?

import sys
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtCore import Qt
from typing import Dict, List

qt_creator_file = "animals.ui"
Ui_MainWindow, QtBaseClass = uic.loadUiType(qt_creator_file)


class Animal:

    def __init__(self, genus: str, species: str):
        self.genus = genus
        self.species = species

    def name(self):
        return f"{self.genus} {self.species}"


class Heard:
    animals: List[Animal]

    def __init__(self, animals: List[Animal]):
        self.animals = animals

    def add_animal(self, animal: Animal):
        self.animals.append(animal)

    def remove_animal(self, animal: Animal):
        self.animals.remove(animal)


class HeardModel(QtCore.QAbstractListModel):
    heard: Heard

    def __init__(self, *args, heard: Heard, **kwargs):
        super(HeardModel, self).__init__(*args, **kwargs)
        self.heard = heard

    def data(self, index, role):
        if role == Qt.DisplayRole:
            animal = self.heard.animals[index.row()]
            return animal.name()

    def rowCount(self, index):
        return len(self.heard.animals)


class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
    def __init__(self):
        QtWidgets.QMainWindow.__init__(self)
        Ui_MainWindow.__init__(self)
        self.setupUi(self)
        self.model = HeardModel(heard=Heard([Animal('Canis', 'Familiaris'), Animal('Ursus', 'Horribilis')]))
        self.heardView.setModel(self.model)

        self.addButton.pressed.connect(self.add_animal_to_heard)

    def add_animal_to_heard(self):
        genus = self.genusEdit.text()
        species = self.speciesEdit.text()
        if genus and species:  # Don't add empty strings.
            # Create new animal
            new_animal = Animal(genus, species)

            # Add animal to heard
            self.model.heard.add_animal(new_animal)

            # Trigger refresh.
            self.model.layoutChanged.emit()

            # Empty the input
            self.genusEdit.setText("")
            self.speciesEdit.setText("")


app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

Solution

  • Your Heard object (maybe you meant "Herd"?) has no direct relation with the model unless you make it so.

    You have to create a "link" between them, which can be done in various ways depending on your needs.

    Note that the proper way to deal with changes in the data (and model) size is not to use layoutChanged but to use the insert/remove functions of QAbstractItemModel: beginInsertRows() (which must end with an endInsertRows()) and beginRemoveRows() (then, endRemoveRows()). This is extremely important because using these functions ensures that the view can keep a list of persistent indexes during the change, allowing proper functionality: selections are correctly handled, the view update is optimized, and possible item editors will be still associated with the correct indexes.

    It is also better to let the model handle its behavior and not do that externally: this is also valid for signals, which should be emitted within the model class. While it technically doesn't change the result, it is more correct the point of view of object structure and code maintenance (see "Separation of concerns").

    Anyhow, the most common way to do all this is to let the model handle insertion and removal of items in the underlying data structure:

    class HeardModel(QtCore.QAbstractListModel):
        # ...
        def add_animal(self, animal):
            row = self.rowCount()
            self.beginInsertRows(QtCore.QModelIndex(), row, row)
            self.heard.add_animal(animal)
            self.endInsertRows()
    
        def remove_animal(self, animal):
            try:
                row = self.heard.animals.index(animal)
                self.beginRemoveRows(QtCore.QModelIndex(), row, row)
                self.heard.remove_animal(animal)
                self.endRemoveRows()
            except ValueError:
                print(f'animal {animal.name} not in model')
    
    
    class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
        # ...
        def add_animal_to_heard(self):
            genus = self.genusEdit.text()
            species = self.speciesEdit.text()
            if genus and species:  # Don't add empty strings.
                self.model.add_animal(Animal(genus, species))
                self.genusEdit.clear()
                self.speciesEdit.clear()
    

    Note that this is not the only way to achieve the wanted result.

    For instance, you could create a reference of the model in the Heard object and then implement the above within their respective functions.

    In that case, you could implement insertRows() and removeRows(), so that you can just call insertRow() inside add_animal() in the Heard object as long as the model reference exists (and it's a QAbstractItemModel instance).
    Still, as explained in the documentation (including that of QAbstractListModel), the related begin/end functions must be implemented anyway.

    In any case, it's extremely important that the handling of both model and data structure uses a unique interface, otherwise you will risk unexpected results or fatal crash: for instance, if you try to remove an animal from the animals list without properly notifying the model, its data() function will raise an IndexError.
    This aspect must not be underestimated, especially considering the possible "increasing complexity in the app" as you yourself noted; imagine adding a new feature to the program or fixing a newly found bug, months after you last opened the project: not only it's quite easy to forget some aspects of the implementation after some time has passed since they have been established, but it may be painfully hard to understand again what the code did (and why or how) or even find the cause of possible further bugs introduced by the new modifications.

    That said, since it doesn't seem like the Heard object implements lots of functions, you could simplify everything by merging it with the model and use a single class instead:

    class HeardModel(QAbstractListModel):
        def __init__(self, animals):
            super().__init__()
            self.animals = animals
    

    You could even do a further step more and actually merge them using multiple inheritance:

    class Heard:
        animals: List[Animal]
    
        def __init__(self, animals):
            super().__init__() # important!
            self.animals = animals
    
        # etc...
    
    
    # note: the inheritance order is important
    class HeardModel(Heard, QtCore.QAbstractListModel):
        # no __init__ override required unless it needs other operations
    
        def add_animal(self, animal):
            row = self.rowCount()
            self.beginInsertRows(QtCore.QModelIndex(), row, row)
            super().add_animal(animal)
            self.endInsertRows()
    
        # etc...
    
    
    class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
        def __init__(self):
            super().__init__()
            self.setupUi(self)
            self.model = HeardModel([
                Animal('Canis', 'Familiaris'), 
                Animal('Ursus', 'Horribilis')
            ])
            # etc...