[article paru dans LinuxMagazine n° 39 d'avril 2002, légèrement adapté]

Signaux et slots personnalisés

Ce mois-ci, nous allons découvrir comment créer noswidgetsà nous, avec des signaux et des slots personnalisés. Ce sera l'occasion de découvrir comment récupérer les saisies de l'utilisateur, ainsi qu'une première méthode pour organiser graphiquement les différents éléments de l'interface.

Les widgets en grille

Dans les articles précédents, ou lors de vos expérimentations, peut-être avez-vous été rapidement lassés par les appels successifs aux méthodesresize() et move() pour obtenir la présentation que vous vouliez. Qt propose heureusement des outils sophistiqués pour organiser vos interfaces. Le premier que nous verrons est la classe QGrid, qui dérive de QFrame (elle-même dérivant de QWidget).

Son principe de fonctionnement est extrèmement simple. Déclarez une instance de QGrid, en indiquant combien vous voulez de lignes ou de colonnes. Par la suite, donnez l'adresse de cette instance comme widget parent à tous les widgets que vous créez : ceux-ci seront alors placés dans la grille, positionnés automatiquement.

Voyons cela sur un exemple.

Naissance d'un convertisseur Euro

Je vous propose la création d'un outil sans doute inutile pour tous les champions du calcul mental : un petit convertisseur euro. Celui-ci sera constitué de trois champs de saisies, un pour l'euro, un pour le franc, et par exemple, un pour le mark (mais rien ne vous empêche de rajouter ce qui manque). Chaque champ de saisie sera accompagné d'un label indiquant la monnaie qu'il représente. Notre but est ici d'obtenir quelque chose comme cela :

euro

Naturellement, nous pourrions y parvenir à coups de resize() et move().

Afin d'obtenir un composant réutilisable, nous allons en faire un widget à part entière. Pour cela, le plus simple est généralement de dériver la classe QWidget elle-même, mais on préfère souvent utiliser la classe QFrame, qui offre la possibilité d'ajouter un cadre décoratif.

Voici une ébauche de définition de notre composant :

01: #include <qframe.h>
02: #include <qlineedit.h>
03: #include <qgrid.h>
04: #include <qlabel.h>
05: class wConvEuro : public QFrame
06: { public:
07:     wConvEuro (QWidget * parent = 0, const char* name = 0) :
08:       QFrame(parent, name)
09:     { QGrid * grid = new QGrid (2, Qt::Horizontal, this) ;
10:       grid->resize(200, 100) ;
11:       new QLabel("  Euro", grid) ;
12:       leEuro = new QLineEdit("0.0", grid) ;
13:       new QLabel("  Franc", grid) ;
14:       leFranc = new QLineEdit("0.0", grid) ;
15:       new QLabel("  Mark", grid) ;
16:       leMark = new QLineEdit("0.0", grid) ;
17:     }
18:   private:
19:     QLineEdit* leEuro ;
20:     QLineEdit* leFranc ;
21:     QLineEdit* leMark ;
22: } ;

Dans le détail :

Arrêtons-nous un instant sur la classe QLineEdit, dont nous créons une instance ligne 12. Il s'agit de la classe qui fournit le champ de saisie simple (une seule ligne). Le premier paramètre est la valeur initiale devant être affichée par défaut, le deuxième est le widget parent. Vous pouvez ne pas donner le premier paramètre : dans ce cas, le champs de saisie est initialement vide.

Les champs de saisie ne manipulent que des chaînes de caractères. Heureusement, le C++ permet de s'affranchir des désagréments habituels liés au fameux char*. Qt propose notamment une classe QString, qui comporte une foule de méthodes intéressantes. Ce sont des instances de cette classe que nous allons manipuler tout au long de notre programme. Reamarquez que, lors de la création du champ de saisie, nous donnons la valeur initiale sous forme d'une chaîne littérale traditionnelle : celle-ci est automagiquement convertie en QString, car QString possède un constructeur acceptant un char*, définissant ainsi une conversion implicite.

Parmis toutes les méthodes de QLineEdit, les plus utilisées sont sans doute :

Le reste du constructeur n'est que la création des autres labels et champs de saisis. Comme nous voudrons obtenir et modifier le contenu des champs, nous conservons les pointeurs sur les diverses instances dans des données privées (lignes 19 à 21).

Utilisation de notre widget

Saisissez le code précédent dans un fichier conv_euro.h (code). Pour les puristes d'entre nous qui tiennent à séparer l'entête de l'implémentation, aucun problème : ne placer dans le .h que les prototypes et déclarations, et les corps des méthodes dans un fichierconv_euro.cpp. Créons rapidement un main pour tester le widget, par exemple dans main.cpp(code) :

1: #include <qapplication.h>
2: #include "conv_euro.h"
3: int main (int argc, char* argv[])
4: { QApplication app(argc, argv) ;
5:   wConvEuro conv(&vbox) ;
6:   conv.resize(220, 100) ;
7:   app.setMainWidget(&conv) ;
8:   conv.show() ;
9:   return app.exec() ;
10: }

Rien de particuliers ici, tout cela est en fait du déjà vu. Pour compiler notre programme :

$ g++ -o conv_euro.x -lqt -L$QTDIR/lib -I$QTDIR/include conv_euro.cpp main.cpp

Si vous avez placé l'implémentation dans le fichier d'en-tête, n'indiquez évidemment pas conv_euro.cpp dans la commande précédente.

Nos signaux et nos slots

Tout cela est bien beau, mais maintenant que notre convertisseur s'affiche, il faut le faire fonctionner - c'est-à-dire, convertir. Nous allons pour cela récupérer les signaux émis par les différents champs de saisie, récupérer la valeur numérique à partir du texte qu'ils contiennent, et mettre à jour les autres champs après conversion. Et pour bien faire, il serait sympathique que notre widget émette un signal dès que la valeur en euro est modifiée (n'oublions pas qu'il est destiné à être intégré à quelque chose de plus vaste).

Afin de pouvoir utiliser dans notre widget les signaux et slots de Qt, il est nécessaire d'insérer le mot Q_OBJECT (une macro propre à Qt) tout au début de la déclarion du widget. Celle-ci devient alors :

5: class wConvEuro : public QFrame
6: { Q_OBJECT
7:   public:
8:     wConvEuro (QWidget * parent = 0, const char* name = 0) ;
9:   ...etc...

La déclaration d'un signal est très simple. Ajoutez dans la classe une section nommée signals:, à la manière des sections public:, protected: et private: du C++, dans laquelle vous listez les prototypes des signaux qui seront émis. Dans notre cas, nous voulons un signal qui emmène la nouvelle valeur en euro, la déclaration pourrait donc être :

   signals:
     void NewValue (double euro) ;

Le type de retour d'un prototype de signal est toujours void. Autre caractéristique des signaux : vous ne leur donner aucun corps. Ce qui précède a toutes les apparences d'un prototype de fonction, mais en aucun cas vous ne lui associer de code. Qt se charge des détails, comme nous le verrons bientôt.

Voyons maintenant comment synchroniser les différents champs.

Pour commencer, ajoutons à notre widget une méthode publique permettant de fixer la valeur en euro, et qui met à jour les autres champs. Voici quel pourrait être son texte :

void wConvEuro::SetEuro(double euro)
{ leEuro->setText( QString::number(euro, 'f', 5) ) ;
  leFranc->setText( QString::number(euro*6.55957, 'f', 5) ) ;
  leMark->setText( QString::number(euro*1.95583, 'f', 5) ) ;
  emit NewValue(euro) ;
}

Nous obtenons une valeur réelle en paramètre, mais les champs de saisie ne manipulent que des chaînes de caractères... La valeur est convertie en chaîne par un appel à une méthode statique de la classe QString, la méthode number(). Il existe un exemplaire de cette méthode pour tous les types numériques (sauf les long long, pas encore vraiment standardisés). Dans le cas des types entiers, il est possible de donner un deuxième paramètre indiquant dans quelle base on veut le résultat (entre 2 et 36). Ainsi, pour obtenir la représentation du nombre 2002 en base 7, il suffit d'appeller QString::number(2002, 7) (ce qui donne "5560", vous pouvez vérifier). Dans le cas des types réels, vous pouvez indiquer :

Donc, à partir d'une valeur, cette fonction fixe le contenu de tous les champs. Sa dernière action est d'émettre le signal que nous avons déclaré précédemment, par la macro emit : ce signal pourra éventuellement être intercepté par d'autres widgets.

Justement, il pourrait être intéressant de « brancher » les signaux d'autres widget sur le nôtre, pour modifier la valeur convertie... Autrement dit, il serait intéressant de faire un slot de la fonction précédente. Pour cela, il suffit de donner sa déclaration dans une section public slots: de notre widget. La déclaration (épurée du corps des méthodes) de notre widget ressemble désormais à ceci :

01: class wConvEuro : public QFrame
02: { Q_OBJECT
03:   public:
04:     wConvEuro (QWidget * parent = 0, const char* name = 0) ;
05:   public slots:
06:     void SetEuro(double euro) ;
07:   signals:
08:     void NewValue(double val) ;
09:   private:
10:     QLineEdit* leEuro ;
11:     QLineEdit* leFranc ;
12:     QLineEdit* leMark ;
13: } ;

L'ordre des sections n'est pas vraiment important. Notez que nous avons déclaré ici un slot publique : il est également possible de déclarer des slots protégés (protected) ou privés (private). Les droits d'accès usuels du C++ s'appliquent alors, comme pour toute méthode. Pour information, les pseudo-méthodes de la section signals: sont privées.

Tout cela commence à prendre forme. Ce n'est toutefois pas suffisant : les champs de saisie émettent bien un signal lorsque leur contenu change, mais celui-ci transporte une chaîne de caractères et non un nombre réel. Nous devons donc déclarer un slot « interne » pour chacun de nos champs, qui réagira lorsque le contenu sera modifié (en fait, on pourrait faire avec un seul slot, mais c'est moins propre). Chacun de ces slots fera partie intégrante du widget, et a priori ne devrait par être visible de l'extérieur : ils seront donc déclarés dans une section private slots:. Voici par exemple le code du slot qui réagira au changement de la valeur en francs :

void wConvEuro::FrancChanged(const QString& text)
{ SetEuro(text.toDouble() / 6.55957) ; }

Son rôle se limite à appeller la méthode SetEuro() définie plus haut, avec la valeur convertie. Remarquez l'utilisation de la méthode QString::toDouble(), qui donne la valeur réelle correspondant à la chaîne de caractères (l'équivalent de strtod()). QString possède également les méthodes de conversion toShort() (conversion vers short), toInt() (conversion vers int), toLong() (devinez...) et toFloat(). Pour les types entiers, une méthode supplémentaire est fournie avec un 'U' devant le type cible pour une conversion non signée (par exemple, toULong() pour une conversion vers unsigned long).

