6.2.3 Développement d’une interface utilisateur graphique

Toute calculatrice se compose d’au moins un écran d’affichage, de dix touches numériques et d’un nombre plus ou moins grand de touches fonctionnelles. Cet ensemble d’écrans et de touches doit être integré visuellement dans un cadre et fonctionnellement de manière telle que chaque activation d’une touche fait changer l’affichage.

La figure 6.16 donne l’image d’une calculatrice postfixe pour les quatre opérations arithmétiques élémentaires. Cette calculatrice est dans une fenêtre qui a la même allure externe que les fenêtres standard de SQUEAK: une bande en haut affiche des icônes pour détruire la fenêtre, pour avoir un menu, pour changer sa taille et pour iconifier la fenêtre; au milieu de cette bande est affiché le nom de la fenêtre.

Une telle fenêtre est une instance de la classe SystemWindow. La classe SystemWindow est une sous-classe de Morph10. Elle peut donc, comme tout autre morph, posseder des sous-morphs, comme des touches ou des écrans.

En haut de notre calculatrice se situe, sur toute la largeur un grand écran destiné à afficher les nombres qu’on entre et les résultats des calculs. À la droite de la calculatrice se situe un deuxième écran qui affiche l’historique des opérations effectuées et des résultats obtenus. Ces deux fenêtres sont des instances de la classe PluggableTextMorph, des morphs prévus pour afficher (et éventuellement éditer) du texte.

Le touches sont, naturellement, des instances de la classe PluggableButtonMorph dont nous venons voir le fonctionnement à travers nos boutons de téléviseur de la section précédente.


PIC
FIG. 6.16: Notre première calculatrice

Revenons un instant sur les PluggableTextMorphs. Un tel morph doit connaître au minimum son modèle, la classe, qui lui livre le texte à afficher, et le sélecteur de la méthode permettant d’importer ce texte (cela doit être une méthode qui retourne une chaîne de caractères). Souvent on lui donne également le sélecteur de la méthode permettant de modifier le texte. Les deux sélecteurs doivent réferer à des méthodes de la classe modèle, puisque le modèle est propriétaire du texte et possède des méthodes d’accès et de modification de ce texte.

Une des méthodes de classe pour la création d’un PluggableTextMorph est on:text:accept: qui crée un tel morph sur la classe donnée comme argument à on:, avec le sélecteur d’accès au texte donné en argument à text: et le sélecteur de modification de texte donné en argument à accept:. Ainsi, le PluggableTextMorph que nous créons pour l’écran principal de notre calculatrice est créé par la transmission:

PluggableTextMorph  
    on: self  
    text: #printString  
    accept: nil.
self désigne l’instance de la calulatrice pour laquelle nous construisons une vue, le sélecteur printString retourne la chaîne de caractères bufferEcran, puisque nous avons redéfini la méthode printOn: comme ci-dessous:
Calculatrice>>printOn: aStream  
    aStream nextPutAll: bufferEcran
et, puisque l’écran de toute calculatrice n’existe que pour afficher du texte et jamais pour changer de texte, le sélecteur de modification de texte, l’argument de accept:, est initialisé à la valeur indéfinie.

Pour la fenêtre de l’historique du calcul, le deuxième PluggableTextMorph de notre calculatrice, nous utilisons la méthode de création:

on:text:accept:readSelection:menu:
Les premiers trois arguments sont du même type: un objet responsable du texte et possédant des méthodes pour livrer ce texte au PluggableTextMorph et, éventuellement, une méthode pour receveoir du texte de ce morph. L’argument readSelection: attend une instance de la classe Interval comprenant les indices des caractères qui seront sélectionnées lors de l’affichage du texte. Par la suite nous allons sélectionner toujours le caractère suivant le dernier caractère du texte affiché. De cette manière nous garantissons que la fenêtre rendra le dernier texte ajouté toujours visible11. Finalement, l’argument menu: attend un menu contextuel.

