Présentation

L'affichage et la manipulation de données est une problèmatique aussi vieille que l'informatique elle-même. En 1979, une tentative de formalisation de ce domaine aboutit au concept de MVC (littéralement Modèle-Vue-Controlleur) qui prend tout d'abord naissance dans le monde smalltalk, et qui permet de dissocier les données de la présentation ainsi que de la logique de contrôle. Cette stratégie permet un découpage clair des concepts et de leurs implémentations, mais surtout une factorisation du code; le fameux concept DRY cher à Ruby, ainsi, alors que le modèle est entièrement dévoué à fournir des données et à en accepter, la vue est uniquement concentrée sur la façon d'afficher les données (c'est la partie émergée de l'iceberg pour l'utilisateur de l'application) tandis que le controlleur, tel un arbitre, se charge de synchroniser les évènements entre le modèle et la vue.

Qt 4 a choisi d'utiliser ces concepts au sein de plusieurs de ses composants de manipulation et d'affichage de données. Dans la bibliothèque de Trolltech, le C du MVC se retrouve combiné au V pour des raisons de praticité et on parle ainsi de ''Modèle/Vue''. A ces concepts se voit adjoint celui de delegate qui définit concrètement la façon dont sont présentées et éditées les données (par exemple via une liste déroulante, une cellule d'édition, etc).

On trouve plusieurs composants de type "vue" ; voici ceux que l'on peut trouver dans la dernière version de Qt 4 au moment où est écrit ce tutoriel (4.3.3) :

  • Le QTableView qui permet d'afficher un ensemble de données sous forme de tableau et sans notion de hiérarchie.
  • Le QTreeView modélise un ensemble de données hiérarchisées sous forme d'arbre.
  • Le QListView affiche des données sous forme de liste, exactement comme le mode "liste" d'un explorateur de fichiers.
  • Le QColumnView qui affiche une succession de QListViews pour afficher une hiérarchie, à l'instar de Finder, le fameux navigateur de fichiers de MacOSX ou de celui de l'Ipod.

Il est à noter que tous ces composants trouvent leurs équivalents déjà affectés de modèles simples et qui peuvent être amplement suffisants pour un usage de base. Si vous cherchez un moyen simple d'afficher des données sans vous soucier d'optimisation, de maintenabilité, de paramétrage fin et si concevoir un système MVC ne vous séduit pas plus que cela, ce tutoriel n'est peut-être pas tout-à-fait indiqué et vous devriez peut-être vous pencher sur les QTableWidget/QTreeWidget/QListWidget qui offrent déjà pas mal de possibilités.

L'utilisation des modèles dans Qt passe systématiquement par l'héritage d'un des modèles Qt dont l'ancêtre commun est toujours la classe abstraite QAbstractItemModel.
La plupart du temps, vous n'aurez pas besoin d'hériter des vues ainsi que de définir de nouveaux delegates (ni même vous en soucier pour ces derniers); ceux qui existent déjà suffisent pour la plupart des usages. Néammoins, ce besoin pourra se faire sentir dans certaines circonstances, c'est pourquoi l'écriture d'un delegate particulier et son intégration au sein d'une vue sera abordé dans un autre tutoriel.

Le concept de MVC est abondamment expliqué dans la documentation officielle Qt et la création d'un modèle simple non hiérarchique est relativement simple à appréhender. Il se peut même que vous n'ayez pas besoin de ce tutoriel car les exemples de Qt en ce qui concerne les modèles/vues sont nombreux et je vous recommande de jeter un oeil aux exemples situés dans le répertoire itemviews du tarball officiel si le parcours d'un tutoriel n'est pas votre tasse de thé.
Ainsi, tout au long de ce document, nous allons nous concentrer sur le QTreeView et sur la création d'un modèle hiérarchique afin d'expliciter quelques points qui peuvent paraître obscures lorsqu'on s'initie à leur création. Nous tâcherons ainsi d'éclaircir le rôle des diverses méthodes virtuelles à hériter obligatoirement (méthodes abstraites) ou de façon optionnelle afin d'affiner le comportement de notre modèle.

Rétro-conception

Puisqu'il va falloir indiquer au modèle où trouver nos données et comment se représenter la hiérarchie de celles-ci, il est crucial d'avoir déjà une idée claire et précise de la façon dont nos données sont agencées en mémoire et ce qui les lie les unes aux autres.
Du côté du modèle, le QModelIndex nous offre un moyen simple de mettre en oeuvre les briques élémentaires de la modélisation de nos données.
Cette classe est le lien entre la donnée élémentaire et le modèle, mais ne se substitue pas à la donnée en elle-même; il est primordial de concevoir une structure de données hiérarchiques interne qui sera utilisée par notre modèle pour créér ses index.
De façon plus formelle, le QModelIndex connaît le modèle auquel il appartient, son père[1], son numéro de ligne (au sein des enfants de ce père, pas le numéro de ligne absolu dans le modèle) ainsi que son numéro de colonne. Voilà en gros tout dont le modèle a besoin pour construire sa hiérarchie et on expliquera les rôles des autres champs ultérieurement. Pour la suite, pour évoquer le QModelIndex on parlera simplement d'index.

Les structures internes de nos données devront refléter ces propriétés relationnelles, par exemple, il sera indispensable qu'on puisse facilement retrouver le père d'un élément de données. Ranger nos données dans un structure arborescente est donc probablement le meilleur des choix. S'il n'est pas possible de stocker toutes les données en mémoire (typiquement dans le cas d'une base de données), il faudra tout de même pouvoir modéliser ces concepts relationnels "facilement".

Comme nous l'avons vu plus haut, concevoir un modèle dans Qt 4, c'est hériter, directement ou indirectement, de la classe QAbstractItemModel et implémenter un lot de méthodes virtuelles dont certaines sont abstraites et donc doivent être impérativement héritées sous peine d'erreur de la part du compilateur.

Ces fonctions abstraites sont :

  • QModelIndex index(int row, int column, const QModelIndex & parent = QModelIndex())

C'est un peu l'"usine à index" du modèle. Ici vont être créés puis renvoyés les index en fonction d'un numéro de ligne, de colonne et d'un index père. A chaque fois que l'on aura besoin d'un index quelque part, que ce soit dans le modèle ou à l'extérieur de ce modèle, c'est cette méthode qui sera invoquée (à l'exception de la création des index pères qui seront fabriqués par le biais de la fonction parent()).

  • QModelIndex parent(const QModelIndex & index)

Cette méthode est utilisée pour retrouver un index père en fonction de son fils. Comme nous l'avons vu plus haut dans l'encadré, cette méthode est directement appelée par la méthode "parent()" de chaque QModelIndex appartenant au modèle.

  • int rowCount(const QModelIndex & parent = QModelIndex())

Cette fonction renvoie le nombre de lignes de l'index père passé en paramètre qu'on peut assimiler au nombre d'index enfants de ce père. Cette méthode peut également se révéler couteuse et dans ce cas, il est parfois utile d'envisager la redéfinition de la méthode "hasChildren()" (cette méthode fait appel à rowCount() dans son implémentation par défaut) qui permet de signaler au modèle qu'un index a des enfants sans pour autant en spécifier combien, et ainsi par exemple afficher un noeud dans la vue qui, impliquera alors un appel à rowCount().

  • int columnCount(const QModelIndex & parent = QModelIndex())

Cette méthode renvoie le nombre de colonnes des enfants de l'index père passé en paramètre. Dans la plupart des cas, cette méthode renverra un nombre non dépendant du père.

  • QVariant data(const QModelIndex & index, int role = Qt::DisplayRole)

C'est probablement la fonction la plus sollicitée pendant la vie du modèle; elle permet de connaître les données à afficher dans la vue en fonction d'un index et d'un rôle.
Les rôles définissent dans les détails la façon dont une donnée est présentée par les vues associées. On peut par exemple invoquer la méthode data() pour demander la valeur du texte à afficher par le biais de Qt::DisplayRole ou alors la couleur de fond de la cellule qui accueuillera le texte avec le paramètre Qt::BackgroundRole, ou si nécessaire, la police de caractère à utiliser grâce à Qt::FontRole.
Les rôles ont été conçus pour être les plus exhaustifs et génériques possibles mais il est possible de les étendre à partir de la valeur Qt::UserRole qui constitue le premier role "utilisateur".
Dans le cas d'une utilisation classique, le rôle essentiel est Qt::DisplayRole car il concerne directement la donnée (très souvent et en particulier ici du texte) à afficher dans la vue.
Il faut également garder à l'esprit que data() étant appelée très fréquemment, il est important de ne pas y trouver de traitements lourds. Typiquement, je ne fais aucun appel aux outils de traduction dans cette méthode, je les déporte dans le constructeur du modèle.

Puisque Trolltech nous fournit déjà des exemples et des démonstrations de qualité, nous allons appuyer ce tutoriel sur la démo simpletreemodel (sur cette page, vous trouverez également une bonne explication en anglais de cet exemple) disponible dans tout tarball Qt (et dans certains paquets) afin d'expliquer en détail l'implémentation du modèle et les choix effectués.

simpletreemodel

Description

Cet exemple charge et affiche le contenu dans un arbre (QTreeView) d'un fichier de type "Sommaire" de livre. Ce fichier est un ensemble de lignes de texte plus ou moins en retrait par le biais de caractères d'espacement. Par exemple, une ligne qui commence par 8 espaces et qui précède une ligne qui débute par 4 espaces sera considérée comme "fille" de la ligne précédente. Chaque ligne de texte est séparée en deux par un ensemble de tabulations, cet ensemble de tabulation permettra de diviser la ligne en deux colonnes dans l'arbre. Ainsi nous avons donc un arbre à deux colonnes décrit au sein d'un fichier texte et dont voici un extrait :

Form Editing Mode                       How to edit a form in Qt Designer
    Managing Forms                      Loading and saving forms
...
    Layouts                             Objects that arrange widgets on a form
        Applying and Breaking Layouts   Managing widgets in layouts

Exécution

Au lancement du programme, un modèle (implémenté dans treemodel.h/treemodel.cpp) est créé avec en paramètre le texte du fichier "default.txt" chargé pour l'occasion. Ce texte est "parsé" (analysé et stocké dans une structure de données) pendant la construction du modèle, ce qui permettra son utilisation immédiate plus tard sans autre forme d'initialisation. Puis, un QTreeView est également créé et on lui affecte le modèle. On attribue un titre à cet arbre puis il est affiché et la boucle d'évènements est lancée; le programme est réellement visuellement disponible à ce moment là et nous pouvons voir à l'écran le QTreeView en action et le manipuler à notre guise.

Analyse

Le stockage des données

Examinons tout d'abord le contenu de treeitem.h/.cpp. La classe TreeItem est conçue pour stocker une hiérarchie de données. Voici ses caractéristiques :

  • parentItem pointe vers son élément père.
  • childItems est une liste de pointeurs vers les fils de cet élément.
  • itemData contient les données de l'élément sous forme de liste de QVariant (qui est le type générique de Qt, voir la documentation).

Pour cet exemple, l'auteur aurait pu se contenter pour itemData d'une liste de QString, mais le QVariant permet d'imaginer un enrichissement de l'exemple afin de pouvoir stocker et afficher des types de données hétéroclytes au sein de la même liste. Il permet surtout d'être directement renvoyé par la méthode data() du modèle sans aucune conversion comme nous le verrons plus tard. A l'image d'une liste chaînée, l'arbre entier est accessible par sa racine et lorsqu'on détruit un TreeItem, ses enfants sont également détruits. Notez à ce titre l'utilisation de qDeleteAll(), fonction bien pratique qui permet de détruire tous les objets pointés stockés dans une structure de données Qt (QList, QVector, etc).

Voilà qui permet de conclure sur cette classe "jumelle" à la hiérarchie des index et qui servira de conteneur de données librement consultable par le modèle de notre vue.

Le modèle

Intéressons-nous maintenant à la classe TreeModel définie dans treemodel.h/.cpp.

La méthode setupModelData() contient le code le plus complexe de l'exemple :-), il analyse le texte en paramètre et en déduit une hiérarchie de TreeItems qu'il lie au noeud parent passé en paramètre. Cette fonction est appelée uniquement dans le constructeur avec rootData préalablement créé. Ce noeud racine sera le point d'accès à l'arbre de données pour le modèle.

En dehors de setupModelData(), le reste des méthodes présentes sont des éléments hérités appartenant au QAbstractItemModel :

La méthode parent()
QModelIndex TreeModel::parent(const QModelIndex &index) const
{
    if (!index.isValid())
        return QModelIndex();

Le but de la méthode parent() est de créer et renvoyer un QModelIndex correspondant au père du QModelIndex en paramètre. Si l'index en paramètre est considéré comme orphelin dans l'arbre, alors il convient de renvoyer un index invalide. Dans notre cas, un index racine est un index correspondant à un TreeItem dont le père est rootData. rootData est le seul élément dans tout l'arbre des TreeItem qui n'a aucun QModelIndex correspondant, il sert juste de père ultime référent et donc, les vrais éléments racines de l'arbre affichés à l'écran sont ses enfants directs. parent() est également une méthode publique. Rien que pour cette raison il est important de tester le cas où l'index en paramètre est invalide. Le père d'un index invalide est donc naturellement ... un index invalide, créé par le biais du constructeur vide du QModelIndex.

TreeItem *childItem = static_cast<TreeItem*>(index.internalPointer());
    TreeItem *parentItem = childItem->parent();

internalPointer est une variable qui fait le lien entre l'index et la donnée. Chaque index du modèle sera donc lié à un TreeItem cette liaison est faite à deux endroits du modèle; la méthode index() et la méthode parent(). La première instruction se contente donc de récupérer le TreeItem associé à l'index, en passant par un static_cast, plus rapide qu'un dynamic_cast et suffisant ici. Il existe un autre moyen de lier une donnée à un index, internalId qui est une autre propriété du QModelIndex, utile dans les cas où la donnée serait accessible via un identificateur entier et non un pointeur. (on peut imaginer une clef entière dans une base de donnée, par exemple). La seconde instruction stocke le père du TreeItem prédédemment extrait dans parentItem.

if (parentItem == rootItem)
        return QModelIndex();

Nous avons vu que chaque TreeItem pointe vers son père, si l'élément père est rootItem, alors on considère du point de vue du modèle que l'index correspondant est un noeud racine et donc, on renvoie un QModelIndex invalide.

return createIndex(parentItem->row(), 0, parentItem);
}

createIndex()[2] est une méthode qui permet de créer en une instruction un QModelIndex attaché au modèle.

La méthode row() du TreeItem renvoie le numéro de ligne de l'élément au sein de ses frères et c'est exactement ce que createIndex attend comme premier paramètre. 0 est passé comme numéro de colonne car le père d'un index est par convention toujours de colonne 0 dans un système hiérarchique conventionnel. Et enfin, parentItem est passé en tant que comme pointeur interne, c'est un des deux endroits dans le code où l'on associe un TreeItem à son QModelIndex.

La méthode index()
QModelIndex TreeModel::index(int row, int column, const QModelIndex &parent) const
{
    if (!hasIndex(row, column, parent))
        return QModelIndex();

La méthode index() est publique. Il convient donc de tester ses paramètres afin de résoudre les cas invalides. En particulier, nous aimerions savoir si les paramètres row et column ne sortent pas des limites du modèle et heureusement, pour le savoir, il existe la fonction hasIndex() qui nous renseigne à ce sujet. La documentation étant peu locace pour cette fonction, c'est dans les sources de Qt que nous allons trouver son rôle précis; cette méthode renvoie vrai si et seulement si les paramètres row et column sont compris entre 0 et le nombre maximum de lignes et de colonnes du parent passé en paramètre.

TreeItem *parentItem;
 
    if (!parent.isValid())
        parentItem = rootItem;
    else
        parentItem = static_cast<TreeItem*>(parent.internalPointer());

Ici nous avons besoin de récupérer l'élément de type TreeItem correspondant à l'index que nous allons renvoyer. Nous savons que si le paramètre parent est valide, alors il aura été créé avec parent() et possèdera donc en pointeur interne sur son TreeItem "jumeau" et, dans le cas où parent est invalide, c'est rootItem qui jouera ce rôle.

TreeItem *childItem = parentItem->child(row);
    if (childItem)
        return createIndex(row, column, childItem);
    else
        return QModelIndex();
}

Maintenant que nous avons l'élément père ainsi que le numéro de ligne du fils, il est trivial d'en déduire l'élément fils avec la méthode child(). Par prudence, le retour de cette méthode est testé et createIndex() est à nouveau invoqué puis retourné par la fonction.

La méthode data()
QVariant TreeModel::data(const QModelIndex &index, int role) const
{
    if (!index.isValid())
        return QVariant();

Nous retrouvons le même début de scénario que pour les deux méthodes précédemment analysées, data() étant public, la validité de index est éprouvée et un QVariant() invalide est envoyé le cas échéant.

if (role != Qt::DisplayRole)
        return QVariant();

Dans cette démo, la vue ne sollicitera que le texte pour son affichage, et donc, tous les rôles autre que Qt::DisplayRole sont écartés. Pour se faire, un QVariant() invalide est renvoyé et signifie à l'appelant qu'il fait ce qu'il veut: pour un QTreeView, ça sera par exemple d'afficher la couleur de fond par défaut du composant dans le cas du Qt::BackgroundRole.

TreeItem *item = static_cast<TreeItem*>(index.internalPointer());
 
    return item->data(index.column());
}

Nous avons besoin de retrouver l'élément correspondant à l'index, puis de renvoyer la donnée correspondant à son numéro de colonne. La méthode data() renvoie un QVariant et nous voyons ici l'utilité sémantique d'avoir stocké une liste de QVariant au sein du TreeItem plutôt qu'une liste de QString, même si en l'occurrence, le renvoi d'un QString aurait été possible grace à la construction implicite du C++.

La méthode rowCount()
int TreeModel::rowCount(const QModelIndex &parent) const
{
    TreeItem *parentItem;
    if (parent.column() > 0)
        return 0;

Nous avons vu plus haut que le père d'un index a par convention son numéro de colonne égal à 0 dans un cas de hiérarchie classique. De la même façon, tout index de colonne strictement supérieur à 0 ne possède aucun fils; ce rôle est dévolu à l'index de colonne 0.

if (!parent.isValid())
        parentItem = rootItem;
    else
        parentItem = static_cast<TreeItem*>(parent.internalPointer());
 
    return parentItem->childCount();
}

Comme pour la fonction data(), il nous faut extraire le TreeItem correspondant à l'index parent. L'appel de rowCount() avec un index invalide signifie que l'utilisateur souhaite le nombre de noeuds racines, dans ce cas, rootItem jouera le rôle de l'élément père. Le nombre d'enfants de l'élément père est alors renvoyé.

La méthode columnCount()
int TreeModel::columnCount(const QModelIndex &parent) const
{
    if (parent.isValid())
        return static_cast<TreeItem*>(parent.internalPointer())->columnCount();
    else
        return rootItem->columnCount();
}

Elle est similaire à la méthode rowCount() si ce n'est une difficulté en moins puisque que l'on peut se contenter de renvoyer directement le nombre de colonnes de l'élément attaché à l'index.

Les méthodes flags() et headerData()
Qt::ItemFlags TreeModel::flags(const QModelIndex &index) const
{
    if (!index.isValid())
        return 0;
 
    return Qt::ItemIsEnabled | Qt::ItemIsSelectable;
}

C'est dans cette fonction que nous allons définir finement le comportement du modèle. Nous lui précisons ici que nous souhaitons pouvoir intéragir avec l'index en paramètre (Qt::ItemIsEnabled), ainsi que le sélectionner (Qt::ItemIsSelectable).

QVariant TreeModel::headerData(int section, Qt::Orientation orientation,
                               int role) const
{
    if (orientation == Qt::Horizontal && role == Qt::DisplayRole)
        return rootItem->data(section);
 
    return QVariant();
}

headerData() est similaire à la fonction data() si ce n'est qu'elle concerne uniquement les entêtes des lignes et colonnes. Pour notre exemple, seule l'entête des colonnes est affichée et nous utilisons donc à cette fin les valeurs de rootItem uniquement dans le cas où l'orientation est Qt::Horizontal.

Conclusion

Pour des raisons de simplicité, j'ai jugé utile de limiter ce tutoriel à l'analyse d'un exemple simple et en lecture seule et de nombreux aspects de la manipulation des modèles/vue n'y sont pas abordés. Par exemple, lorsqu'on veut modifier les données internes, comment avertir le modèle -et donc la vue- que les données ont changées ? Comment rendre le modèle éditable; doit-on utiliser les méthodes d'insertions et de suppression du modèle ou un système alternatif ? L'édition des données est trop basique, je voudrais la changer, comment faire ? Comment construire un système de recherche au sein d'un modèle ? Comment gérer les types Mime ainsi que le drag'n'drop ? Ces questions seront abordées dans un tutoriel plus avancé.

Notes

[1] Si les valeurs renvoyées par row(), column() et model() appartiennent bien en interne au QModelIndex par le biais de champs privés, le champ parent() est en revanche une invocation directe de la méthode virtuelle pure parent() du modèle de l'index. Ceci peut mettre en lumière le rôle de la fonction parent() du modèle et son implication.

[2] Il existe un constructeur du QModelIndex qui accepte à peu près les mêmes paramètres que createIndex() et qui aurait pu prendre sa place mais pour des raisons d'intégrité et de cohérence (pour ne jamais avoir d'index sans modèle référent), ce constructeur est privé; le seul moyen de créer un index valide est de passer par le modèle et donc par une des déclinaisons de createIndex().