[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.
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.
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 :
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 :
wConvEuro
, par une dérivation publique
de QFrame
: ainsi nous bénéficions de toutes les
possibilités des frames de Qt ;QFrame
. N'oubliez pas d'appeler le
constructeur de la classe ancêtre !this
en dernier paramètre (qui est celui du widget
parent). Les deux premiers paramètres indiquent l'organisation que nous
souhaitons : ici, nous voulons que les widgets dépendant de la
grille soit placés par deux (2
) horizontalement
(Qt::Horizontal
), c'est-à-dire que notre grille comporte
deux colonnes, et un nombre de lignes adaptés au nombre de widgets
enfants. Si nous avions choisi une organisation verticale
(Qt::Vertical
), la valeur aurait indiqué le nombre de
lignes. La grille est dimensionnée manuellement, ligne 10. Remarquez le
préfixe Qt::
: en lieu et place d'un espace de nommage
(namespace
), la librairie définie une classe nommée
Qt
, qui joue un peu le même rôle, et qui contient de
nombreuses énumérations. Presques toutes les autres classes dérivent de
celle-ci.move()
! Les widgets sont placés dans la grille
dans l'ordre où ils sont créés, de gauche à droite, de bas en haut,
et deux par ligne (conformément au paramétrage de l'instance de
QGrid
créée deux lignes plus haut).new
est
libérée par Qt elle-même, qui pose comme principe que tous les
enfants d'un widget sont détruits lorsque celui-ci est
détruit. Donc, lorsque notre widget sera détruit, la
frame sera détruite, ce qui provoquera la destruction de la
grille, ce qui provoquera la destruction de ce que contient la
grille. Et justement, la grille est marquée widget parent du
label ! Donc il n'y a pas de fuite de mémoire.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 :
QString text() const
retourne le contenu actuel du champ,
sous la forme d'une chaîne de caractères (donc d'une instance de
QString
) ;void setText(const QString &)
fixe le texte contenu
dans le champs (dont une partie peut être masquée, si ce texte est trop
long) ; il s'agit en réalité d'un slot ;void textChanged(const QString &)
est le signal émis
dès que le texte a été modifié, que cela soit par l'utilisateur ou par un
appel à setText()
;void returnPressed()
est le signal émis lorsque
l'utilisateur presse la touche Entrée, validant ainsi sa saisie.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).
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.
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 :
e
',
'E
', 'f
', 'g
' ou 'G
',
dont la signification est la même que pour printf()
('g
' par défaut) ;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: } ;
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.
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 !
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.