Interfaces graphiques avec Python et Qt (PySide)

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 :

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 est self, c’est à dire la fenêtre de l’application, vu le contexte d’utilisation de self. 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 objets A et B, puis ajouter l’objet B dans l’objet A en faisant B.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’objet obj est connecté au slot fonc 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) :

  1. 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.
  2. 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).
  3. La troisième, un peu plus complexe et spécifique à Qt, mais aussi plus puissante, consiste à utiliser les widgets QGraphicsView et QGraphicsScene 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 liste listepoints, 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 dans e.x() et e.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é (slot repaint()). Elle a pour effet de créer un objet de type QPainter 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 liste listepoints 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 un QPainter 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 objet QPainter) :

# 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éthode efface à notre widget, et connecter le signal clicked 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).

Documentation eventFilter

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é dans paintEvent.

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 (ligne numero_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é par jeu (qui est toujours la même liste, avec le même id), 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

  1. En programmation, un paradigme est une méthode/un mode de programmation et de pensée, une vision particulière ↩︎

  2. qui consiste à l’appel de la fonction d’intialisation de la classe QWidget, mère de MaFenetre ↩︎

  3. parent au sens de l’interface graphique, pas au sens de la POO ↩︎

  4. cette écriture provient de la syntaxe utilisée en C++, le langage d’origine de Qt ↩︎