Pour que ce slot soit appellé, nous devons le connecter au signal émis par le champs de saisie correspondant. Cela est fait dans le constructeur, avec cette simple instruction :

connect (leFranc, SIGNAL(textChanged(const QString&)), this, SLOT(FrancChanged(const QString&))) ;

Une instruction de ce genre pour chacun des champs de saisie. Si vous avez tout inscrit dans un unique fichier conv_euro.h, ce fichier devrait ressembler à ceci (code) :

01: #include <qframe.h>
02: #include <qlineedit.h>
03: #include <qgrid.h>
04: #include <qlabel.h>
05: class wConvEuro : public QFrame
06: { Q_OBJECT
07:   public:
08:     wConvEuro (QWidget * parent = 0, const char* name = 0)
09:         : QFrame(parent, name)
10:     {
11:       QGrid * grid = new QGrid (2, Qt::Horizontal, this) ;
12:       grid->resize(200, 100) ;
13:       new QLabel("  Euro", grid) ;
14:       leEuro = new QLineEdit("0.0", grid) ;
15:       new QLabel("  Franc", grid) ;
16:       leFranc = new QLineEdit("0.0", grid) ;
17:       new QLabel("  Mark", grid) ;
18:       leMark = new QLineEdit("0.0", grid) ;
19: 
20:       connect (leEuro, SIGNAL(textChanged(const QString&)),
21:                this,   SLOT(EuroChanged(const QString&))) ;
22:       connect (leFranc, SIGNAL(textChanged(const QString&)),
23:                this,    SLOT(FrancChanged(const QString&))) ;
24:       connect (leMark, SIGNAL(textChanged(const QString&)),
25:                this,   SLOT(MarkChanged(const QString&))) ;
26:     }
27: 
28:   public slots:
29:     void SetEuro(double euro)
30:     {   
31:         leEuro->setText(QString::number(euro, 'f', 5)) ;
32:         leFranc->setText(QString::number(euro*6.55957, 'f', 5)) ;
33:         leMark->setText(QString::number(euro*1.95583, 'f', 5)) ;
34:         emit NewValue(euro) ;
35:     }
36: 
37:   signals:
38:     void NewValue(double val) ;
39: 
40:   private slots:
41:     void EuroChanged(const QString& text)
42:     { SetEuro(text.toDouble()) ; }
43:     void FrancChanged(const QString& text)
44:     { SetEuro(text.toDouble() / 6.55957) ; }
45:     void MarkChanged(const QString& text)
46:     { SetEuro(text.toDouble() / 1.95583) ; }
47: 
48:   private:
49:     QLineEdit* leEuro ;
50:     QLineEdit* leFranc ;
51:     QLineEdit* leMark ;
52: } ; 