Commençons par créer en plus des classes Touche, Calculatrice et CalculatricePostfixe une classe Ecran représentative de l’écran historique de notre calculatrice. Elle possède une variable d’instance, hist, qui accumule l’ensemble des interactions entre l’utilisateur et la calculatrice:

Model subclass: #Ecran  
   instanceVariableNames: ’hist’  
   classVariableNames: ’’  
   poolDictionaries: ’’  
   category: ’Cours-Smalltalk’
Les deux méthodes d’accès et de modification de la variable d’instance nous serviront également d’interface avec le morph responsable d’afficher l’écran:
Ecran>>hist  
   ^ hist
et
Ecran>>hist: aString  
    hist := aString.
Pour imprimer une instance d’écran, il suffit d’afficher le contenu de la variable d’instance hist:
Ecran>>printOn: aStream  
   hist printOn: aStream
Après ces travaux préparatoires, commençons à mettre en place les vues pour chacune des parties de notres calculatrice. Les vues seront construites par la méthode openInWorld dont voici le début de définition:
Calculatrice>>openInWorld  
1    | window view c l couleur a1 a2 a3 |  
2    window := SystemWindow labelled: self class name.  
3    a1 := AlignmentMorph newRow.  
4    a2 := AlignmentMorph newRow.  
5    a3 := AlignmentMorph newRow.
Pour nous simplifier le travail nous commençons par déclarer quelques variables temporaires:
window contiendra le morph principal de la calculatrice. Tous les autres morphs que nous utiliserons seront des sous-morphs de window. Dans la ligne 2 nous initialisons cette variable à une instance de SystemWindow avec le nom de la classe comme en-tête de la fenêtre. Au début, le nom sera CalculatricePostfixe, puisque nous créons la calculatrice par la transmission
CalculatricePostfixe new openInWorld
view sera utilisé pour garder temporairement des morphs en constructions.
c et l sont des variables temporaires qui nous aideront à savoir dans quelle colonne et dans quelle ligne d’un tableau de morphs nous nous trouvons à un instant donné.
couleur nous sert juste à garder une fois pour toutes la couleur des fonds pour nos sous-morphs.
a1, a2 et a3 garderont temporairement des AlignmentMorphs comme montré dans les lignes 3 à 5 de la méthode openInWorld ci-dessus.

Examinons la suite de notre méthode:

6    view := PluggableTextMorph  
7             on: self  
8             text: #printString  
9             accept: nil.  
10   view  
11      font: (StrikeFont familyName: ’Atlanta’ size: 22).
Dans les lignes 6 à 11 nous créons la fenêtre d’affichage de la calculatrice: elle est une instance de la classe PluggableTextMorph possédant comme modèle l’instance de la calculatrice que nous sommes en train de créer. Elle affiche le texte que la méthode printString retourne quand elle est transmise au modèle et elle exclut de changer le texte du modèle (l’argument accept: est indéfini, nil).

Ce PluggableTextMorph affiche son texte en caractères relativement grands, taille 22 pixels, dans le jeux de caractères Atlanta. Tout morph pouvant afficher du texte possède l’attribut font: déterminant le jeu de caractères utilisé pour l’afficher.12

Voici les trois lignes suivantes de la méthode Caculatrice»openInWorld:

12   window  
13      addMorph: view  
14      frame: (0 @ 0 extent: 1 @ (1 / 5)).
Avec la méthode addMorph:frame: nous ajoutons la vue juste que nous venons de construire, l’écran de la calculatrice, au morph principale de notre calculatrice. Le rectangle le représentant commence au point supérieur gauche (le point 0@0) et s’étendra horizontalement sur toute la largeur et prendra verticalement 1 5-ième de la fenêtre principale.

Pour placer des sous-morphs à des endroits précis d’un morph, soit on utilise des AlignmentMorphs, et le placement se fait proportionnellement avec la méthode addMorph:, soit on donne, avec la méthode addMorph:frame: le placement avec un rectangle dont tous les points sont donnés en mesures relatives par rapport à la taille du morph receveur. Le rectangle:

