Interfaces graphiques avec Python et Qt (PySide) #
Cette page a été écrite aux environs de 2013 et elle n’est pas maintenue. Certaines informations sont donc peut être maintenant incorrectes.
Introduction #
Ce document traite de la réalisation d’applications graphiques (avec fenêtres, boutons etc..) en Python. La création d’applications graphiques nécessite le choix d’un toolkit particulier. Les contraintes étant la portabilité de l’application, la disponibilité du toolkit sur python 3, la maturité et la simplicité d’utilisation, c’est le toolkit Qt qui a été retenu. Python dispose de deux bindings pour Qt : PyQt et PySide.
Qt ainsi que le binding PySide (celui que nous avons choisi ici) ont été ensuite développé par Nokia à partir de 2008.
Actuellement Qt appartient à la société Digia, et son développement est toujours actif. Il est disponible sous plusieurs licences : GPL, LGPL et une licence commerciale.
Ce sont les versions 4.7.4 de Qt et 1.1.1 de PySide qui seront utilisées dans les exemples qui suivent avec Python 3. Toutefois, tout devrait fonctionner à l’identique avec la version la plus récente : PySide 1.2.2.
L’objectif de ce document est de guider le lecteur pas à pas dans la réalisation de programmes comportant une interface graphique.
Installation #
La distribution Python Pyzo disponible pour Windows, GNU/Linux et OSX contiennt déjà PySide. Elle contient en outre de nombreux modules scientifiques et un environnement de développement très agréable. Pensez-y…
Sous Linux #
Installation de QT 4 pour Ubuntu :
sudo apt-get install libqt4-dev
Installation Pyside pour Ubuntu :
sudo add-apt-repository ppa:pyside
sudo apt-get update
sudo apt-get install python3-pyside
Des instructions pour d’autres distributions sont disponibles ici : Installation Linux de PySide
Sous Windows #
PySide est disponible en version binaires pour Windows et Python 3.2 à l’adresse suivante: Installation Windows de PySide. L’installation par ce biais est immédiate.
Test de l’installation #
Le programme suivant ouvre une fenêtre contenant le texte Hello World :
import sys
from PySide import QtGui
app = QtGui.QApplication(sys.argv)
label = QtGui.QLabel("Hello <b>World</b>")
label.show()
sys.exit(app.exec_())
Ce programme doit pouvoir être exécuté :
- en ligne de commande :
python3 testqt.py
- sous Windows, en cliquant sur le fichier
.py
ou en ouvrant ce dernier avec l’interpréteur. - depuis l’environnement de développement Idle (touche F5)
Accès à la documentation #
Qt et PySide sont dotés d’une bonne documentation officielle… en anglais. Voici quelques pages Web que vous aurez besoin de consulter :
- Documentation de PySide 1.2.1
- Site officiel de Qt
- Dépot PySide sur GitHub (semble être le dépot le plus récent au 20/09/14)
- Site officiel de PySide
- Documentation de PySide
- Documentation de Qt 4.7 avec, sur la droite une zone pour rechercher dans la documentation
- FAQ Qt sur developpez.com
Comme nous l’avons déjà signalé, il existe un autre binding python pour Qt : PyQt4. Pyside et PyQt4 ne sont pas strictement identiques mais le plus souvent, les informations relatives à l’un s’appliquent aussi à l’autre.
Les informations relatives à Qt en général présentent la plupart du temps des exemples écrits en C++. Leur utilisation demande un effort de traduction, pas forcément très évident.
Apprentissage par l’exemple #
L’utilisation de PySide nécessite d’avoir une petite connaissance de la programmation orientée objets avec Python. Le choix de la POO est tout naturel car Qt est écrit en C++, un langage orienté objets, et l’utilisation du paradigme1 objets, avec Python, permet des formulations concises et claires.
Création d’un widget et lancement d’une application #
Une application graphique doit contenir au moins un objet (les éléments graphiques des applications sont appelés widgets).
Une interface graphique complète contient généralement un objet de base de type QMainWindow (qmainwindow.html). Cependant, il est possible de créer une application avec un objet plus simple comme un QLabel ou un QWidget.
Un programme complet se compose d’un objet de type QApplication, et d’un ou plusieurs widgets graphiques, qui forment l’interface. L’objet QApplication «gère» le programme (initialisation, boucle des événements, terminaison).
Voici un petit programme qui illustre ces concepts :
import sys
from PySide import QtGui
# Création de l'application, on lui envoie en paramètres
# ce qui provient de la ligne de commande
app = QtGui.QApplication(sys.argv)
# Création du premier et unique Widget
fen = QtGui.QWidget()
# Dimensionnement du Widget
fen.resize(200, 100)
# Titre de la fenêtre
fen.setWindowTitle('Simple')
# Affichage
fen.show()
# Boucle des événements
app.exec_()
# Fin
sys.exit()
Personnalisation d’un Widget #
Dans la plupart des programmes utilisant une interface graphique, on écrit au moins une classe, qui correspond à la fenêtre principale.
La compréhension de l’exemple qui suit nécessite de savoir écrire des
classes, de connaître la signification et l’utilisation de self
, et de
la méthode spéciale __init__
. Si vous ne connaissez pas ces notions,
demandez conseil à l’enseignant. Vous pouvez aussi consulter le document
Programmation orientée objets avec Python.
import sys
from PySide import QtGui
class MaFenetre(QtGui.QWidget):
def __init__(self, titre):
QtGui.QWidget.__init__(self)
self.setWindowTitle(titre)
self.resize(200, 100)
self.show()
app = QtGui.QApplication(sys.argv)
fen = MaFenetre("Application simple")
sys.exit(app.exec_())
Dans le code qui précède on a personnalisé l’objet graphique MaFenetre. Ce Widget hérite de QWidget. Sa méthode d’initialisation appelle celle du widget parent, ajuste le titre, la taille, et provoque l’affichage.
Dans la suite de ce tutoriel, la ligne : QtGui.QWidget.__init__(self)
2 pourra avantageusement être remplacée par : super().__init__()
.
Ajout de Widgets à l’intérieur de la fenêtre #
Une application réelle se compose de nombreux widgets, savamment disposés, et qui interagissent. La page suivante donne un aperçu des widgets et des dispositions disponibles : Widgets and Layouts.
Dans un premier temps, nous allons utiliser une disposition des widgets en mode absolu c’est à dire que nous indiquerons les coordonnées et les tailles précises de chaque objet. Gardons cependant à l’esprit que c’est une simple étape. Une véritable application graphique a des objets disposés selon un ou plusieurs layouts. C’est ce qui permet à l’application d’être redimensionnée correctement et de s’adapter à plusieurs tailles d’écran.
Nous allons ajouter 2 widgets à notre fenêtre. Un QPushButton et un QLabel. Puis, nous connecterons ces deux objets de telle manière que l’appui sur le bouton ait une action sur le label : en cliquant sur le bouton, le texte du label changera.
Voici la première version, contenant uniquement le squelette graphique :
import sys
from PySide import QtGui
class MaFenetre(QtGui.QMainWindow):
def __init__(self):
super().__init__()
self.creationInterface()
self.show()
def creationInterface(self):
self.setWindowTitle("Deuxième application")
self.resize(100, 40)
label = QtGui.QLabel("...", self)
label.setGeometry(0, 0, 100, 20)
label.setStyleSheet("QLabel {background-color : yellow;}");
bouton = QtGui.QPushButton("Cliquez ici", self)
bouton.setGeometry(0, 20, 100, 20)
app = QtGui.QApplication(sys.argv)
fen = MaFenetre()
sys.exit(app.exec_())
Notez dans le code qui précède :
- lors de la création d’un widget B dans un widget A, on indique
que B a le widget A pour parent 3 :
label=QtGui.QLabel("...",self)
crée un label contenant le texte...
et dont le parent estself
, c’est à dire la fenêtre de l’application, vu le contexte d’utilisation deself
. C’est pour cette raison que le label apparaît dans la fenêtre. On peut aussi procéder en deux temps : créer d’abord les objetsA
etB
, puis ajouter l’objetB
dans l’objetA
en faisantB.setParent(A)
. - la position et la taille d’un widget sont réglées par la méthode
setGeometry
qui permet de préciser les coordonnes du coin supérieur gauche du widget, sa largeur et sa hauteur. L’origine du repère est le coin supérieur gauche du widget parent. L’axe des ordonnées est dirigé vers le bas. - l’aspect graphique des widgets est modifié par le biais d’un
mécanisme de feuille de styles qui pourra paraître curieux aux
habitués d’autres toolkits graphiques, mais qui le paraîtra moins à
ceux connaissant déjà Css et Html. La feuille de style aurait pu
être attachée à la fenêtre principale (
self.setStyleSheet
) plutôt qu’au label, et aurait ainsi concerné tous les labels enfants de la fenêtre.
Testez l’application et voyez que chaque objet apparaît effectivement. Le bouton est fonctionnel (il s’enfonce), mais ne provoque aucune action particulière.
Nous allons maintenant associer une action au bouton. Le mécanisme
utilisé ici est celui des signaux et des slots. Un événement (bouton
enfoncé) est un signal (c’est le signal clicked
). Nous le connectons
à un slot, qui peut déjà exister ou que nous écrirons nous même, comme
ici :
import sys
from PySide import QtGui
class MaFenetre(QtGui.QMainWindow):
def __init__(self):
super().__init__()
self.creationInterface()
self.show()
self.compteur = 0
# Création du slot
def unkilometre(self):
self.compteur += 1
self.label.setText("{} km à pied...".format(self.compteur))
def creationInterface(self) :
self.setWindowTitle("Deuxième application")
self.resize(100, 40)
self.label = QtGui.QLabel("...", self)
self.label.setGeometry(0, 0, 100, 20)
self.label.setStyleSheet("QLabel { background-color : yellow;}");
bouton = QtGui.QPushButton("Cliquez ici", self)
bouton.setGeometry(0, 20, 100, 20)
# Connextion du signal "clicked" au slot
bouton.clicked.connect(self.unkilometre)
app = QtGui.QApplication(sys.argv)
fen = MaFenetre()
sys.exit(app.exec_())
Notez de quelle manière le signal issu du bouton a été connecté à la
méthode unkilometre
: bouton.clicked.connect(self.unkilometre)
Il y a plusieurs façons de connecter un signal à un slot. Nous aurions aussi pu faire 4 :
self.connect(self.bouton,QtCore.SIGNAL('clicked()'),self.unkilometre)
Dans la méthode unkilometre
, on incrémente un compteur (attribut de
l’objet MaFenetre
), et on affiche ce compteur dans le QLabel. Notez
de quelle manière nous avons accédé à la variable label
depuis la
méthode unkilometre
:
self.label.setText("{} km à pied...".format(self.compteur))
Nous avons dû transformer la variable label
en un attribut de l’objet
MaFenetre
. C’est pour cette raison que les lignes :
label = QtGui.QLabel("...", self)
label.setGeometry(0, 0, 100, 20)
label.setStyleSheet("QLabel {background-color : yellow;}");
ont été changées en :
self.label = QtGui.QLabel("...", self)
self.label.setGeometry(0, 0, 100, 20)
self.label.setStyleSheet("QLabel { background-color : yellow;}");
Résumé
- Une application graphique est crée généralement en écrivant une classe qui hérite de QMainWindow et qui représentera la fenêtre principale de l’application. Pour une application simple (sans menu par exemple), la classe
QWidget
peut suffire.- Le système de signaux et de slots permet de connecter un événement (signal) à une action (slot). Le signal
sig
de l’objetobj
est connecté au slotfonc
en écrivant :obj.sig.connect(fonc)
Convertisseur Euros / Dollars #
Nous allons réaliser une application de conversion d’une unité en une autre. Nous reprendrons l’exemple classique du convertisseur Euros / Dollars.
Le convertisseur qui suit comporte trois widgets : le bouton de conversion, le label qui affiche le résultat et le champ QLineEdit qui permet d’entrer la valeur à convertir :
# convertisseur Euros/Dollars
from PySide import QtGui,QtCore
import sys
class Fenetre(QtGui.QMainWindow): #QWidget
def __init__(self):
super().__init__()
self.createInterface()
self.show()
def conversion(self):
try :
val = int(self.valeur.text())
except ValueError:
val = 0
val = val * 1.3507
self.label.setText("{:.2f}".format(val))
def createInterface(self):
self.resize(400, 70)
self.setFont(QtGui.QFont("Verdana"))
self.setWindowTitle("Convertisseur Euros/Dollars")
convert = QtGui.QPushButton("Convertir",self)
convert.setGeometry(200-50, 20, 100, 40)
convert.clicked.connect(self.conversion)
self.label = QtGui.QLabel("...", self)
self.label.setGeometry(300, 20, 100, 40)
self.valeur = QtGui.QLineEdit("", self)
self.valeur.setGeometry(10, 20, 100, 40)
app = QtGui.QApplication(sys.argv)
frame = Fenetre()
sys.exit(app.exec_())
Résumé
- On récupère le texte inscrit dans un champ
QlineEdit
en faisant :self.valeur.text()
.- Les chaînes de caractères récupérées dans les champs peuvent être converties en utilisant les méthodes standard de python (
int(...)
pour convertir en entier).- Le mécanisme d’exception permet de prévenir les erreurs de saisie. Il n’est pas nécessaire de l’utiliser, mais il rend le programme plus clair, plus sûr, et plus facile à débuguer.
Exercice #
Améliorez le programme pour qu’il fasse la conversion simultanément dans plusieurs devises. Possibilités d’amélioration :
Valider la saisie au clavier provoque la conversion (chercher dans la doc les signaux associés à QLineEdit) Faire la conversion vers plusieurs devises en ayant plusieurs afficheurs Faire la conversion vers l’une ou l’autre des devises en choisissant un bouton ou un item dans une liste déroulante.
Dessiner à la souris #
De nombreuses applications nécessitent la production de graphismes (dessins, schémas, plateaux de jeu, courbes…). De même la souris peut être utilisée pour interagir avec ces dessins (déplacement de pions, modification de courbes, ajustement d’une simulation…).
Nous allons donc détailler ici trois manières de dessiner à la souris (ce qui permet de voir en même temps la gestion de la souris et les fonctionnalités de dessin) :
- La première consiste à redéfinir la méthode
paintEvent
qui dessine le contenu d’un Widget. L’idée sera de conserver dans une liste les éléments à tracer, et, à chaque fois que c’est nécessaire, de retracer tout le contenu du widget. Pour un jeu, comme un jeu de plateau, cette méthode est parfois la plus simple car on dispose généralement d’une représentation logique de l’état du jeu. Le tracé est alors une VUE sur la représentation interne du jeu. - La seconde est un peu similaire, mais le tracé sera fait dans une image, conservée en mémoire, et cette image sera plaquée sur le widget à chaque fois qu’un affichage est nécessaire (ce qui ne nécessitera pas de conserver une trace logique de chaque élément dessiné, mais simplement du dessin lui-même).
- La troisième, un peu plus complexe et spécifique à Qt, mais aussi
plus puissante, consiste à utiliser les widgets
QGraphicsView
etQGraphicsScene
prévus à cet usage.
Redéfinition de paintEvent #
Nous créons un widget très simple (basé sur QWidget
) et redéfinissons
le slot qui répond aux clics souris (mousePressEvent
) et la méthode
qui redessine le widget (paintEvent
).
class ZoneDessin(QtGui.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.listepoints = []
def mousePressEvent(self, e) :
self.listepoints.append((e.x(), e.y()))
self.repaint()
def paintEvent(self, e) :
p = QtGui.QPainter(self)
p.setBrush(QtGui.QColor(255, 0, 0))
for l in self.listepoints :
p.drawEllipse(l[0], l[1], 15, 15)
- La méthode
__init__
se contente d’appeler la méthode d’initialisation de la classe parent, puis crée une listelistepoints
, initialement vide. - la méthode
mousePressEvent
est appelée chaque fois qu’on presse le bouton de la souris sur le widget. Elle ajoute les coordonnées cliquées (accessibles danse.x()
ete.y()
à la liste des points. Puis elle demande au widget de se redessiner (self.repaint()
) - la méthode
paintEvent
est appelée chaque fois que le widget doit être redessiné, soit parce qu’il a été occulté par une autre fenêtre, soit parce que le programme l’a demandé (slotrepaint()
). Elle a pour effet de créer un objet de typeQPainter
sur le widget, et de dessiner dedans (on ne dessine par directement sur le widget). Après avoir sélectionné le type de brosse, la listelistepoints
est parcourue, et un disque de rayon 15 est dessiné, centré sur chaque point de la liste.
Ce nouveau widget, que nous avons appelé ZoneDessin
est donc un widget
qui répond aux clics souris en dessinant des disques.
Nous allons maintenant créer une fenêtre et ajouter ce widget dedans :
# Dessiner en utilisant paintEvent
from PySide import QtGui,QtCore
import sys
class ZoneDessin(QtGui.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.listepoints = []
def mousePressEvent(self, e) :
self.listepoints.append((e.x(), e.y()))
self.repaint()
def paintEvent(self, e) :
p=QtGui.QPainter(self)
p.setBrush(QtGui.QColor(255, 0, 0))
for l in self.listepoints :
p.drawEllipse(l[0], l[1], 15, 15)
class Fenetre(QtGui.QMainWindow):
def __init__(self,parent=None):
super().__init__(parent)
self.resize(420, 420)
self.setWindowTitle("Dessin painEvent")
dessin = ZoneDessin(self)
dessin.setGeometry(10, 10, 400, 400)
app = QtGui.QApplication(sys.argv)
frame = Fenetre()
frame.show()
sys.exit(app.exec_())
Le programme est complet et peut être testé.
Les stylos, les brosses et les couleurs
Chaque tracé utilise un stylo (
Pen
) et une brosse (Brush
). Le stylo est utilisé pour les contours du tracé et la brosse pour l’intérieur. Pour modifier le stylo ou la brosse à utiliser sur unQPainter
p
, on utilise les fonctions :
p.setPen(...)
p.setBrush(...)
Ces deux fonctions prennent normalement en paramètre un stylo ou une brosse construite par avance. Il existe cependant des raccourcis, et ces deux fonctions peuvent prendre simplement une couleur ou une texture.
Les textures de brosse sont prédéfinies dans le module
QtCore.Qt
Doc BrushStyle :
- QtCore.Qt.SolidPattern
- QtCore.Qt.CrossPattern
- …
Les textures de stylos sont définies dans le même module Doc PenStyle :
- QtCore.Qt.SolidLine
- QtCore.Qt.DashLine
- …
Certaines couleurs sont aussi prédéfinies dans ce module Doc GlobalColor :
- Qt.black
- Qt.darkYellow
- …
Enfin, les couleurs peuvent être créées à partir de leurs composantes RVB (trois entiers entre 0 et 255) :
QtGui.QColor(r,v,b)
.Voici plusieurs portions de code fonctionnelles pour manipuler les brosses et les stylos (
p
est supposé être une référence vers un objetQPainter
) :# Choix Fond rouge, bord bleu p.setPen(QtGui.QColor(0, 0, 200)) p.setBrush(QtCore.Qt.red) ... # Idem, avec le fond hachuré orange, il faut alors créer une brosse : p.setPen(QtGui.QColor(0, 0, 200)) b=QtGui.QBrush(QtCore.Qt.DiagCrossPattern) b.setColor(QtGui.QColor(255, 100, 0)) p.setBrush(b)
Tracé dans une image #
Voici un programme qui provoque le même comportement que l’exemple précédent, mais fonctionne d’une autre manière.
# Dessiner avec une image
from PySide import QtGui,QtCore
import sys
class ZoneDessin(QtGui.QWidget):
def __init__(self,parent=None):
super().__init__(parent)
self.im = QtGui.QPixmap(400, 400)
self.im.fill(QtCore.Qt.white)
def mousePressEvent(self, e):
p = QtGui.QPainter(self.im)
p.setBrush(QtGui.QBrush(QtCore.Qt.SolidPattern))
p.drawEllipse(QtCore.QPoint(e.x(), e.y()), 15, 15)
self.repaint()
def paintEvent(self, e):
p=QtGui.QPainter(self)
p.drawPixmap(0, 0, self.im)
class Fenetre(QtGui.QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.resize(420, 420)
self.setWindowTitle("Dessin PixMap")
dessin = ZoneDessin(self)
dessin.setGeometry(10, 10, 400, 400)
app = QtGui.QApplication(sys.argv)
frame = Fenetre()
frame.show()
sys.exit(app.exec_())
Dans cet exemple, un objet de type QPixmap
est associé à la zone de
dessin. C’est dans cet objet que les disques sont tracés :
p = QtGui.QPainter(self.im)
p.setBrush(QtGui.QBrush(QtCore.Qt.SolidPattern))
p.drawEllipse(QtCore.QPoint(e.x(),e.y()),15,15)
Puis, lors du réaffichage du widget de dessin, le pixmap en question est plaqué sur l’écran :
def paintEvent(self, e) :
p = QtGui.QPainter(self)
p.drawPixmap(0, 0, self.im)
Cette solution est économe en mémoire en ce sens que la complexité du dessin ne change pas la taille des données utilisées (on a toujours uniquement le pixmap). Les inconvénients sont qu’on ne garde pas de trace symbolique du dessin (on ne garde que les données sur les pixels), et qu’il faut faire en sorte que le pixmap ait la bonne taille (si on permettait le redimensionnement de la fenêtre, il faudrait redimensionner le pixmap…)
Vous pouvez améliorer le programme de dessin en permettant le dessin par «glissé». Pour cela il faut redéfinir la méthode
mouseMoveEvent
. Pour pouvez aussi ajouter un bouton qui effacera l’écran. Pour cela, il faudra ajouter une méthodeefface
à notre widget, et connecter le signalclicked
du bouton à cette méthode.Vous pouvez aussi faire en sorte que le dessin se symétrise en faisant apparaître, à chaque clic, non seulement le point cliqué, mais aussi un ou plusieurs autres points symétriques par une symétrie de votre choix. Vous pouvez utiliser différentes symétries.
Enfin, vous pouvez modifier les couleurs / taille du trait en fonction du temps ou de la position de l’objet à tracer.
Pour réaliser ces améliorations vous pouvez partir du programme utilisant une image ou non. Ce choix dépend en partie des améliorations que vous comptez ajouter. Réfléchissez avant…
QGraphicsView et QGraphicsScene #
La dernière méthode, plus difficile à mettre en oeuvre, est aussi plus souple, car elle permet à l’utilisateur d’interagir avec les objets dessinés, de réaliser des zooms, des rotations, des vues avec barres de défilement.
L’idée est d’utiliser les deux objets : QGraphicsScene
et
QraphicsView
. Le premier contient les objets graphiques et le second
est une vue éventuellement transformée de la scène.
Voici un programmeutilisant ces deux objets :
from PySide import QtCore,QtGui
import sys
class Window(QtGui.QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.scene = QtGui.QGraphicsScene()
self.view = QtGui.QGraphicsView(self.scene, self)
# Position de la vue dans la fenêtre
self.view.setGeometry(10, 10, 300, 300)
# Partie de la scène qui apparaît dans la vue
self.view.setSceneRect(QtCore.QRectF(-200, -200, 400, 400))
self.resize(510,420)
self.setWindowTitle("Dessin QGraphicsView")
def mousePressEvent(self, e):
# Conversion des coord fenêtre en coord View :
c = e.pos() - self.view.pos()
# Conversion en coordonnées scenes :
c = self.view.mapToScene(c)
# Ajout à la scne
self.scene.addRect(c.x() - 10, c.y() - 10, 20, 20)
app = QtGui.QApplication(sys.argv)
w = Window()
w.show()
sys.exit(app.exec_())
Après la création de la scene, dans __init__
, la vue est créée :
self.view = QtGui.QGraphicsView(self.scene, self)
Le premier argument indique que c’est une vue sur la scène
(self.scene
), et le second, que le widget est ajouté à l’objet
self
, la fenêtre principale. Puis l’objet nouvellement créé est
positionné dans la fenêtre et redimensionné :
self.view.setGeometry(10, 10, 300, 300)
Enfin, on indique la portion de scène qui sera visible dans la vue :
self.view.setSceneRect(QtCore.QRectF(-200, -200, 400, 400))
Puis, le slot recevant les événements clics souris est redéfini :
def mousePressEvent(self,e):
# Conversion des coord fenêtre en coord View :
c = e.pos() - self.view.pos()
print(dir(e))
# Conversion en coordonnées scenes :
c = self.view.mapToScene(c)
# Ajout à la scne
self.scene.addRect(c.x() - 10, c.y() - 10, 20, 20)
Dans cette méthode, ce sont les clics dans le fenêtre principale qui
sont reçus (car c’est le slot mousePressEvent
de la fenêtre que nous
avons redéfini). Les coordonnées sont donc celles dans la fenêtre
principale, et non dans la vue. La première ligne sert donc à calculer
les coordonnées dans la vue :
c = e.pos() - self.view.pos()
Puis, on transforme ces coordonnées en coordonnées dans la scène :
c=self.view.mapToScene(c)
Enfin, un carré est ajouté dans la scène à ces coordonnées. Ce carré va donc apparaître à l’endroit pointé par la souris :
self.scene.addRect(c.x() - 10, c.y() - 10, 20, 20)
Amélioration de la gestion des clics souris #
Le défaut du programme précédent est que les clics souris effectués dans
toute la fenêtre sont traités par la fonction mousePressed
, y compris,
ceux effectués en dehors de la vue. Cliquer en dehors de la vue provoque
donc aussi l’apparition d’un carré dans la scène (on ne le voit pas,
puisqu’il n’est pas dans la vue, mais il apparaîtra si on translate
la vue).
Il existe un mécanisme qui permet, dans un objet parent, de récupérer
les événements à destination d’un des enfants. Ce mécanisme, qui évite
de créer une nouvelle classe pour chaque objet est mis en place par
l’appel : self.view.installEventFilter(self)
. Les événements à
destination de self.view
seront redirigés vers la méthode
eventFilter(self, obj, event)
de l’objet self
(la fenêtre
principale). Dans eventFilter
, nous pouvons tester l’émetteur du
signal et le type de signal et y répondre en appelant la méthode
traitement_clic
. Notons qu’il est ici inutile de transformer les
coordonnées fenêtre du clic en coordonnées vue, puisque le signal
traité est celui de la vue (on est donc déjà en coordonnées vue).
La méthode eventFilter
doit renvoyer True
si elle prend en charge
l’évènement, et False
sinon (auquel cas le signal sera propagé au
parent).
from PySide import QtCore,QtGui
import sys
class Window(QtGui.QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.scene = QtGui.QGraphicsScene()
self.view = QtGui.QGraphicsView(self.scene, self)
self.view.setGeometry(50, 10, 300, 300)
self.resize(510, 420)
self.setWindowTitle("Dessin QGraphicsView")
self.view.installEventFilter(self)
def eventFilter(self, obj, event):
if obj == self.view:
if event.type() == QtCore.QEvent.MouseButtonPress:
self.traitement_clic(event)
return True
return False
def traitement_clic(self, e):
# Conversion en coordonnées scène :
c = self.view.mapToScene(e.pos())
# Ajout à la scène
self.scene.addRect(c.x() - 10, c.y() - 10, 20, 20)
def mousePressEvent(self,e) :
print("Clic")
app = QtGui.QApplication(sys.argv)
w = Window()
w.show()
sys.exit(app.exec_())
Pour bien saisir l’ordre de traitement des signaux :
- cliquer hors de la vue, vérifier que
traitement_clic
n’est pas appelée et que le mot Clic apparaît dans la console (suite à l’appel àprint
). - cliquer sur la vue, vérifier que
tratement_clic
est appelée et que le mot Clic n’apparaît pas dans la console.
Jeu de Tic Tac Toe #
Nous allons dans cette section porogrammer le jeu de Tic Tac Toe. Ce sera l’occasion d’utiliser des boîtes de dialogue modales (boîtes qui doivent être refermées par l’utilisateur pour qu’il puisse continuer à utiliser l’application). Nous verrons aussi un principe qui peut être réutilisé dans de nombreux jeux de plateau :
- l’application possède une représentation interne de l’état du jeu, indépendante de l’interface
- la partie graphique ne fait que refléter cette représentation interne
Fenêtre principale #
L’application ne comporte qu’une seule fenêtre (classe Fenetre
héritant de QFrame
). Cette fenêtre ne comporte qu’un Widget, la zone
de dessin :
# Attention, ce code n'est pas fonctionnel, il faudra le compléter
class Fenetre(QtGui.QFrame):
def __init__(self,parent=None):
super().__init__(parent)
self.resize(420, 420)
self.setWindowTitle("Tic Tac Toe")
dessin = ZoneDessin(self)
dessin.setGeometry(10, 10, 400, 400)
app = QtGui.QApplication(sys.argv)
frame = Fenetre()
frame.show()
sys.exit(app.exec_())
Représentation interne #
Le plateau de jeu (3x3) sera représenté par une liste de listes. Chaque élément pourra valoir 0 si la case est inoccupée ou le numéro du joueur (1 ou -1) si elle est occupée.
Une variable, nommée numero_joueur
contiendra le numéro du prochain
joueur qui doit joueur (1 ou -1).
# Numéro du joueur en cours (1 ou -1)
numero_joueur = 1
# Plateau de jeu
jeu=[[0] * 3 for i in range(3)]
Nous aurons aussi besoin d’une fonction qui analyse l’état d’une partie et indique si il y a un gagnant ou si c’est une partie nulle. Nous écrirons le contenu de cette fonction plus tard, mais nous pouvons dès maintenant décider de son fonctionnement :
def analyse_partie(gr_jeu):
# Renvoie 1 ou -1 si un joueur a gagné
# Renvoie 0 si la partie est nulle
# Renvoie None dans les autres cas
pass
Nous aurions pu créer une classe pour représenter le jeu, plutôt que des variables globales et des fonctions. Le jeu étant ici très simple, la solution que nous avons choisie est défendable. Elle le serait plus difficilement pour un projet un peu plus conséquent.
Nous passons un paramètre à
analyse_partie
pour se réserver la possibilité d’analyser différentes grilles et pas seulement la grille actuellement en cours d’utilisation. Cette possibilité n’est cependant pas utilisée ici.
Dessin du plateau de jeu #
Voyons maintenant comment coder le widget ZoneDessin
. Nous savons
qu’il faudra au moins écrire les méthodes paintEvent
pour tracer le
plateau de jeu et mousePressEvent
pour permettre à l ‘utilisateur de
jouer à la souris.
class ZoneDessin(QtGui.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
def mousePressEvent(e):
pass
def paintEvent(self, e):
pass
La méthode paintEvent
doit :
- dessiner la grille de jeu (en traits noirs)
- dessiner les pions qui ont été joués. Pour cela, il faudra parcourir la représentation interne du jeu et afficher les pions en conséquence (en jaune et rouge)
def paintEvent(self, e):
p = QtGui.QPainter(self)
p.setBrush(QtGui.QBrush(QtCore.Qt.SolidPattern))
# Dessin de la grille
largeur_case = self.width() // 3
hauteur_case = self.height() // 3
for i in range(4):
p.drawLine(0, i * hauteur_case, self.width(), i * hauteur_case)
p.drawLine(i * largeur_case, 0 ,i * largeur_case, self.height())
# Dessin des pions
# On parcourt la représentation du jeu et on affiche
for i in range(3):
for j in range(3):
if jeu[i][j] !=0:
if jeu[i][j] == 1:
p.setBrush(QtGui.QColor(255, 0, 0))
else:
p.setBrush(QtGui.QColor(255, 255, 0))
p.drawEllipse(j * largeur_case, i * hauteur_case,
largeur_case, hauteur_case)
En ce qui concerne mousePressEvent
, il faudra, à partir d’un clic
souris, jouer un coup.
Attention, jouer un coup ne signifie pas afficher un pion à l’écran. Cela signifie :
- modifier la représentation interne du jeu en fonction du coup joué
- réactualiser l’affichage (nous avons déjà tout écrit dans
paintEvent
).En particulier, il ne serait pas logique que la méthode
mousePressEvent
contienne l’affichage d’un pion. En effet, cet affichage a déjà été codé danspaintEvent
.
La méthode mousePressEvent
devra donc :
- calculer les coordonnées du pion dans le représentation interne en fonction des coordonnées du clic souris
- valider le coup si la case est inoccupée
Voici une première ébauche de cette méthode :
def mousePressEvent(self,e):
global numero_joueur
largeur_case = self.width() // 3
hauteur_case = self.height() // 3
# Les coordonnées du point cliqué sont e.x() et e.y()
# Transformation des coordonnées écran en coordonnées dans
# le plateau de jeu
j = e.x() // largeur_case
i = e.y() // hauteur_case
# Vérification
print('Vous avez cliqué sur la case : ', (i, j))
# La case est elle vide ?
if jeu[i][j] == 0:
# Si oui, on joue le coup
jeu[i][j] = numero_joueur
# Et c'est au tour de l'autre joueur
numero_joueur = -numero_joueur
# Si non, rien de particulier à faire. C'est toujours au même
# joueur
# On réaffiche
self.repaint()
Notez la première ligne :
global numero_joueur
. Cette ligne est nécessaire car nous modifions l’objet référencé par une variable globale (lignenumero_joueur=-numero_joueur
).En revanche la même précaution n’est pas nécessaire pour la variable
jeu
. En effet, nous ne modifions pas l’objet référencé parjeu
(qui est toujours la même liste, avec le mêmeid
), mais simplement le contenu de la liste (il n’y a pas de ligne du type :jeu=...
).
Notez l’astuce pour changer de joueur :
numero_joueur=-numero_joueur
.
Notez la ligne
print('Vous avez cliqué sur la case : ',(i,j))
qui nous permet de vérifier que la conversion des coordonnées fonctionne correctement
Le jeu est jouable et vous pouvez faire quelques parties, mais rien n’indique le vainqueur ni les parties nullles.
Améliorations #
Nous avions laissé en plan la fonction analyse_partie
censée détecter
les fins de jeu à partir de la représentation interne.
La numérotation des joueurs (1 ou -1) et donc des pions va nous faciliter la tâche pour trouver les vainqueurs. En effet, un joueur a gagné si et seulement si la somme sur une ligne, une colonne, ou une diagonale vaut 3 ou -3. Si ce n’est pas le cas et qu’il ne reste aucune case à 0, alors la partie est nulle :
def analyse_partie(gr_jeu):
# Renvoie 1 ou -1 si un joueur a gagné
# Renvoie 0 si la partie est nulle
# Renvoie None dans les autres cas
for j in range(3):
s = sum(gr_jeu[j][i] for i in range(3))
if s == 3 or s == -3:
return s // 3
s = sum(gr_jeu[i][j] for i in range(3))
if s == 3 or s == -3:
return s // 3
s = sum(gr_jeu[i][i] for i in range(3))
if s == 3 or s == -3:
return s // 3
s = sum(gr_jeu[i][2 - i] for i in range(3))
if s == 3 or s == -3: return s // 3
for i in range(3):
for j in range(3):
if gr_jeu[i][j] == 0:
return None
return 0
Nous pouvons dans un premier temps tester notre fonction en ajoutant
quelques lignes à la fin de la méthode mousePressEvent
:
def mousePressEvent(self, e) :
....
r = analyse_partie(jeu)
print('Analyse de la partie renvoie : ', r)
À présent nous devons décider quoi faire en cas de fin de partie. Nous pouvons par exemple, si la partie est terminée (gain ou nulle), vider le plateau de jeu pour recommencer une nouvelle partie. Avant cela, nous afficherons une boîte de dialogue indiquant s’il y a un vainqueur ou si la partie est nulle. Enfin, si la partie est nulle, nous continuerons l’alternance des joueurs. Si un joueur a gagné, ce sera au perdant de commencer (ce qui revient au même car un joueur est nécessairement victorieux juste après avoir joué).
Rajouter tous ces traitements alourdirait la méthode mousePressEvent
et nous allons donc les réaliser dans une nouvelle méthode de la classe
ZoneDessin
. Ce choix est critiquable. Certaines actions relèvent
plutôt de la représentation interne, d’autres de l’affichage (grille
vide) et d’autres de l’application (boîte de dialogue).
Le projet étant très modeste, nous regrouperons tout ceci dans la
méthode fin_partie
de la zone de dessin :
class ZoneDessin(QtGui.QWidget):
...
def fin_partie(self):
# S'il y a un gagnant, on affiche un message et on réinitialise le
# jeu
g = analyse_partie(jeu)
if g != None :
msg = QtGui.QMessageBox()
if g == 1 or g == -1:
msg.setText("Le joueur " + str(g) + " est vainqueur")
else:
msg.setText("Partie nulle")
# La main passe au dialogue
msg.exec_()
# Remise de la partie à 0
for i in range(3):
for j in range(3):
jeu[i][j] = 0
self.repaint()
Notez l’utilisation des boîtes de dialogue.
Code complet #
Voici le code complet de notre petit jeu :
# Dessiner en utilisant paintEvent
from PySide import QtGui,QtCore
import sys
# Numéro du joueur en cours (1 ou -1)
numero_joueur = 1
# Plateau de jeu
jeu = [[0] * 3 for i in range(3)]
def analyse_partie(gr_jeu):
# Renvoie 1 ou -1 si un joueur a gagné
# Renvoie 0 si la partie est nulle
# Renvoie None dans les autres cas
for j in range(3):
s = sum(gr_jeu[j][i] for i in range(3))
if s == 3 or s == -3:
return s // 3
s = sum(gr_jeu[i][j] for i in range(3))
if s == 3 or s == -3:
return s // 3
s = sum(gr_jeu[i][i] for i in range(3))
if s == 3 or s == -3:
return s // 3
s = sum(gr_jeu[i][2-i] for i in range(3))
if s == 3 or s == -3:
return s // 3
for i in range(3) :
for j in range(3) :
if gr_jeu[i][j] == 0:
return None
return 0
class ZoneDessin(QtGui.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
def fin_partie(self) :
# S'il y a un gagnant, on affiche un message et on réinitialise le
# jeu
g = analyse_partie(jeu)
if g != None :
msg=QtGui.QMessageBox()
if g == 1 or g == -1:
msg.setText("Le joueur " + str(g) + " est vainqueur")
else:
msg.setText("Partie nulle")
# La main passe au dialogue
msg.exec_()
# Remise de la partie à 0
for i in range(3):
for j in range(3):
jeu[i][j] = 0
self.repaint()
def mousePressEvent(self, e):
global numero_joueur
largeur_case = self.width() // 3
hauteur_case = self.height() // 3
# Les coordonnées du point cliqué sont e.x() et e.y()
# Transformation des coordonnées écran en coordonnées dans
# le plateau de jeu
j = e.x() // largeur_case
i = e.y() // hauteur_case
# Vérification
print('Vous avez cliqué sur la case : ', (i, j))
# La case est elle vide ?
if jeu[i][j] == 0:
# Si oui, on joue le coup
jeu[i][j] = numero_joueur
# Et c'est au tour de l'autre joueur
numero_joueur = -numero_joueur
# Si non, rien de particulier à faire. C'est toujours au même
# joueur
# On réaffiche
self.repaint()
# On analyse le jeu pour savoir s'il y a une fin de partie
self.fin_partie()
def paintEvent(self, e):
p = QtGui.QPainter(self)
p.setBrush(QtGui.QBrush(QtCore.Qt.SolidPattern))
# Dessin de la grille
largeur_case = self.width() // 3
hauteur_case = self.height() // 3
for i in range(4):
p.drawLine(0, i * hauteur_case, self.width(), i * hauteur_case)
p.drawLine(i * largeur_case, 0, i * largeur_case,self.height())
# Dessin des pions
# On parcourt la représentation du jeu et on affiche
for i in range(3):
for j in range(3):
if jeu[i][j] !=0:
if jeu[i][j] == 1:
p.setBrush(QtGui.QColor(255, 0, 0))
else:
p.setBrush(QtGui.QColor(255, 255, 0))
p.drawEllipse(j * largeur_case, i * hauteur_case,
largeur_case, hauteur_case)
class Fenetre(QtGui.QFrame):
def __init__(self, parent=None):
super().__init__(parent)
self.resize(420, 420)
self.setWindowTitle("Tic Tac Toe")
dessin = ZoneDessin(self)
dessin.setGeometry(10, 10, 400, 400)
app = QtGui.QApplication(sys.argv)
frame = Fenetre()
frame.show()
sys.exit(app.exec_())
Exercice #
- Ajoutez un bouton pour recommencer à tout moment une nouvelle partie.
- Ajoutez un indicateur indiquant quelle est la couleur du prochain joueur qui va jouer.
- Améliorez l’esthétique de l’affichage de la grille et des pions
-
En programmation, un paradigme est une méthode/un mode de programmation et de pensée, une vision particulière ↩︎
-
qui consiste à l’appel de la fonction d’intialisation de la classe
QWidget
, mère deMaFenetre
↩︎ -
parent au sens de l’interface graphique, pas au sens de la POO ↩︎
-
cette écriture provient de la syntaxe utilisée en C++, le langage d’origine de Qt ↩︎