Aller au contenu

Programmation C++ : TP séance 02

Rendu du TP sur Ametice :
Suivez les instructions pour le rendu du TP en mettant le bon numéro de séance dans le nom du répertoire.
N'oubliez pas de rendre votre travail à la fin de la séance comme indiqué, même si vous n'avez pas fini la planche de TP, puis de rendre la version finale avant le début de la séance suivante.

Exercice 1. Dessins de dominos en Cairo avec GTKmm, classes

On se propose de réaliser un programme utilisant la librairie GTKmm, qui est un wrapper C++ pour le toolkit GTK ; cette dernière est la librairie graphique originellement écrite pour réaliser l'interface de GIMP, et utilisée en particulier dans le bureau GNOME et de nombreux utilitaires.

La librairie GTK fournit un widget DrawingArea dans lequel on peut réaliser des dessins en 2D avec la librairie de dessin vectoriel Cairo. Cette librairie utilise la carte graphique et produit des dessins avec anti-crénelage, transparence et transformations géométriques.

1.a. Programme démo

Les onglets ci-dessous contiennent les instructions pour l'installation, deux fichiers à copier-coller dans un répertoire vide, les instructions pour la compilation et l'exécution du programme, et des tests à effectuer.

Sur les machines en salle de TP, les librairies et les fichiers nécessaires pour le développement sont déjà installés.

Si vous travaillez sur votre ordinateur, vous pouvez installer les packages nécessaires sur Ubuntu ou WSL en tapant :

$ sudo apt update
$ sudo apt upgrade
$ sudo apt install g++ make libgtkmm-3.0-dev

Ensuite pour vérifier, tapez simplement

$ pkg-config gtkmm-3.0 --modversion

Vous obtiendrez soit la version installée, soit un message d'erreur.

Copiez le code de départ ci-dessous dans un fichier nommé demo1.cpp :

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
// demo1.cpp - premiers dessins en Cairo avec GTKmm
// CC BY-SA Edouard.Thiel@univ-amu.fr - 14/10/2024

#include <iostream>
#include <gtkmm-3.0/gtkmm.h>

//--------------------------- U T I L I T A I R E S ---------------------------

// Pour connecter un signal à une fonction
#define CONNECT(signal, func) \
    signal().connect( sigc::mem_fun(*this, &func) )

// Pour les dessins avec Cairo
typedef ::Cairo::RefPtr<::Cairo::Context> CairoContext;

const double PI = 3.141592653589793238463;

//------------------------------- D O N N É E S -------------------------------

class MyData {
public:
    int win_width = 640;
    int win_height = 480;
    int m_mouse_button_num_pressed = 0;
    int m_flag1 = 1;
};

//-------------------- F E N Ê T R E   P R I N C I P A L E --------------------

class MainWindow {
public:
    Gtk::Window m_window;
    Gtk::VBox m_vbox1;
    Gtk::HBox m_hbox1;
    Gtk::Button m_button_quit {"Quit"};
    Gtk::Button m_button_click_me {"Cliquez-moi"};
    Gtk::Frame m_frame1;
    Gtk::DrawingArea m_darea;
    MyData *m_my = nullptr;

    MainWindow (MyData& my)
    {
        m_my = &my;

        // Fenêtre
        m_window.set_title ("Demo 1");
        m_window.set_size_request (my.win_width, my.win_height);
        m_window.add (m_vbox1);
        m_vbox1.pack_start (m_hbox1, false, false, 5);

        // Boutons
        m_hbox1.pack_start (m_button_click_me, false, false, 5);
        CONNECT (m_button_click_me.signal_clicked, 
            MainWindow::button_click_me_on_clicked);
        m_hbox1.pack_end (m_button_quit, false, false, 5);
        CONNECT (m_button_quit.signal_clicked, 
            MainWindow::button_quit_on_clicked);

        // Zone de Dessin m_darea
        m_vbox1.pack_start (m_frame1, true, true, 2);
        m_frame1.set_border_width (2);
        m_frame1.add (m_darea);
        CONNECT (m_darea.signal_draw, MainWindow::darea_on_draw);

        m_darea.set_events (Gdk::BUTTON_PRESS_MASK |
                            Gdk::BUTTON_RELEASE_MASK |
                            Gdk::POINTER_MOTION_MASK |
                            Gdk::SCROLL_MASK );

        CONNECT (m_darea.signal_button_press_event,
            MainWindow::darea_on_button_press);
        CONNECT (m_darea.signal_button_release_event,
            MainWindow::darea_on_button_release);
        CONNECT (m_darea.signal_motion_notify_event,
            MainWindow::darea_on_motion_notify);
        CONNECT (m_darea.signal_scroll_event,
            MainWindow::darea_on_scroll);
        CONNECT (m_darea.signal_size_allocate,
            MainWindow::darea_on_size_allocate);

        m_darea.set_can_focus (true);
        m_darea.grab_focus();
        CONNECT (m_darea.signal_key_press_event,
            MainWindow::darea_on_key_press);

        m_window.show_all();
    }

