I. Objectifs de cet article▲
L'objectif de cet article est simple, il doit vous permettre, à la fin, de savoir comment créer des modèles personnalisés pour tout type de vue en utilisant les classes de base de PySide/PyQt.
Ainsi vous saurez quelle classe de base choisir et dans quel cas l'utiliser, ou comment spécialiser de manière exhaustive la classe mère de tous les modèles QAbstractItemModel.
Note sur PySide vs PyQt : ces deux bindings Python sont compatibles en termes d'API, ainsi le code écrit en utilisant l'un devrait fonctionner sans encombre avec l'autre. Néanmoins PySide étant une implémentation plus libre (LGPL) que PyQt et possédant le soutien en termes de développement de Nokia (cf. About PySide), j'ai décidé de me baser sur cette implémentation et d'utiliser la documentation officielle de PySide pour documenter cet article.
C'est un choix personnel et le code présenté en exemple est utilisable pour les deux bibliothèques moyennant les imports adéquats.
II. Hiérarchie et vue d'ensemble▲
L'implémentation du patron de conception MVD (Modèle Vue Délégué) est pensé de la façon suivante pour Qt :
- le modèle permet de stocker les données, de les modifier et de les distribuer ;
- la vue permet d'afficher les données (en s'appuyant sur un modèle) ;
- enfin les délégués servent à faire la transition entre l'édition des données et leur rendu dans la vue.
Cette définition quelque peu académique peut s'illustrer comme ceci. Imaginez un tableau permettant d'éditer les données d'une base de données :
- la variable de type matrice stockant les données du tableau représente le modèle ;
- le tableau tel que le voit l'utilisateur est la vue ;
- enfin si l'on choisit, par exemple, des composants précis pour éditer certaines cases de ce tableau (ex. un menu déroulant), alors c'est un délégué qui se charge de gérer le cycle de vie de ce composant.
Dans l'esprit de ce patron, Qt a implémenté quelques vues et quelques modèles de base. Pour mieux se représenter le système, voici une petite représentation hiérarchique des classes modèles du framework :
Ainsi que les délégués :
Cet article se pose donc à la fois comme une introduction et comme un approfondissement, car il existe déjà beaucoup de modèles de base répondant aux problématiques les plus courantes. Vous pouvez, par ailleurs, voir la partie ci-après Autres modèles de base pour autres objectifs pour la liste exhaustive des modèles existants et de leur utilisation.
Maintenant, voyons comment étendre le système pour, par exemple, créer un nouveau modèle de type liste.
III. Création d'un nouveau modèle de type liste▲
La création d'un nouveau modèle de type liste passe par la création d'une classe ayant pour parent la classe QAbstractListModel.
Un bon code valant mieux que beaucoup d'explications, voici un exemple simple de création d'un nouveau modèle de type liste :
class
SimpleListModel
(
QAbstractListModel):
# Constructeur
def
__init__
(
self, mlist):
QAbstractListModel.__init__
(
self)
self._items =
mlist
# redéfinition du calcul du nombre de lignes
def
rowCount
(
self, parent =
QModelIndex
(
)):
return
len(
self._items)
# méthode principale gérant l'accès aux données via un index
# avec une notion de role
def
data
(
self, index, role =
Qt.DisplayRole):
# Role simple de récupération des données
if
role ==
Qt.DisplayRole:
return
self._items[index.row
(
)]
# Role d'habillage des données
elif
role ==
Qt.BackgroundRole:
if
index.row
(
) %
2
==
0
:
return
QColor
(
Qt.gray)
else
:
return
QColor
(
Qt.lightGray)
else
:
return
None
Cet exemple est volontairement peu loquace en commentaires, la version complète écrite par Robin Burchell est disponible dans le fichier des sources téléchargeables de l'article.
Pour expliciter un peu, dans cet exemple, on étend la classe PySide/PyQt de base QAbstractListModel pour ajouter des fonctionnalités tout en conservant les implémentations des fonctions communes à tous types de listes, comme le tri (méthode sort()).
La documentation officielle est parfaite en elle-même, aussi je vous invite à la consulter dès qu'un point vous demande confirmation (ex. pour le QAbstractListModel). Elle nous précise qu'une sous-classe bien élevée réimplémente la méthode headerData() précisant l'en-tête de la colonne.
De plus, si la vue n'est pas en lecture seule alors il est nécessaire d'implémenter les méthodes suivantes :
- setData(index, value[, role=Qt::EditRole]) : permet de définir les données à la position index dans la table pour le rôle (role) donné ;
- flags() : prenant en entrée l'index d'un élément et retournant l'indicateur Qt.ItemIsEditable ;
- si la liste est de taille variable, les méthodes insertRows() et removeRows() doivent aussi être implémentées en appelant, juste avant et après l'insertion ou la suppression, les méthodes : beginInsertRows() et endInsertRows() ou beginRemoveRows() et endRemoveRows().
Pour avoir plus d'informations sur les différentes méthodes pouvant être réimplémentées, il suffit de se reporter à la partie création d'un modèle depuis la base qui contient la liste exhaustive des méthodes disponibles.
Avec ce petit exemple, on a réussi à implémenter un modèle pouvant gérer de manière personnalisée une simple liste d'objets. La partie suivante va nous permettre de créer un modèle personnalisé de type tableau.
IV. Création d'un nouveau modèle de type tableau▲
La création d'un nouveau modèle de type tableau passe par la création d'une classe ayant pour parent la classe QabstractTableModel.
Pour approfondir un peu plus, je vais présenter deux exemples, un basique et un porté sur le formatage. Ces deux exemples ont été créés par Eliot auteur du blog SaltyCrane.com et ont été adaptés pour cet article avec son autorisation. Voici donc le premier exemple permettant de créer un modèle simple de tableau :
from
PySide.QtCore import
*
from
PySide.QtGui import
*
import
sys
# données à représenter
my_array =
[['00'
,'01'
,'02'
],
['10'
,'11'
,'12'
],
['20'
,'21'
,'22'
]]
def
main
(
):
app =
QApplication
(
sys.argv)
w =
MyWindow
(
)
w.show
(
)
sys.exit
(
app.exec_
(
))
# création de la vue et du conteneur
class
MyWindow
(
QWidget):
def
__init__
(
self, *
args):
QWidget.__init__
(
self, *
args)
tablemodel =
MyTableModel
(
my_array, self)
tableview =
QTableView
(
)
tableview.setModel
(
tablemodel)
layout =
QVBoxLayout
(
self)
layout.addWidget
(
tableview)
self.setLayout
(
layout)
# création du modèle
class
MyTableModel
(
QAbstractTableModel):
def
__init__
(
self, datain, parent=
None
, *
args):
QAbstractTableModel.__init__
(
self, parent, *
args)
self.arraydata =
datain
def
rowCount
(
self, parent):
return
len(
self.arraydata)
def
columnCount
(
self, parent):
return
len(
self.arraydata[0
])
def
data
(
self, index, role):
if
not
index.isValid
(
):
return
QVariant
(
)
elif
role !=
Qt.DisplayRole:
return
QVariant
(
)
return
QVariant
(
self.arraydata[index.row
(
)][index.column
(
)])
if
__name__
==
"__main__"
:
main
(
)
Cet exemple n'est pas si différent du modèle précédent, on a juste adapté la structure des données pour gérer un tableau bidimensionnel plutôt qu'une liste simple. Mais cet exemple nous permet de profiter des facilités de la syntaxe de Python notamment pour la méthode columnCount() où l'on récupère la taille d'une tranche du tableau.
L'exemple suivant accorde une attention particulière au formatage des données et présente un petit programme permettant avec PySide/PyQt d'afficher le résultat de la commande « dir c:/ » sous MS Windows :
import
re
import
os
import
sys
from
PySide.QtCore import
*
from
PySide.QtGui import
*
def
main
(
):
app =
QApplication
(
sys.argv)
w =
MyWindow
(
)
w.show
(
)
sys.exit
(
app.exec_
(
))
class
MyWindow
(
QWidget):
def
__init__
(
self, *
args):
QWidget.__init__
(
self, *
args)
# création du tableau
self.get_table_data
(
)
table =
self.createTable
(
)
# mise en page
layout =
QVBoxLayout
(
)
layout.addWidget
(
table)
self.setLayout
(
layout)
def
get_table_data
(
self):
stdouterr =
os.popen4
(
"dir c:
\\
"
)[1
].read
(
)
lines =
stdouterr.splitlines
(
)
lines =
lines[5
:]
lines =
lines[:-
2
]
self.tabledata =
[re.split
(
r"\s+"
, line, 4
)
for
line in
lines]
def
createTable
(
self):
# création de la vue
tv =
QTableView
(
)
# création du modèle
header =
['date'
, 'time'
, ''
, 'size'
, 'filename'
]
tm =
MyTableModel
(
self.tabledata, header, self)
tv.setModel
(
tm)
# rajout d'une taille minimale
self.setMinimumSize
(
400
, 300
)
# et suppression du quadrillage
tv.setShowGrid
(
False
)
# mise en place de la police
font =
QFont
(
"Courier New"
, 8
)
tv.setFont
(
font)
# suppression de l'en-tête vertical
vh =
tv.verticalHeader
(
)
vh.setVisible
(
False
)
# mise en place de l'en-tête horizontal
hh =
tv.horizontalHeader
(
)
hh.setStretchLastSection
(
True
)
# redimensionnement de la largeur des colonnes
tv.resizeColumnsToContents
(
)
# définition de la largeur des cellules
nrows =
len(
self.tabledata)
for
row in
xrange(
nrows):
tv.setRowHeight
(
row, 18
)
return
tv
class
MyTableModel
(
QAbstractTableModel):
def
__init__
(
self, datain, headerdata, parent=
None
, *
args):
QAbstractTableModel.__init__
(
self, parent, *
args)
self.arraydata =
datain
self.headerdata =
headerdata
def
rowCount
(
self, parent):
return
len(
self.arraydata)
def
columnCount
(
self, parent):
return
len(
self.arraydata[0
])
def
data
(
self, index, role):
if
not
index.isValid
(
):
return
QVariant
(
)
elif
role !=
Qt.DisplayRole:
return
QVariant
(
)
return
QVariant
(
self.arraydata[index.row
(
)][index.column
(
)])
def
headerData
(
self, col, orientation, role):
if
orientation ==
Qt.Horizontal and
role ==
Qt.DisplayRole:
return
QVariant
(
self.headerdata[col])
return
QVariant
(
)
if
__name__
==
"__main__"
:
main
(
)
Ce code (conçu pour Python 2.4) permet de bien illustrer la mise en place des en-têtes de colonnes et d'un formatage précis. Pour ceux souhaitant l'exécuter avec Python 3, une version modifiée et mise au goût du jour est disponible dans le zip des sources de l'article.
On sait maintenant comment gérer des modèles de types liste et tableau, mais de nombreux autres modèles existent déjà nativement dans la bibliothèque Qt et la partie suivante va les présenter avec leurs particularités.
V. Autres modèles de base pour d'autres objectifs▲
Nous allons évoquer dans cette partie les différents modèles existants de base dans PySide/PyQt :
- le QStringListModel est utilisé pour stocker des objets QString dans une simple liste ;
- le QDirModel fournit des informations sur les fichiers et répertoires du système de fichiers local ;
- le QStandardItemModel permet de gérer des structures complexes d'objets (arbres, listes, tableaux…) en se servant du composant de base QStandardItem. Pour plus d'informations sur la manière de l'utiliser, consultez la documentation officielle qui présente de très bons exemples ;
- enfin les modèles QSqlQueryModel (lecture seule), QSqlTableModel et QSqlRelationalTableModel permettent de gérer plus facilement les liens avec une base de données en utilisant une approche modèle/vue. Vous pouvez trouver un exemple d'utilisation de ce type de modèles dans mon précédent tutoriel : Introduction et prise en main de PyQt. Chacun de ces modèles se distingue par sa gestion des particularités des bases de données, ainsi le modèle le plus précis le QSqlRelationalTableModel permet d'obtenir un tableau éditable où les clés étrangères sont directement gérées par des menus déroulants.
VI. Créer son modèle depuis la base▲
Créer son propre modèle est un sujet assez avancé qui ne doit pas être abordé à la légère sous peine de perdre de longues heures en débogage. Commençons donc par nous fixer un objectif : nous allons créer à partir de la classe mère QAbstractItemModel un modèle nous permettant d'afficher des données dans une vue arborescente la QTreeView.
Nous allons, pour aborder cette tâche, nous baser sur une approche existante, celle de Hardcoded Software (cf. section liens), en la simplifiant un peu et en se concentrant sur le code et son explication, mais d'abord un petit point sur les concepts abordés dans ce code.
Tout d'abord, parlons des QModelIndex, leur rôle est de fournir une indexation des éléments de notre modèle conditionnée par la ligne, la colonne, et (potentiellement) le QModelIndex de son parent (d'où la création d'une hiérarchie et donc d'un arbre). Il est important de lire la description détaillée du QModelIndex et de la QAbstractItemModel pour bien savoir où mettre les pieds et ce que l'un attend de l'autre.
Le code ci-dessous va créer deux classes abstraites qui vont implémenter deux méthodes, très difficile à gérer index() et parent(), tout en restant suffisamment génériques pour pouvoir être utilisées dans n'importe quelle situation où l'on veut utiliser une vue en arbre. Pour expliquer les méthodes du code ci-dessous je parlerai donc d'indice en faisant référence au QModelIndex unique de chaque objet du modèle.
- La méthode index() : permet de récupérer l'indice servant d'accesseur à un objet du modèle. Chaque objet du modèle a un indice et cette méthode permet de le récupérer, moyennant le triplet unique (ligne, colonne, parent) ;
- La méthode parent() : permet de récupérer le parent de l'indice donné en paramètre ou un indice invalide si celui-ci n'a pas de parent.
Maintenant place au code :
# tout d'abord, créons un objet qui va jouer le rôle de nœud dans notre vue arborescente
# il est volontairement simple pour être adaptable à toutes situations
# avec :
# - un parent ;
# - un fils ;
# - et un enregistrement associé (la valeur du nœud).
class
TreeNode
(
object):
def
__init__
(
self, parent, row):
self.parent =
parent
self.row =
row
self.subnodes =
self._getChildren
(
)
def
_getChildren
(
self):
raise
NotImplementedError
(
)
# Nous allons créer une classe mère de nos futurs modèles
# pour limiter la complexité et gérer ce que l'on peut gérer en amont :
class
TreeModel
(
QAbstractItemModel):
def
__init__
(
self):
QAbstractItemModel.__init__
(
self)
self.rootNodes =
self._getRootNodes
(
)
# à implémenter par la future classe fille
def
_getRootNodes
(
self):
raise
NotImplementedError
(
)
# cette méthode héritée de QAbstractItemModel doit retourner
# l'indice de l'enregistrement en entrée moyennant le parent (un QModelIndex)
# cf. paragraphe suivant pour plus d'explications.
def
index
(
self, row, column, parent):
# si l'indice du parent est invalide
if
not
parent.isValid
(
):
return
self.createIndex
(
row, column, self.rootNodes[row])
parentNode =
parent.internalPointer
(
)
return
self.createIndex
(
row, column, parentNode.subnodes[row])
# cette méthode héritée de QAbstractItemModel doit retourner
# l'indice du parent de l'indice donné en paramètre
# ou un indice invalide (QModelIndex()) si le nœud n'a pas de parent
# ou si la requête est incorrecte
# c.f. paragraphe suivant pour plus d'explications.
def
parent
(
self, index):
if
not
index.isValid
(
):
return
QModelIndex
(
)
# on récupère l'objet sous-jacent avec la méthode internalPointer de l'indice
node =
index.internalPointer
(
)
if
node.parent is
None
:
return
QModelIndex
(
)
else
:
# si tout est valide alors on crée l'indice associé pointant vers le parent
return
self.createIndex
(
node.parent.row, 0
, node.parent)
def
reset
(
self):
self.rootNodes =
self._getRootNodes
(
)
QAbstractItemModel.reset
(
self)
def
rowCount
(
self, parent):
if
not
parent.isValid
(
):
return
len(
self.rootNodes)
node =
parent.internalPointer
(
)
return
len(
node.subnodes)
Avec l'implémentation de ces deux méthodes complexes (index() et parent()), on simplifie grandement notre modèle qui est maintenant utilisable :
class
NamedElement
(
object): # notre structure interne pour gérer les objets
def
__init__
(
self, name, subelements):
self.name =
name
self.subelements =
subelements
# notre nœud concret implémentant getChildren
class
NamedNode
(
TreeNode):
def
__init__
(
self, ref, parent, row):
self.ref =
ref
TreeNode.__init__
(
self, parent, row)
# renvoie la liste des nœuds fils en utilisant la liste subelements de
# notre objet (interne) NamedElement
def
_getChildren
(
self):
return
[NamedNode
(
elem, self, index)
for
index, elem in
enumerate(
self.ref.subelements)]
# et enfin notre modèle avec
class
NamesModel
(
TreeModel):
def
__init__
(
self, rootElements):
self.rootElements =
rootElements
TreeModel.__init__
(
self)
def
_getRootNodes
(
self):
return
[NamedNode
(
elem, None
, index)
for
index, elem in
enumerate(
self.rootElements)]
def
columnCount
(
self, parent):
return
1
# permet de récupérer les données liées à un indice et un rôle.
# ces données peuvent ainsi varier selon le rôle.
def
data
(
self, index, role):
if
not
index.isValid
(
):
return
None
node =
index.internalPointer
(
)
if
role ==
Qt.DisplayRole and
index.column
(
) ==
0
:
return
node.ref.name
return
None
def
headerData
(
self, section, orientation, role):
if
orientation ==
Qt.Horizontal and
role ==
Qt.DisplayRole \
and
section ==
0
:
return
'Name'
return
None
Ce n'est pas du code prêt à l'emploi pour aller en production, mais il est suffisamment basique pour permettre de bien comprendre les concepts en jeu. Pour sa part Hardcoded Software a distribué, en plus de ce code, une version libre (sous licence BSD) du code qu'ils utilisent avec des améliorations de conception et de performance disponible ici.
J'espère que cet exemple vous aura permis de bien appréhender la création d'un modèle à partir de la base. Bien sûr, ce code se base sur une représentation orientée arbre, mais je trouvais important d'aborder la question de la QTreeView. En effet, il est plus probable que vous partiez du QAbstractItemModel pour créer une vue arborescente, les modèles QAbstractListModel et QAbstractTableModel étant plus adaptés pour d'autres types de vues.
Cet exemple permet aussi de bien saisir les limitations du patron MVD, car, si on est parti du principe que modèles et vues étaient combinables indépendamment, quand il s'agit d'un modèle d'arbre, l'implémentation devient rapidement spécifique et complexe.
La complexité autour de la création de son propre modèle a d'ailleurs poussé un des développeurs de Qt à construire un petit outil de vérification des modèles : ModelTest. Cet outil en C++ n'est pas disponible pour PySide/PyQt, mais n'hésitez pas à me contacter si ce défi vous tente, je serais ravi d'aider.
VII. Conclusion▲
En conclusion, on a pu voir de nombreux modèles existant au sein de PySide/PyQt qui permettent de réaliser beaucoup sans avoir à recoder son propre modèle, ainsi que comment étendre certains modèles, voire créer son propre modèle en partant de la base.
Compte tenu des possibilités du système, il reste assez rare de créer son propre modèle, mais j'espère que cet article vous permettra de vous lancer sans appréhension dans cette tâche, n'hésitez pas d'ailleurs à contribuer en retour, en communiquant ces modèles à la communauté pour permettre à chacun d'en profiter et d'apprendre.
VIII. Liens pour aller plus loin▲
- Tutoriel de Robin Burchell sur la création d'un modèle de type Liste
- Documentation officielle de Qt sur le QAbstractItemModel
- Documentation officielle sur les bonnes pratiques pour étendre les classes modèles ;
- Blog de SaltyCrane avec de nombreux articles autour de PyQt
- Création d'un modèle à partir de QAbstractItemModel pour une QTreeView par Hardcodefd Software
- Discussion sur les interfaces graphiques pour Python sur developpez.net
IX. Remerciements▲
Je tiens à remercier Thibaut Cuvelier responsable Qt pour son aide dans la rédaction de cet article, Robin Burchell et Eliot - SaltyCrane pour leur aide et leur expertise autour PySide/PyQt et enfin Jacques Thery pour sa relecture attentive.