0 @ 0 extent: 1 @ (1 / 5)
peut se lire comme:
0 × largeur-morph-receveur @ (0 × hauteur-morph-receveur)
extent: 1 × largeur-morph-receveur @ (1 / 5 × hauteur-morph-receveur )
L’écran occupe le haut de la calculatrice sur un cinquième de la hauteur.

Ci-dessous alors la partie se chargeant de mettre en place l’écran historique de notre calculatrice. Cet écran, également un PluggableTextMorph, a comme modèle une instance de la classe Ecran, qui lui a comme modèle la calculatrice. Cette dernière dépendance est réalisée lors de sa création dans la méthode on: de la classe Ecran que voici:

Ecran class>>on: aCalc  
   | aux |  
   aux := Ecran new.  
   aux hist: ’’.  
   aux addDependent: aCalc.  
   ^ aux
Mais revenons vers la suite de notre méthode openInWorld:
15   view := PluggableTextMorph  
16            on: (c := Ecran on: self)  
17            text: #hist  
18            accept: nil  
19            readSelection: #selectTextInterval  
20            menu: #myMenu:.  
21   self addDependent: c.  
22   view setTextColor: Color blue.  
23   window  
24      addMorph: view  
25      frame: (4 / 5 @ (1 / 5) extent: 1 / 5 @ (4 / 5)).
Répétons-le: la vue sur l’écran historique affiche le texte obtenu par le message hist, donc le contenu de la variable d’instance hist et n’accepte, comme l’écran principal de la calculatrice, aucune modification: le texte affiché ne sera accessible qu’en lecture. Pour forcer le morph à dérouler le texte tel que sa fin soit toujours visible dans la fenêtre, la méthode selectTextInterval choisit la fin du texte comme partie du texte à sélectionner, donc aussi à afficher.

Voici cette méthode selectTextInterval:

Ecran>>selectTextInterval  
   ^Interval  
            from:  hist size + 1  
            to: hist size + 1.
L’argument de menu:, dans la création du PluggableTextMorph, est le sélecteur d’une méthode du modèle, donc ici de la classe Ecran, qui retourne le menu à activer si l’on clique le bouton jaune de la souris. Notre méthode myMenu:
Ecran>>myMenu: evt  
   | menu |  
   menu := MenuMorph new.  
   menu addTitle: ’Ecran-Menu’;  
      add: ’inspect’  
      action: #calculatriceInspect.  
   ^ menu
propose un menu avec une seule entrée: inspect. Bien entendu nous voulons inspecter notre calculatrice et non pas le PluggableTextMorph à partir duquel nous pouvons obtenir ce menu. Pour cela, nous donnons la méthode calculatriceInspect que nous devons ajouter à la classe PluggableTextMorph comme suit:
PluggableTextMorph>>calculatriceInspect  
   self model dependents  
     do:[:obj | (obj isKindOf: Calculatrice)  
                  ifTrue:[^obj inspect]]
L’écran, instance de la classe PluggableTextMorph, possède comme modèle une instance de la classe Ecran. Une telle instance possède deux dépendants: le morph qui la représente graphiquement et la calculatrice dont elle est l’écran historique. Dans la méthode ci-dessus la transmission self model retourne donc l’instance de la classe Ecran associée au PluggableTextMorph receveur. Cette instance de la classe Ecran recoit le message dependents qui retourne le tableau de ses dépendants. Ce tableau est ensuite parcouru, avec le message do: jusqu’au rencontre d’un objet qui est une sorte de13 Calculatrice. La méthode isKindOf: de la classe Object répond true si le receveur est une instance de la classe ou d’une des sous-classes de la classe donnée en argument. Ici isKindOf: répond true si le receveur est une instance de Calculatrice ou CalculatricePostfixe. Cette instance reçoit ensuite le message standard inspect.