La compilation

Voici la partie un peu délicate. Le code que nous avons écrit n'est pas directement utilisable en l'état. Si vous essayer de compiler ceci, vous avez toutes les chances d'obtenir un torrent d'injures de la part du compilateur. La déclaration de notre widget (donc, le contenu du fichier d'en-tête conv_euro.h) doit être pré-traitée, pré-processé, par un outil propre à Qt, le moc (pour meta-object compiler). Le résultat de ce traitement est un nouveau fichier, qui devra être compilé et lié avec les autres fichiers sources. Dans notre cas, la commande a exécuter est par exemple :

$ moc conv_euro.h -o moc_conv_euro.cpp

Sans l'option -o, le résultat est envoyé sur la sortie standard. Ceci créé un nouveau fichier, nommé moc_conv_euro.cpp. L'opération doit être renouvelée à chaque modification dans la déclaration du widget, dès lors que cela concerne les signaux ou les slots qu'il renferme. Notez que ceci est valable, que le code des méthodes soit dans le .h ou dans un fichier séparé. La compilation du programme se fait alors par :

$ g++ -o conv_euro.x -lqt -L$QTDIR/lib -I$QTDIR/include moc_conv_euro.cpp conv_euro.cpp main.cpp

Compilez, exécutez... Voilà un convertisseur euro ! Si vous voulez rajouter un bouton « quitter » à votre programme, et pourquoi pas un affichage LCD de la valeur en euro, il suffit de modifier la fonction main(). Je vous laisse étudier ceci (code) :

01: #include <qapplication.h>
02: #include <qvbox.h>
03: #include <qlcdnumber.h>
04: #include <qpushbutton.h>
05: #include "conv_euro.h"
06: int main (int argc, char* argv[])
07: { QApplication app(argc, argv) ;
08:   QVBox vbox ;
09:   vbox.resize(220, 220) ;
10:   wConvEuro conv(&vbox) ;
11:   conv.resize(220, 100) ;
12:   QLCDNumber lcd(10, &vbox) ;
13:   app.connect (&conv, SIGNAL(NewValue(double)),
14:                &lcd,  SLOT(display(double))) ;
15:   QPushButton bt_quit("Quitter", &vbox) ;
16:   app.connect (&bt_quit, SIGNAL(clicked()),
17:                &app,     SLOT(quit())) ;
18:   app.setMainWidget(&vbox) ;
19:   vbox.show() ;
20:   return app.exec() ;
21: }

La classe QVBox utilisée ici est en fait une spécialisation de QGrid que nous avons utilisé, qui dispose simplement les widgets en colonne, un par ligne.

Remarquez comme nous récupérons le signal que nous avons défini, pour le brancher sur le slot approprié de l'afficheur LCD (que nous avons déjà rencontré le mois dernier), lignes 13 et 14.

L'erreur

En principe, après quelques manipulations rapides, vous devriez constater un gel du programme, voire une erreur de segmentation. Que ce passe-t-il ?

C'est en fait extrèmement simple. Lorsque le contenu de l'un des champs de saisie est modifié, il y a émission d'un signal (textChanged()). Ce signal est reçu par le slot correspondant, lequel slot appel la méthode SetEuro(), laquelle méthode va modifier le contenu des champs de saisie, ce qui provoque l'émission... Vous voyez le problème : malgré les précautions et gardes-fous présents dans Qt, il arrive d'un signal « boucle », provoquant un cycle d'émission/réception sans fin.

En fait, le principe de base est de toujours prendre garde, lors de l'écriture du code d'un slot, aux signaux qui pourraient être émis par ce code. Si dans la plupart des cas, ces émissions ne portent pas à conséquence, il arrive que l'on provoque ce genre de cycle. Une solution consiterait à n'exécuter le code du slot qu'après s'être assuré qu'il y effectivement quelque chose à fire, donc de garder la mémoire des mises à jour précédentes. Dans notre exemple, je vous propose une autre solution, consistant tout simplement, au début de la méthode SetEuro(), à déconnecter les signaux des slots « internes », puis à les reconnecter à la fin. Le code de cette méthode devient donc :

01: void SetEuro(double euro)
02: {
03:     disconnect (leEuro, SIGNAL(textChanged(const QString&)),
04:                 this,   SLOT(EuroChanged(const QString&))) ;
05:     disconnect (leFranc, SIGNAL(textChanged(const QString&)),
06:                 this,    SLOT(FrancChanged(const QString&))) ;
07:     disconnect (leMark, SIGNAL(textChanged(const QString&)),
08:                 this,   SLOT(MarkChanged(const QString&))) ;
09:     leEuro->setText(QString::number(euro, 'f', 5)) ;
10:     leFranc->setText(QString::number(euro*6.55957, 'f', 5)) ;
11:     leMark->setText(QString::number(euro*1.95583, 'f', 5)) ;
12:     emit NewValue(euro) ;
13:     connect (leEuro, SIGNAL(textChanged(const QString&)),
14:              this,   SLOT(EuroChanged(const QString&))) ;
15:     connect (leFranc, SIGNAL(textChanged(const QString&)),
16:             this,    SLOT(FrancChanged(const QString&))) ;
17:     connect (leMark, SIGNAL(textChanged(const QString&)),
18:             this,   SLOT(MarkChanged(const QString&))) ;
19: }

Et pensez à ériger une statut à l'inventeur du copier-coller !

Conclusion

Vous voilà désormais bien armé pour créer vos propres widgets. Le mois prochain, nous verrons un autre outil d'organisation des widgets, légèrement plus délicat à manipuler mais aussi plus puissant. Ce qui nous permettra de nous débarasser des derniers resize() encore présents dans l'exemple d'aujourd'hui.