I. L'article original

Qt Quarterly est une revue trimestrielle électronique proposée par Nokia à destination des développeurs et utilisateurs de Qt. Vous pouvez les trouver en version originale.

Nokia, Qt, Qt Quarterly et leurs logos sont des marques déposées de Nokia Corporation en Finlande et/ou dans les autres pays. Les autres marques déposées sont détenues par leurs propriétaires respectifs.

Cet article est la traduction de l'article Mapping Data to Widgets de David Boddie paru dans la Qt Quarterly Issue 21.

Cet article est une traduction de l'un des tutoriels en anglais écrits par Nokia Corporation and/or its subsidiary(-ies), inclus dans la documentation de Qt. Les éventuels problèmes résultant d'une mauvaise traduction ne sont pas imputables à Nokia.

II. Un simple widget de correspondance

La classe QDataWidgetMapper est une classe conçue pour accéder aux données dans un modèle en tableau et afficher les informations obtenues dans une collection de widgets choisis.

Dans sa configuration par défaut, un QDataWidgetMapper a accès à une seule ligne à la fois et associe son contenu à des widgets spécifiques. Pour chaque ligne examinée, il fait correspondre les propriétés utilisateur des widgets à chaque colonne correspondante, comme l'indique le diagramme suivant :

http://qt-quarterly.developpez.com/qq-21/widget-correspondance-donnees/images/image2.png

Voici un petit exemple de code qui crée une simple interface en formulaire, en utilisant un QDataWidgetMapper et un tableau pour mettre à jour le contenu des registres dès que l'utilisateur clique sur un bouton. L'exemple sera constitué d'une seule classe : « Window ».

Elle possédera une méthode (utilisée comme slot) pour conserver une interface cohérente et une autre méthode setupModel() qui crée un modèle contenant quelques données à afficher.