Les lignes 15 à 20 de notre méthode Calculatrice»openInWorld créent donc cette instance de PluggableTextMorph et la garde temporairement dans la variable view.

La ligne 21 de cette méthode ajoute l’instance nouvellement créée de la classe Ecran aux dépendants de ce la calculatrice. Ainsi la calculatrice connaîtra, elle aussi, son écran historique.

La ligne 22:

view setTextColor: Color bleu
spécifie que tout texte affiché dans le morph view, l’écran historique, doit être écrit en caractères de couleur bleu.

Finalement, les lignes 23 à 25:

23   window  
24      addMorph: view  
25      frame: (4 / 5 @ (1 / 5) extent: 1 / 5 @ (4 / 5)).
ajoutent notre nouveau morph au morph principal de la calculatrice. Comme tout à l’heure, les mesures pour la dimension de ce sous-morph, donc pour son placement à l’intérieur du morph propriétaire, sont exprimées en proportions des mesures du morph propriétaire. L’écran historique occupe le dernier cinquième de la partie droite de la calculatrice, et ceci sur toute la hauteur libre sous l’écran d’affichage des résultats. Une telle calculatrice, composée juste des deux écrans, est montrée dans la figure 6.17.
PIC
FIG. 6.17: Notre calculatrice au stade actuel du programme: un écran d’affichage du résultat en haut et un écran pour l’historique à droite

L’ajout des touches numériques est réalisé dans les lignes 26 à 49 (montrées ci-dessous) de notre méthode openInWorld. Nous devons créer neuf touches, donc neuf instances de la classe PluggableButtonMorph, alignées en trois lignes horizontales, chacune se composant de trois touches. Chacune de ces lignes de touches est formée par un AlignmentMorph sur lequel trois PluggableButtonMorph sont disposés. Chaque ligne de touches forme donc la même structure que notre rangée de boutons de la section précédente – c’est bien naturel: une touche de la calculatrice remplit un rôle sensiblement identique à un bouton de notre téléviseur.

Dans la ligne 26 nous initialisons la variable temporaire l à la valeur 0. Elle nous sert de compteur pour savoir à quel endroit une touche doit être insérée sur son AlignmentMorph: en première, deuxième ou troisième position.

La ligne 27 garde dans la variable temporaire couleur la couleur de fond du fenêtre représentant notre calculatrice. Nous mettrons chacune de nos touches à cette même couleur afin d’avoir une représentation unicolore des diverses touches de notre calculatrice.

26   l := 0.  
27   couleur := window color.  
28   #(#(9 8 7) #(6 5 4) #(3 2 1) )  
29      with: {a1. a2. a3}  
30      do: [:ro :morph |  
31         l := l + 1.  
32         ro  
33            do: [:co |  
34               view := PluggableButtonMorph  
35                        on: (Touche new  
36                              label: co asString  
37                              fonction: #activeNumber:  
38                              on: self)  
39                        getState: #etat  
40                        action: #activer.  
41               view := self  
42                        initalise: view  
43                        with: couleur  
44                        and: co.  
45               morph addMorph: view].  
46         window  
47            addMorph: morph  
48            frame: (0 @ (l * (1 / 5))  
49            extent: 3 / 5 @ (1 / 5))].
L’initialisation de l’ensemble des touches se fait dans deux boucles imbriquées: une boucle extérieure, formée par la transmission du message with:do: allant de la ligne 28 à la ligne 49, et une boucle intérieure, formée par la transmission du message do: et allant de la ligne 32 à la ligne 45.

Le receveur du message with:do: est un tableau composé des trois sous-tableaux correspondant aux étiquettes des touches des trois lignes de touches à construire (ligne 28). L’argument with: est le tableau des trois AlignmentMorph représentant les trois lignes de touches (ligne 29).