    // Les fonctions qui suivent sont des "callback" c'est-à-dire qu'elles
    // seront appelées automatiquement lors d'un événement.

    void repaint_darea()
    {
        m_darea.queue_draw();   // force darea à se redessiner
    }

    void button_quit_on_clicked()
    {
        Gtk::Main::quit();
    }

    void button_click_me_on_clicked()
    {
        m_my->m_flag1 = !m_my->m_flag1;
        repaint_darea();
    }

    // Tous les dessins sont faits ici, à partir du contexte Cairo cr.
    // Pour faire un dessin, on crée un chemin, puis on appelle 
    // - stroke() : dessine et supprime le chemin courant,
    // - fill()   : remplit et supprime le chemin courant,
    // - stroke_preserve() : dessine et conserve le chemin courant,
    // - fill_preserve()   : remplit et conserve le chemin courant.
    //
    bool darea_on_draw (const CairoContext& cr)
    {
        std::cout << __func__ << " " << std::endl;

        // Une ligne polygonale
        cr->set_line_width (3);
        cr->move_to (500, 30);
        cr->line_to (600, 100);
        cr->line_to (450, 140);
        cr->close_path();
        cr->set_source_rgb (1.0, 0, 1.0);
        cr->stroke();

        // Un rectangle non rempli
        cr->set_line_width (1);
        cr->rectangle (100, 120, 250, 180);  // x, y, width, height
        cr->set_source_rgb (0.0, 0.0, 1.0);
        cr->stroke();

        // Un rectangle rempli sans bord
        cr->rectangle (80, 250, 100, 120);
        cr->set_source_rgb (1.0, 0.7, 0.7);
        cr->fill();

        // Un rectangle rempli avec bord
        if (m_my->m_flag1) {
            cr->rectangle (300, 150, 80, 120);
            cr->set_source_rgb (0.7, 1.0, 0.7);
            cr->fill_preserve();
            cr->set_source_rgb (0.0, 0.0, 0.0);
            cr->stroke();
        }

        // Un arc de cercle
        cr->set_line_width (2);
        cr->arc (500, 300, 80,     // centre_x, centre_y, rayon,
                 0, PI * 2.0);     // angle1, angle2 en radians
        cr->set_source_rgb (0.9, 0.9, 0.9);
        cr->fill_preserve();
        cr->set_source_rgb (0.5, 0.5, 0.5);
        cr->stroke();

        // Du texte
        cr->set_font_size (20);
        cr->move_to (40, 60);
        cr->set_source_rgb (0.82, 0.41, 0.12); // on donne la couleur avant
        cr->show_text ("Quelques dessins :");
        cr->stroke();

        return true;  // événement capté
    }

    bool darea_on_button_press (GdkEventButton *event)
    {
        std::cout << __func__ << " " << event->button << " " <<
            event->x << " " << event->y << std::endl;

        m_darea.grab_focus();  // prend le focus clavier chaque fois qu'on clique
        m_my->m_mouse_button_num_pressed = event->button;  // bouton pressé
        return true;  // événement capté
    }

    bool darea_on_button_release (GdkEventButton *event)
    {
        std::cout << __func__ << " " << event->button << " " <<
            event->x << " " << event->y << std::endl;

        m_my->m_mouse_button_num_pressed = 0;  // bouton relaché
        return true;  // événement capté
    }

    bool darea_on_motion_notify (GdkEventMotion *event)
    {
        if (m_my->m_mouse_button_num_pressed > 0)
            std::cout << __func__ << " " << 
                event->x << " " << event->y << std::endl;

        return true;  // événement capté
    }