Presque tout est fait dans le constructeur de la classe Window. On commence par initialiser le modèle contenant les données à afficher (on s'y attardera) et assemble l'interface de l'utilisateur.

 
Sélectionnez
class Window(QtGui.QWidget):
    """Classe principale"""
    def __init__(self, parent):
        """Constructeur"""
        super(Window, self).__init__(parent)
        self.setWindowTitle("Exemple de QDataWidgetMapper")

        self.setupModel()

        # Nom
        self.nameLabel=QtGui.QLabel("Nom:")
        self.nameEdit=QtGui.QLineEdit()

        # Adresse        
        self.addressLabel=QtGui.QLabel("Adresse:")
        self.addressEdit=QtGui.QTextEdit()

        # Âge
        self.ageLabel=QtGui.QLabel("Âge:")
        self.ageSpinBox=QtGui.QSpinBox()

        # Navigation
        self.nextButton=QtGui.QPushButton("Suivant")
        self.previousButton=QtGui.QPushButton("Précédent")

Seuls les widgets éditables seront utilisés avec le QDataWidgetMapper. Les boutons permettent à l'utilisateur de naviguer parmi les registres.

Utiliser le widget mapper en lui-même est assez facile : on construit une instance de QDataWidgetMapper, lui apporte un modèle à utiliser et associe chaque widget à une colonne choisie dans le modèle.

 
Sélectionnez
    # Widget de correspondance
        self.mapper=QtGui.QDataWidgetMapper(self)
        self.mapper.setModel(self.model)
        self.mapper.addMapping(self.nameEdit, 0)
        self.mapper.addMapping(self.addressEdit, 1)
        self.mapper.addMapping(self.ageSpinBox, 2)

On devra s'assurer que le modèle contient les bonnes données dans chaque colonne. Créer un QWidgetMapper est rarement plus compliqué que ça.

Pour rendre l'exemple interactif, on connecte un QPushButton au widget pour que l'utilisateur puisse examiner chaque registre. Pour plus de cohérence, on le connecte au slot updateButtons() (en réalité, une simple méthode), on peut ainsi choisir d'activer ou non le bouton si nécessaire.

 
Sélectionnez
    #Connexions
        self.connect(self.previousButton, QtCore.SIGNAL("clicked()"),
                     self.mapper, QtCore.SLOT("toPrevious()"))
        self.connect(self.nextButton, QtCore.SIGNAL("clicked()"),
                     self.mapper, QtCore.SLOT("toNext()"))
        self.connect(self.mapper,QtCore.SIGNAL("currentIndexChanged(int)"),
                     self.updateButtons)

    self.mapper.toFirst() # Nécessaires au premier lancement 
    self.updateButtons(0) # 

Une fois les connexions réalisées, on demande au widget de correspondance de se référer à la première ligne du modèle.

Pour montrer l'utilisation la plus simple possible d'un QDataWidgetMapper, on utilise un QStandardItemModel comme table. La fonction setupModel() crée un modèle à taille fixe et l'initialise en utilisant des données préparées à l'aide de trois listes :

 
Sélectionnez
def setupModel(self):
        """Crée le modèle"""
        self.model=QtGui.QStandardItemModel(5, 3, self)
        
        names=["Alice", "Bob", "Carol", "Donald", "Emma"]
        ages=["12", "25", "29", "32", "41"]
        
        for row in range(0,5):
            item=QtGui.QStandardItem(names[row])
            self.model.setItem(row, 0, item)
            
            item=QtGui.QStandardItem(ages[row])
            self.model.setItem(row, 2, item)

On enregistre les noms, adresses et âges d'un groupe de personnes, respectivement dans les colonnes 0, 1 et 2, chaque ligne contenant les informations relatives à une seule personne. Les colonnes utilisées sont les mêmes que celles spécifiées au widget. Tous les noms seront donc affichés dans un QLineEdit, les adresses dans un QTextEdit et les âges dans un QSpinBox.

Le slot updateButton() est appelé dès que le widget de correspondance lit une ligne du modèle et est simplement utilisé pour activer et désactiver les QPushButton :

 
Sélectionnez
def updateButtons(self, index):
        """Met à jour l'état des boutons si nécessaire"""
        self.previousButton.setEnabled(index>0)
        self.nextButton.setEnabled(index<self.model.rowCount()-1)

Quand l'exemple est lancé, les boutons permettent de sélectionner différents registres en changeant la ligne courante dans le modèle. Comme on utilise des widgets éditables, tous les changements effectués sur les informations affichées seront répercutés sur le modèle.

III. Utiliser les délégués pour laisser le choix

Pour les widgets ne détenant qu'une seule information, comme un QLineEdit et les autres widgets de l'exemple précédent, utiliser un QDataWigetMapper est un jeu d'enfant. Les problèmes surviennent avec les widgets qui proposent des choix (tels que les QComboBox). On doit alors penser à la façon dont ces choix seront stockés et comment mettre les widgets à jour le cas échéant.

Regardons l'exemple précédent. On utilisera presque la même interface qu'avant, en remplaçant le QSpinBox par un QComboBox. Bien que le combo box puisse afficher la valeur d'une colonne d'un tableau, il n'y a aucun moyen d'enregistrer à la fois le choix courant et les choix possibles d'une manière compréhensible pour un QDataWidgetMapper.

On pourrait mettre un modèle différent sur le combo box. Cependant, ce simple changement le détacherait du widget de correspondance. Pour contrer ce problème, on doit introduire un autre composant fréquent dans l'architecture modèle/vue de Qt : le délégué.

Image non disponible

Le diagramme précédent montre ce que l'on souhaite réaliser : un combo box affichant une liste de choix pour chaque item de la troisième colonne du modèle. À cette fin, on crée un modèle en liste contenant les choix possibles du combo box (« Home », « Work » et « Other »). Dans la troisième colonne du tableau, on fait correspondre ces choix par le numéro de ligne leur correspondant.

En se basant sur l'exemple précédent, on peut utiliser un combo box à la place d'un spin box et la configurer pour utiliser un modèle en liste contenant les choix voulus. On construit ensuite son propre délégué.

La partie intéressante de la classe Window ressemble maintenant à ceci :

 
Sélectionnez
class Window(QtGui.QWidget):
    """Classe principale"""
    def __init__(self, parent):
        """Constructeur"""
        super(Window, self).__init__(parent)
        self.setWindowTitle("Exemple de QDataWidgetMapper")

        self.setupModel()    
        
        # Type
        self.typeComboBox=QComboBox()
        self.typeComboBox.setModel(self.typeModel)
        ...
        
        # Widget de correspondance
        self.mapper=QDataWidgetMapper(self)
        self.mapper.setModel(self.model)
        delegate=Delegate()
        self.mapper.setItemDelegate(delegate)
        
        self.mapper.addMapping(self.nameEdit, 0)
        self.mapper.addMapping(self.addressEdit, 1)
        self.mapper.addMapping(self.typeComboBox, 2)

Quand on crée le modèle dans la fonction setupModel(), on initialise aussi la l'attribut typeModel avec un QStringListModel des choix disponibles dans le combo box.

 
Sélectionnez
    def setupModel(self):
    """Crée le modèle"""
       ...
       self.typeModel=QtGui.QStringListModel(["Home", "Work", "Other"])

Maintenant, le délégué. La classe Delegate dérive de QItemDelegate et apporte une implémentation minimale de l'API qui interprète les données spécialement pour les combo box.

 
Sélectionnez
class Delegate(QtGui.QItemDelegate):
    def __init__(self, parent=None):
        QtGui.QItemDelegate.__init__(self, parent=None)

Comme les délégués n'ont besoin, pour communiquer, que d'informations entre le modèle et le widget de correspondance, on ne doit implémenter que les méthodes setEditorData() et setModelData(). Dans la plupart des cas, les délégués sont responsables de la création et de l'édition, mais c'est inutile ici, car ils sont utilisés avec un QDataWidgetMapper et l'éditeur existe déjà.

La méthode setEditorData() initialise l'éditeur avec les bonnes données. Pour rendre le code plus générique, on ne vérifie pas explicitement que l'éditeur est une instance de QCombobox, on vérifie l'absence de propriétés d'utilisateur et celle d'une propriété d'index courant, "currentIndex".

 
Sélectionnez
    def setEditorData(self, editor, index):
        if not (editor.metaObject().userProperty().isValid()) :
            if editor.property("currentIndex")is not None:
                editor.setProperty("currentIndex", index.data())
                return
        QtGui.QItemDelegate.setEditorData(self, editor, index)

On règle la propriété utilisateur en fonction de la valeur fournie par le modèle en changeant l'item visible dans le combo box. On recommence l'implémentation de base pour tous les autres widgets.

La méthode setModelData() est responsable du transfert des données modifiées dans le modèle. Si l'éditeur n'a pas de propriété utilisateur valide, mais possède une propriété "currentIndex", c'est cette valeur qui sera stockée dans le modèle.

 
Sélectionnez
    def setModelData(self, editor, model, index):
        if not (editor.metaObject().userProperty().isValid()):
            value=editor.property("currentIndex")
            if value is not None:
                model.setData(index, value)
                return
        QtGui.QItemDelegate.setModelData(self, editor, model, index)

Dans tous les autres cas, l'implémentation de base des classes est utilisée pour lier l'éditeur et le modèle.

Dans les deux méthodes, on n'obtient pas les informations affichées dans le combo box, puisqu'elles sont déjà dans un modèle séparé.

III-A. Propriétés utilisateur par défaut

Pour transférer les données entre un modèle et des widgets, chaque QDataWidgetMapper a besoin de savoir à quelle propriété se référer. De nombreux widgets n'ont qu'une seule propriété utilisateur (on peut y accéder grâce à la méthode userProperty() de l'objet en question) et cette dernière est automatiquement choisie quand elle est disponible.

Les problèmes surviennent quand un widget que l'on veut utiliser n'a pas de propriété utilisateur, c'est par exemple le cas avec un QCombobox. Pour résoudre ce problème, QDataWidgetMapper possède une nouvelle méthode, addMapping(), qui prend en argument un nom de propriété. Ainsi, il est possible contourner ces problèmes et d'utiliser les widgets sans propriété utilisateur.

IV. Associer les informations d'après une base de données

Une des plus fréquentes utilisations des QDataWidgetMapper est de connecter une base de données à une vue. Comme la classe elle-même est faite pour fonctionner avec n'importe quel modèle correct, ce devrait être un jeu d'enfant de créer un autre exemple comme le précédent. Pourtant, il s'avère que les applications basées sur des registres requièrent souvent à l'utilisateur de sélectionner parmi un set de choix, rendant ce cas plus intéressant.

Dans la plupart des applications avec une base de données SQL, les deux modèles utilisés dans les exemples précédents seraient représentés à l'aide de deux tables. On créera une table "person" qui contiendra les noms, adresses et une clef étrangère dans une autre table qui contiendra le type d'adresse. La table ressemble à ceci :

id name address typeid
1 Alice 123 Main Street 101
2 Bob PO Box 32... 102
3 Carol The Lighthouse... 103
4 Donald 47338 Park Avenue... 101
5 Emma Research Station... 103

La valeur de "typeid" correspond à la valeur de la colonne "id" de la table "addresstype", qui ressemble à cela :

id description
101 Home
102 Work
103 Other

Dans la méthode setupModel(), on crée une unique instance de QSqlRelationalTableModel, en utilisant une base de données SQLite en mémoire, remplie avec une série de requêtes SQL (qui ne sont pas montrées ici).

 
Sélectionnez
        self.db=QtSql.QSqlDatabase.addDatabase("QSQLITE")
        self.db.setDatabaseName(":memory:")
        self.db.open()

        self.setupDatabase()

        self.model=QtSql.QSqlRelationalTableModel(self)
        self.model.setTable("person")

La table "person" contient les informations que nous voulons utiliser avec le widget de correspondance. On utilise setTable() avec la table en argument.

On doit créer une relation entre ces deux tables. On demande donc au modèle son champ d'index et l'enregistre pour utilisation future :

 
Sélectionnez
        self.typeIndex=self.model.fieldIndex("typeid")
        self.model.setRelation(self.typeIndex,
                               QtSql.QSqlRelation("addresstype", "id", "description"))
        self.model.select()

La relation indique que les valeurs trouvées dans la colonne "typeid" doivent correspondre aux valeurs de la colonne "id" dans la table "adresstype" et que ce sont les valeurs de la colonne " description " qui sont intéressantes.

Dans le constructeur de la classe Window, on obtient le modèle qui gère cette relation. Les valeurs enregistrées précédemment sont utilisées avec le combo box, comme dans l'exemple précédent. On utilise la fonction setModelColumn() pour s'assurer que le combo box affiche bien la donnée de la colonne appropriée :

 
Sélectionnez
    self.rel=self.model.relationModel(self.typeIndex)
    self.typeComboBox.setModel(self.rel)
    self.typeComboBox.setModelColumn(self.rel.fieldIndex("description"))
    ...
    #Widget de correspondance
    self.mapper=QtGui.QDataWidgetMapper(self)
    self.mapper.setModel(self.model)

    self.mapper.setItemDelegate(QtSql.QSqlRelationalDelegate(self))
    ...
    self.mapper.addMapping(self.typeComboBox, self.typeIndex)

Grâce à la structure de la base de données mise en place, on peut utiliser un QSqlRelationalDelegate au lieu d'en implémenter un personnalisé.

Cependant, un bogue de Qt oblige à ajouter quelques lignes de code.

Pour y remédier, il faut modifier stratégie d'édition du modèle. Celle par défaut est « OnFieldChange », on va l'échanger avec « OnManualSubmit ». Comme vous l'aurez compris, on doit par conséquent préciser à notre modèle quand se mettre à jour.

Le plus intéressant est de le faire au moment où l'on change l'index du QDataWidgetMapper, c'est-à-dire le moment où l'utilisateur a fini de modifier ses champs. Les index sont modifiés lors des slot toPrevious() et toNext() du QDataWidgetMapper.

On choisit donc d'écrire deux nouvelles méthodes de la classe Window :

 
Sélectionnez
    def toPrevious(self):
        self.mapper.toPrevious()
        self.model.submit()

    def toNext(self):
        self.mapper.toNext()
        self.model.submit()

On doit alors modifier les slots associés aux signaux des boutons :

 
Sélectionnez
        self.connect(self.previousButton,QtCore.SIGNAL("clicked()"),
                     self.toPrevious)
        self.connect(self.nextButton,QtCore.SIGNAL("clicked()"),
                     self.toNext)

Bien sûr, on n'oublie pas de modifier la stratégie d'édition du modèle principal :

 
Sélectionnez
        self.model.setEditStrategy(QtSql.QSqlTableModel.OnManualSubmit)

Mis à part le SQL, cet exemple est plus ou moins le même qu'avant ; le QSqlRelationalDelegate se comporte différemment de la classe de délégué personnalisée, mais finalement réalise la même chose.

V. Récapitulatif

Un QDataWidgetMapper permet d'associer une colonne d'un tableau à un widget spécifique dans une interface de l'utilisateur et d'accéder aux registres un par un.

Les champs qui nécessitent une liste de choix autorisés ne sont pas gérés de la même manière que les autres, on doit leur appliquer un procédé plus compliqué. On utilise les délégués comme intermédiaire entre le modèle et les ressources additionnelles contenant les choix disponibles. On a aussi besoin de widgets comme les QCombobox pour afficher ces choix, plutôt que les données brutes du modèle.

Le module QtSql apporte la classe QSqlRelationalDelegate pour faciliter l'utilisation des modèles SQL, mais on a tout de même besoin de faire attention à créer des relations entre les tables correctement.

Les sources de cet article sont disponibles : section 2, section 3 et section 3 avec contournement du bogue de Qt.

Merci à Thibaut Cuvelier, Claude Leloup et Maxime Gault pour leur relecture !