La boucle intérieure parcourt les trois étiquettes de touches d’un sous-tableau (lignes 32 et 33) pour créer pour chacune d’elles une instance de PluggableButtonMorph (ligne 34) associée à une nouvelle instance de la classe Touche (ligne 35) ayant l’étiquette actuelle (ligne 36), la fonctionnalité activeNumber: (ligne 37) et elle même associée à la calculatrice que nous visualisons avec cette méthode openInWorld (ligne 38). Le nouveau PluggableButtonMorph trouve l’état de son modèle, donc de sa touche, avec la méthode Touche»etat (ligne 39) et lancera, quand il est activé, la méthode Touche»activer (ligne 40).

Tout PluggableButtonMorph demande la connaissance de la méthode livrant l’état du modèle, soit true, soit false – même s’il ne doit pas distinguer entre ces deux états possibles. C’est pourquoi nous ajoutons à la classe Touche la méthode etat que voici:

Touche>>etat  
   ^ false
Clairement notre touche ne change jamais d’état; ce n’est pas nécessaire. En conséquence: le PluggableButtonMorph associé à une touche affiche toujours la couleur offColor, celle correspondant à l’état false de son modèle.

Revenons vers notre méthode openInWorld: les lignes 41 à 44 contribuent à l’initialisation du PluggableButtonMorph qui vient d’être créé. Voici la méthode initialise:with:and: qui est en charge de ces initialisations supplémentaires:

Calculatrice>>initalise: view with: couleur and: co  
   ^ view offColor: couleur;  
       borderWidth: 1;  
       borderColor: Color black;  
       hResizing: #spaceFill;  
       vResizing: #spaceFill;  
       label: co asString;  
       useRoundedCorners;  
       yourself

FIG. 6.18: Notre calculatrice au stade actuel du programme: deux écrans et neuf touches numériques