    bool darea_on_scroll (GdkEventScroll *event)
    {
        std::cout << __func__ << " " << event->direction << " " <<
            event->x << " " << event->y << std::endl;

        return true;  // événement capté
    }

    void darea_on_size_allocate (Gtk::Allocation& allocation)
    {
        std::cout << __func__ << " " << allocation.get_width() << " " << 
            allocation.get_height() << std::endl;
    }

    bool darea_on_key_press (GdkEventKey *event)
    {
        switch (event->keyval) {
            case GDK_KEY_a :
                std::cout << "Touche a pressée" << std::endl;
                break;

            case GDK_KEY_KP_Add :
            case GDK_KEY_plus :
                std::cout << "Touche + pressée" << std::endl;
                break;

            default : std::cout << "Touche pressée : GDK_KEY_" << 
                gdk_keyval_name(event->keyval) << std::endl;
        }
        return true;  // événement capté
    }

};  // MainWindow

//--------------------------------- M A I N -----------------------------------

int main (int argc, char** argv)
{
    // Initialise GTK et enlève de argc,argv les options reconnues
    Gtk::Main app (argc, argv);

    MyData my;
    MainWindow main_window (my);

    app.run (main_window.m_window);
}

Copiez le code ci-dessous et enregistrez-le dans un fichier nommé Makefile ; attention, les indentations doivent être des caractères tabulation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# Makefile pour Gtkmm - version multi-exécutables
#
# CC BY-SA Edouard.Thiel@univ-amu.fr - 14/10/2024
#
# Pour tout compiler, tapez : make all
# Pour tout compiler en parallèle, tapez : make -j all
# pour supprimer les .o et exécutables : make clean
# Pour tout recompiler : make clean all

SHELL   = /bin/bash
CPP     = g++
RM      = rm -f
CFLAGS  = $$(pkg-config gtkmm-3.0 --cflags) -Wall -O2 --std=c++17  # -g pour gdb
LIBS    = $$(pkg-config gtkmm-3.0 --libs)

# Fichiers à compiler :
# chaque fichier .cpp produira un exécutable du même nom
CFILES  := $(wildcard *.cpp)
EXECS   := $(CFILES:%.cpp=%)

# Règle pour fabriquer les .o à partir des .cpp
%.o : %.cpp
	$(CPP) $(CFLAGS) -c $*.cpp

# Déclaration des cibles factices
.PHONY : all clean

# Règle pour tout compiler
all : $(EXECS)

# Règle de production de chaque exécutable
$(EXECS) : % : %.o
	$(CPP) -o $@ $^ $(LIBS)

# Règle de nettoyage - AUTOCLEAN
clean :
	$(RM) *.o *~ $(EXECS) tmp*.*

Pour compiler le programme demo.cpp à l'aide du fichier Makefile, il faut que ces deux fichiers soient copiés dans le même répertoire, qui ne doit pour le moment contenir rien d'autre.

$ ls
demo1.cpp  Makefile

Pour compiler le programme, tapez :

$ make all

Pour l'exécuter, tapez :

./demo1

Il est également possible de supprimer les fichiers compilés en tapant :

$ make clean

ou encore, de forcer une recompilation générale en tapant :

$ make clean all

Cliquez plusieurs fois sur le bouton Cliquez-moi. Observez les traces dans le terminal lorsque vous cliquez dans le dessin avec les différents boutons de la souris, ou tirez la souris avec un bouton enfoncé, ou tapez sur des touches du clavier, ou agrandissez la fenêtre.

Cherchez dans le code quelles fonctions ont affiché ces messages.

