Issue
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:
- Creates a new
Animal
using the user input - Uses the
Heard
class'sadd_animal
method to add the newAnimal
to theHeard
- Tells the
HeardModel
to update the view usinglayoutChanged.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...
Answered By - musicamante
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.