Cette méthode initialise la couleur offColor du morph à la couleur de la fenêtre calculatrice, la couleur de notre instance de SystemWindow, donne au morph une bordure d’une largeur de un pixel (la transmission de borderWidth:) de couleur noire (la transmission borderColor:), lui dit de prendre tout l’espace hoprizontal et vertical disponible (les transmissions hResizing: et vResizing: avec l’argument #spaceFill), donne une étiquette au morph (la transmission de label:) et indique au morph de s’afficher comme un rectangle avec des coins arrondis (la transmission useRoundedCorners). Cette méthode retourne le PluggableButtonMorph ainsi initialisé (avec la transmission yourself).

Après ces initialisations, le PluggableButtonMorph est ajouté (ligne 45) à l’instance courante des trois AlignmentMorph disponibles.

Une fois la boucle intérieure terminée, nous avons crée trois vues de touches, la boucle extérieure ajoute l’AlignmentMorph contenant ces trois vues à la fenêtre calculatrice (lignes 46 à 49). La variable temporaire l, qui est incrémentée à chaque itération, nous permet de connaître la position verticale, l 5, de ce morph.

Comme on peut voir dans la figure 6.18, qui montre une vue de notre calculatrice à l’instant actuel de sa programmation, elle commence à ressembler à une vraie calculatrice. Les quelques touches qui y manquent encore seront construites ci-dessous.

50   a1 := AlignmentMorph newRow.  
51   view := PluggableButtonMorph  
52            on: (Touche new  
53                  label: ’C’  
54                  fonction: #activeClear:  
55                  on: self)  
56            getState: #etat  
57            action: #activer.  
58   view := self  
59            initalise: view  
60            with: couleur  
61            and: ’C’.  
62   a1 addMorphBack: view.  
63   view := PluggableButtonMorph  
64            on: (Touche new  
65                  label: ’0’  
66                  fonction: #activeNumber:  
67                  on: self)  
68            getState: #etat  
69            action: #activer.  
70   view := self  
71            initalise: view  
72            with: couleur  
73            and: ’0’.  
74   a1 addMorphBack: view.  
75   view := PluggableButtonMorph  
76            on: (Touche new  
77                  label: ’=’  
78                  fonction: #activeEgal:  
79                  on: self)  
80            getState: #etat  
81            action: #activer.
82   view := self  
83            initalise: view  
84            with: couleur  
85            and: ’=’.  
86   a1 addMorphBack: view.  
87   window  
88      addMorph: a1  
89      frame: (0 @ (4 / 5) extent: 3 / 5 @ (1 / 5)).
L’extrait de la méthode Calculatrice»openInWorld ci-dessus peut se lire comme une succession de 3 blocs de 12 lignes. Chacun de ces blocs définit la vue d’une touche particulière: les lignes 51 à 62 définissent la touche de réinitialisation C, les lignes 63 à 74 la touche numérique 0 et les lignes 75 à 86 la touche d’entrée =.

La première ligne, la ligne 50, crée une nouvelle instance de la classe AlignmentMorph qui sert de conteneur pour ces trois touches. Les lignes 62, 74 et 86 ajoutent chacune des touches à ce morph. Nous utilisons ici le message addMorphBack: qui est identique à addMorph:, à la différence près que le premier ajoute le morph argument après les morphs déjà dans le morph conteneur pendant que le deuxième l’ajoute avant les morphs déjà présents dans le morph conteneur.

Les lignes 87 à 89 ajoutent le morph conteneur de ces trois touches, l’AlignementMorph crée dans la ligne 50, à la vue de la calculatrice. La figure 6.19 montre la vue de la calculatrice au stade actuel du programme: il n’y manque qu’une rangée verticale de touches d’opérateurs arithmétiques.


FIG. 6.19: Notre calculatrice au stade actuel du programme: deux écrans, dix touches numériques et les touches C et =

Terminons alors la vue de notre calculatrice en ajoutant ces touches des opérateurs arithmétiques. Elles seront alignées verticalement: nous utilisons donc un AlignmentMorph à insertion verticale des sous-morphs. Ce qui se fait dans la ligne 91 grâce à la méthode de création newColumn.

Ensuite, comme nous l’avons fait pour les touches numériques, nous allons parcourir un tableau contenant les étiquettes de chacune des quatres touches d’opérations (les touches +, -, * et /, ligne 91 ci-dessous) pour construire un nouveau PluggableButtonMorph pour chacune d’elles. Si les instances de la classe Touche correspondant aux touches numériques ont comme label: la valeur numérique et comme fonction: le sélecteur activeNumber:, les instances des opérateurs ont comme label: le symbol correspondant à leur étiquette et comme fonction: le sélecteur activeFonction: (lignes 94 à 97). Pour le reste, la définition des vues de ces touches d’opérations se fait exactement de la même manière que la définition des autres touches.

La ligne 108, la dernière ligne de notre méthode openInWorld, lance l’affichage de notre calculatrice avec la transmission openInWorld. Notez bien que cette méthode openInWorld est transmise à une instance de la classe SystemWindow et n’a donc rien à faire avec la méthode openInWorld de notre classe Calculatrice que nous venons d’écrire.

90    a1 := AlignmentMorph newColumn.  
91    #(’+’ ’-’ ’*’ ’/’ )  
92       do: [:label |  
93          view := PluggableButtonMorph  
94                   on: (Touche new  
95                         label: label asSymbol  
96                         fonction: #activeFonction:  
97                         on: self)  
98                   getState: #etat  
99                   action: #activer.  
100         view := self  
101                  initalise: view  
102                  with: couleur  
103                  and: label.  
104         a1 addMorph: view].  
105   window  
106      addMorph: a1  
107      frame: (3 / 5 @ (1 / 5) extent: 1 / 5 @ (4 / 5)).  
108   window openInWorld
Clairement notre méthode openInWorld est trop longue: 108 lignes, c’est vraiment trop! Mon conseil pour le style d’écriture de programmes SMALLTALK est qu’une méthode ne doit pas dépasser la taille d’un écran d’affichage standard. Nous laissons la factorisation qui s’impose comme une exercice.