Étudiez la structure du code, repérez la partie où sont instanciés les widgets (window gadget, c'est-à-dire les objets graphiques), comment dans le constructeur de la fenêtre principale ils sont attachés les uns aux autres dans des boîtes horizontales ou verticales, et comment les événements sont CONNECT-és à des callbacks (des fonctions appelées automatiquement après un événement).

1.b. Gestion d'événements

Recopier demo1.cpp sous le nom demo2.cpp dans le même répertoire. Pour le compiler et l'exécuter il suffira de taper :

$ make all && ./demo2

Modifier le programme afin que le titre de la fenêtre soit Demo 2.

Rajouter un bouton à droite du bouton Cliquez-moi, dont l'action échangera la largeur et la hauteur de l'un des rectangles dessinés (toujours le même). Pour y parvenir, il faut mémoriser ces deux valeurs dans la classe MyData, les utiliser dans darea_on_draw, et les échanger dans la callback du nouveau bouton. Que faut-il faire à la fin de la callback pour forcer darea à se redessiner ?

Rajouter la possibilité de déplacer le cercle en cliquant dedans et en tirant la souris. Pour y parvenir, mémoriser son centre et son rayon dans la classe MyData, les utiliser dans darea_on_draw. Mémoriser également un flag permettant de savoir si le cercle est actuellement cliqué. Modifier ce flag dans les callbacks appelées lorsque la souris est enfoncée (si la distance entre la souris et le centre du cercle est inférieure au rayon) ou relâchée. Enfin dans la callback appelée lorsque la souris est tirée, si le flag est vrai, déplacer les coordonnées du centre du cercle à partir de la différence de coordonnées actuelles et précédemment enregistrées de la souris, et rafraîchir l'affichage. Le but est que l'on puisse "attraper" le cercle à n'importe quel endroit et que lors du déplacement, cet endroit reste fixe par rapport à la souris.

Remarque : toutes les coordonnées sont des double, donc ce doit être également le cas pour les différences de coordonnées quand on tire le cercle.

Taille des lignes de code

Pensez à limiter la taille de vos lignes de code à (environ) 80 caractères, en alignant lorsque nécessaire (par exemple des tests à rallonge) de manière à en faciliter la relecture.

1.c. Classe Domino

Recopier demo1.cpp sous le nom domino.cpp dans le même répertoire.

Déclarer une classe Domino, mémorisant en données membre les points entiers m_a et m_b, les coordonnées en pixels (mais de type double) du centre du domino m_xc et m_yc, et un angle de rotation entier m_angle valant 0 (horizontal), 90 (vertical), 180 (horizontal poids inversés) ou 270 (vertical poids inversés).

Le constructeur n'aura pour le moment aucun paramètre (on améliorera cela au TP n°3). Les 5 données membre d'un domino pourront être utilisées ou modifiées directement (nous verrons les setter et getter au cours n°4).

Comme au TP n°1, déclarer une constante DOMINO_SIDE qui sera la largeur en pixels du petit côté d'un domino, qui aura donc comme longueur 2*DOMINO_SIDE, et des constantes pour d'autres paramètre du dessin, en particulier pour le rayon du rivet.

Ajouter une méthode void draw (const CairoContext& cr) qui dessine le domino selon ses données membres en utilisant le contexte Cairo cr, en particulier son angle (4 cas à traiter).

Mémoriser un Domino nommé m_domino1 dans MyData, donner des valeurs aux 5 membres de m_domino1 dans le constructeur de MyData et le dessiner en appelant sa méthode draw dans darea_on_draw. Tester les 4 cas pour l'angle.

1.d. Manipulation d'un Domino

Les gestes pour manipuler un domino seront les suivants : on pourra déplacer un domino en cliquant à l'intérieur ou au bord du domino puis en tirant la souris ; un clic sur le centre du domino (le rivet) provoquera sa rotation de 90 degrés.

Rajouter une méthode domino_is_clicked (ev_x, ev_y) recevant en paramètre les coordonnées réelles de la souris. La méthode renverra true si les coordonnées de la souris sont à l'intérieur ou au bord du domino, false sinon.

Rajouter une méthode rivet_is_clicked (ev_x, ev_y) recevant en paramètre les coordonnées réelles de la souris. La méthode renverra true si les coordonnées de la souris sont à l'intérieur ou au bord du rivet, false sinon (il suffit de comparer la distance de la souris au centre du domino, au rayon du rivet).

Rajouter un flag dans MyData, initialisé à false. Appeler la méthode domino_is_clicked lorsque la souris est cliquée et mémoriser la valeur obtenue dans le flag.

Lorsque la souris est tirée et le flag est true, déplacer le domino en utilisant la même approche que dans la question 1.b. ci-dessus avec le cercle.

Lorsque la souris est relâchée et le flag est true, si la méthode rivet_is_clicked renvoie true alors rajouter 90 degrés (modulo 360) à son angle, et forcer le réaffichage.

Enfin, remettre systématiquement le flag à false lorsque la souris est relâchée.

Tester l'interface en déplaçant le domino et en lui faisant subir des rotations. Suite au prochain épisode !