Aller au contenu

21 - Tkinter

Ouverture hors programme. Le module tkinter n’est pas au programme de NSI, mais il sert dans beaucoup de projets : quiz, calculatrices, petits jeux. Il est fourni avec Python : rien à installer. Le programme de première demande en revanche d’identifier les événements que les composants d’une interface peuvent recevoir : c’est l’idée centrale de cette page. Les programmes s’exécutent dans Thonny (ou tout autre éditeur installé sur l’ordinateur), pas dans le navigateur.

Deux façons d’écrire un programme

Jusqu’ici, nos programmes étaient séquentiels : les instructions s’exécutent dans l’ordre, de la première à la dernière ligne, et c’est le programme qui décide quand demander quelque chose à l’utilisateur, avec input().

Une interface graphique fonctionne autrement. Le programme prépare une fenêtre et ses composants, puis il attend. C’est l’utilisateur qui décide : il clique sur un bouton, tape au clavier, ferme la fenêtre. Chacune de ces actions est un événement, et le programme y répond en appelant la fonction qu’on lui a associée. On parle de programmation événementielle.

L’attente est assurée par la boucle d’événements, lancée par fenetre.mainloop(). Elle tourne jusqu’à la fermeture de la fenêtre : elle surveille les événements et appelle les fonctions associées. Nous n’écrivons donc jamais cette boucle nous-mêmes.

Une première fenêtre

import tkinter as tk

fenetre = tk.Tk()                     # la fenêtre principale
fenetre.title("Ma première fenêtre")  # texte de la barre de titre
fenetre.geometry("360x120")           # largeur x hauteur, en pixels

message = tk.Label(fenetre, text="Bonjour !", font=("Arial", 24))
message.pack(pady=30)                 # placer le texte dans la fenêtre

fenetre.mainloop()                    # boucle d'événements
Une fenêtre intitulée « Ma première fenêtre » qui affiche le texte « Bonjour ! ».

La première fenêtre.

  • import tkinter as tk importe le module sous le nom court tk : on écrit tk.Label au lieu de tkinter.Label.
  • tk.Tk() crée la fenêtre principale. Un programme n’en crée qu’une seule.
  • tk.Label(fenetre, text=...) crée une étiquette, c’est-à-dire un texte affiché. Le premier argument indique dans quoi on la range : ici, la fenêtre.
  • Créer un composant ne suffit pas à l’afficher : il faut le placer, ici avec pack(). Un composant créé mais jamais placé reste invisible.
  • fenetre.mainloop() doit être la dernière instruction : les lignes écrites après ne s’exécutent qu’à la fermeture de la fenêtre.

Les composants d’une interface s’appellent des widgets (de window gadget). Les plus courants :

WidgetRôleCréation
Labelafficher un texte ou une imagetk.Label(fenetre, text="Score : 0")
Buttonbouton cliquabletk.Button(fenetre, text="OK", command=valider)
Entrychamp de saisie d’une lignetk.Entry(fenetre, width=20)
Framecadre qui regroupe d’autres widgetstk.Frame(fenetre)
Canvaszone de dessintk.Canvas(fenetre, width=400, height=300)
Checkbuttoncase à cochertk.Checkbutton(fenetre, text="...", variable=v)
Radiobuttonchoix unique dans une listetk.Radiobutton(fenetre, text="...", variable=v, value=1)

Un bouton qui réagit

Un bouton reçoit un paramètre command : la fonction à appeler quand on clique dessus. Cette fonction s’appelle un gestionnaire d’événement (en anglais callback, « fonction rappelée »).

import tkinter as tk

compteur = 0


def cliquer():
    """Appelée par tkinter à chaque clic sur le bouton."""
    global compteur
    compteur = compteur + 1
    affichage.config(text=f"Nombre de clics : {compteur}")


fenetre = tk.Tk()
fenetre.title("Compteur")

affichage = tk.Label(fenetre, text="Nombre de clics : 0", font=("Arial", 18))
affichage.pack(padx=40, pady=15)

bouton = tk.Button(fenetre, text="Cliquer ici", command=cliquer)
bouton.pack(pady=10)

fenetre.mainloop()
Une fenêtre « Compteur » affichant « Nombre de clics : 3 » au-dessus d'un bouton « Cliquer ici ».

Après trois clics.

Le programme principal crée la fenêtre, puis lance mainloop(). À partir de là, il ne se passe rien tant que l’utilisateur ne clique pas. À chaque clic, la boucle d’événements appelle cliquer().

MomentCe qui se passecompteurTexte affiché
lancementcréation des widgets, mainloop()0Nombre de clics : 0
1er clicappel de cliquer()1Nombre de clics : 1
2e clicappel de cliquer()2Nombre de clics : 2
3e clicappel de cliquer()3Nombre de clics : 3

Deux points de ce programme reviennent dans toutes les interfaces.

  1. Modifier un widget : affichage.config(text=...) change le texte d’une étiquette déjà affichée. La méthode config (ou configure, c’est la même) modifie n’importe quelle option : text, fg (couleur du texte), bg (couleur du fond), font, state…
  2. Garder une information d’un clic à l’autre : la variable compteur est définie dans le programme principal. Comme la fonction la modifie, elle doit l’annoncer avec global compteur ; sinon Python la considère comme une variable locale et lève l’erreur UnboundLocalError. Une fonction qui se contente de lire une variable globale, ou d’appeler une méthode d’un widget comme affichage.config(...), n’a pas besoin de global.

Le piège des parenthèses

Question. Que se passe-t-il si l’on écrit command=cliquer() au lieu de command=cliquer ?

Réponse

Avec les parenthèses, Python appelle la fonction tout de suite, au moment où il crée le bouton : le compteur passe à 1 avant même l’affichage de la fenêtre. Ensuite, command reçoit la valeur renvoyée par cliquer(), c’est-à-dire None, et le bouton ne fait plus rien.

Sans parenthèses, on donne le nom de la fonction : tkinter la garde de côté et l’appellera lui-même, à chaque clic.

À l’inverse, à l’intérieur d’une fonction, écrire valider sans parenthèses ne fait rien du tout : Python lit le nom de la fonction sans l’appeler. Pour appeler une fonction soi-même, il faut toujours les parenthèses : valider().

On écritSignification
command=valider« appelle valider plus tard, à chaque clic » : correct
command=valider()appelle valider tout de suite et donne son résultat au bouton : erreur
valider() dans une fonctionappelle valider maintenant : correct
valider seul dans une fonctionne fait rien : erreur

Lire une saisie

Un champ Entry permet à l’utilisateur de taper du texte. Le programme lit ce texte avec la méthode get(), au moment où il en a besoin, en général quand on clique sur un bouton.

import tkinter as tk


def verifier():
    """Compare la réponse tapée au résultat de 7 x 8."""
    saisie = champ.get()               # le texte tapé, toujours une chaîne
    if saisie == "":
        resultat.config(text="Taper une réponse.", fg="black")
    elif saisie.isdigit() and int(saisie) == 56:
        resultat.config(text="Bravo !", fg="green")
    else:
        resultat.config(text=f"Non, 7 x 8 = 56 et non {saisie}.", fg="red")
    champ.delete(0, "end")             # vider le champ


fenetre = tk.Tk()
fenetre.title("Calcul mental")

question = tk.Label(fenetre, text="Combien font 7 x 8 ?", font=("Arial", 18))
question.pack(padx=30, pady=10)

champ = tk.Entry(fenetre, width=10, font=("Arial", 18), justify="center")
champ.pack()

bouton = tk.Button(fenetre, text="Vérifier", command=verifier)
bouton.pack(pady=10)

resultat = tk.Label(fenetre, text="", font=("Arial", 16))
resultat.pack(pady=(0, 15))

fenetre.mainloop()
Une fenêtre « Calcul mental » : la question « Combien font 7 x 8 ? », un champ, un bouton « Vérifier » et le message vert « Bravo ! ».

Après avoir tapé 56 et cliqué sur « Vérifier ».

Trois règles évitent la plupart des erreurs.

  1. champ.get() renvoie toujours une chaîne, même si l’utilisateur a tapé un nombre : "56" et non 56. Pour calculer, on convertit avec int() ou float(), exactement comme avec input().
  2. On compare le texte du champ, pas le champ lui-même. La condition champ == 56 est toujours fausse : champ est un widget, pas un nombre. Il faut écrire int(champ.get()) == 56.
  3. On vérifie la saisie avant de la convertir. int("") ou int("abc") lève l’erreur ValueError. La méthode isdigit() renvoie True si la chaîne n’est faite que de chiffres (elle refuse donc aussi les nombres négatifs) : on ne convertit qu’après ce test.

La méthode champ.delete(0, "end") efface le contenu du champ, du premier caractère (indice 0) jusqu’à la fin. Pour y écrire un texte, on utilise champ.insert(0, "texte").

Question. L’utilisateur tape 56 (avec une espace à la fin). Quel message s’affiche ?

Réponse

« Non, 7 x 8 = 56 et non 56 . » : la chaîne "56 " contient une espace, donc isdigit() renvoie False. Pour être plus tolérant, on peut retirer les espaces au début et à la fin avec saisie = champ.get().strip().

Valider avec la touche Entrée

Pour que la touche Entrée fasse la même chose que le bouton, on lie l’événement « touche Entrée » à une fonction avec bind :

champ.bind("<Return>", verifier)

Une fonction appelée par bind reçoit toujours un argument : un objet qui décrit l’événement (touche enfoncée, position de la souris…). Une fonction appelée par command n’en reçoit aucun. Pour qu’une même fonction serve aux deux, on lui donne un paramètre avec une valeur par défaut :

def verifier(event=None):
    ...


bouton = tk.Button(fenetre, text="Vérifier", command=verifier)   # appel sans argument
champ.bind("<Return>", verifier)                                 # appel avec un argument

Les noms d’événements s’écrivent entre chevrons :

ÉvénementSignification
"<Return>"touche Entrée
"<KeyPress>"n’importe quelle touche ; event.keysym donne son nom ("a", "Left", "space"…)
"<Left>", "<Right>", "<Up>", "<Down>"flèches du clavier
"<Button-1>"clic gauche ; event.x et event.y donnent la position du clic
"<Motion>"déplacement de la souris

Disposer les widgets

Tkinter propose trois façons de placer les widgets dans une fenêtre. On en choisit une seule par conteneur (la fenêtre ou un même cadre) : mélanger pack et grid dans le même conteneur provoque une erreur.

MéthodePrincipeUsage
pack()empile les widgets, de haut en bas par défautinterfaces simples, en colonne ou en ligne
grid(row=..., column=...)range les widgets dans un tableau de lignes et de colonnesformulaires, claviers, grilles de jeu
place(x=..., y=...)position exacte, en pixelsdécors, superpositions

Une grille : le calcul de l’IMC

Un formulaire s’organise naturellement en grille : les libellés dans la colonne 0, les champs dans la colonne 1.

import tkinter as tk


def calculer():
    """Calcule l'indice de masse corporelle : masse / taille²."""
    try:
        masse = float(champ_masse.get().replace(",", "."))
        taille = float(champ_taille.get().replace(",", "."))
        imc = masse / taille ** 2
        resultat.config(text=f"IMC : {imc:.1f}")
    except ValueError:
        resultat.config(text="Saisir deux nombres.")
    except ZeroDivisionError:
        resultat.config(text="La taille ne peut pas être nulle.")


fenetre = tk.Tk()
fenetre.title("Indice de masse corporelle")

tk.Label(fenetre, text="Masse (kg) :").grid(row=0, column=0, sticky="e", padx=5, pady=5)
champ_masse = tk.Entry(fenetre, width=10)
champ_masse.grid(row=0, column=1, padx=5, pady=5)

tk.Label(fenetre, text="Taille (m) :").grid(row=1, column=0, sticky="e", padx=5, pady=5)
champ_taille = tk.Entry(fenetre, width=10)
champ_taille.grid(row=1, column=1, padx=5, pady=5)

tk.Button(fenetre, text="Calculer", command=calculer).grid(row=2, column=0, columnspan=2, pady=5)

resultat = tk.Label(fenetre, text="", font=("Arial", 16))
resultat.grid(row=3, column=0, columnspan=2, pady=(0, 10))

fenetre.mainloop()
Une fenêtre « Indice de masse corporelle » avec deux champs remplis (62 et 1,70), un bouton « Calculer » et le résultat « IMC : 21.5 ».

Le formulaire après un calcul.

  • sticky="e" colle le libellé au côté droit (east) de sa case, contre le champ. Les autres valeurs sont "w" (gauche), "n" (haut) et "s" (bas).
  • columnspan=2 fait occuper deux colonnes au bouton et au résultat, qui se retrouvent centrés.
  • padx et pady ajoutent une marge, en pixels, autour du widget.
  • Quand un widget n’a jamais besoin d’être modifié (les libellés), on peut le créer et le placer en une ligne, sans le ranger dans une variable. Mais grid() renvoie None : écrire champ = tk.Entry(fenetre).grid(...) range None dans champ, et champ.get() lève alors AttributeError. Un widget qu’on utilisera plus tard se crée et se place en deux lignes.

Le bloc try … except attrape les erreurs de conversion : si l’utilisateur tape des lettres, float() lève ValueError, et au lieu de planter, le programme affiche un message. L’appel replace(",", ".") accepte la virgule, que tout le monde tape en français.

Des cadres pour organiser la fenêtre

Un Frame est un conteneur invisible : on y range plusieurs widgets, puis on place le cadre comme un seul bloc. Chaque cadre a sa propre disposition : on peut ainsi utiliser pack dans la fenêtre et grid à l’intérieur d’un cadre.

import tkinter as tk

fenetre = tk.Tk()
fenetre.title("Cadres")

titre = tk.Label(fenetre, text="Clavier numérique", font=("Arial", 16))
titre.pack(pady=5)

clavier = tk.Frame(fenetre)          # un cadre pour les boutons
clavier.pack(padx=10, pady=10)

for i in range(9):
    chiffre = i + 1
    bouton = tk.Button(clavier, text=str(chiffre), width=3)
    bouton.grid(row=i // 3, column=i % 3)

fenetre.mainloop()
Une fenêtre « Cadres » avec le titre « Clavier numérique » et neuf boutons de 1 à 9 disposés en trois lignes de trois.

Le titre est placé avec pack, les boutons avec grid dans un cadre.

Question. Dans quelle ligne et dans quelle colonne se trouve le bouton 6 ?

Réponse

Le bouton 6 est créé quand i vaut 5 : il est en ligne 5 // 3 = 1 et en colonne 5 % 3 = 2, c’est-à-dire sur la deuxième ligne, dans la troisième colonne (on compte à partir de 0). Le quotient // donne la ligne, le reste % la colonne.

Des boutons qui transmettent une valeur

Les neuf boutons du clavier ci-dessus ne font rien. Pour qu’ils affichent leur chiffre, il faudrait que chacun appelle une fonction avec son chiffre en argument. Or command attend une fonction sans argument, et command=ajouter(chiffre) appellerait la fonction tout de suite.

La solution la plus simple est d’écrire une petite fonction par bouton :

def ajouter_1():
    ajouter(1)


def ajouter_2():
    ajouter(2)

C’est correct mais répétitif. Beaucoup de programmes utilisent à la place une fonction anonyme, écrite avec le mot-clé lambda (hors programme) : lambda: ajouter(1) est une fonction sans nom et sans paramètre, qui appelle ajouter(1) quand on l’appelle, c’est-à-dire au moment du clic.

import tkinter as tk


def ajouter(chiffre):
    """Ajoute le chiffre cliqué à la fin de l'écran."""
    ecran.config(text=ecran.cget("text") + str(chiffre))


def effacer():
    ecran.config(text="")


fenetre = tk.Tk()
fenetre.title("Clavier")

ecran = tk.Label(fenetre, text="", width=12, font=("Courier", 22),
                 bg="white", relief="sunken")
ecran.pack(padx=10, pady=10)

clavier = tk.Frame(fenetre)
clavier.pack(padx=10, pady=(0, 10))

for i in range(9):
    chiffre = i + 1
    bouton = tk.Button(clavier, text=str(chiffre), width=3,
                       command=lambda c=chiffre: ajouter(c))
    bouton.grid(row=i // 3, column=i % 3)

tk.Button(clavier, text="C", width=3, command=effacer).grid(row=3, column=1)

fenetre.mainloop()
Une fenêtre « Clavier » : un écran blanc affiche 1984, sous lequel se trouvent les boutons de 1 à 9 et un bouton C.

Après des clics sur 1, 9, 8 et 4.

La méthode cget("text") lit une option d’un widget : ici, le texte déjà affiché sur l’écran.

Pourquoi écrire lambda c=chiffre: ajouter(c) et pas simplement lambda: ajouter(chiffre) ? Une fonction anonyme ne lit la variable chiffre qu’au moment du clic. À ce moment-là, la boucle est terminée depuis longtemps et chiffre vaut 9 : tous les boutons ajouteraient 9. Écrire c=chiffre donne au paramètre c une valeur par défaut, calculée à la création du bouton : chaque bouton garde ainsi son propre chiffre.

Question. Le clavier n’a pas de touche 0. Quelle ligne faut-il ajouter, et où placer le bouton pour qu’il soit à gauche du bouton C ?

Réponse
tk.Button(clavier, text="0", width=3, command=lambda: ajouter(0)).grid(row=3, column=0)

Le bouton C est en ligne 3, colonne 1 : le 0 va en ligne 3, colonne 0. Ici, pas besoin de c=..., car la valeur 0 est écrite directement, sans variable de boucle.

Le temps : after remplace while et sleep

Dans un programme séquentiel, on attend avec time.sleep(1) et on répète avec while. Dans une interface graphique, ces deux instructions bloquent la fenêtre : pendant qu’une fonction tourne, la boucle d’événements est arrêtée. La fenêtre ne se redessine plus, les boutons ne répondent plus, et le système finit par signaler que l’application ne répond pas.

La règle est donc : un gestionnaire d’événement doit se terminer vite. Pour faire quelque chose plus tard, on demande à tkinter de rappeler une fonction après un délai, avec fenetre.after(delai, fonction) ; le délai est en millisecondes.

import tkinter as tk

restant = 10


def decompter():
    """Appelée toutes les secondes jusqu'à zéro."""
    global restant
    affichage.config(text=str(restant))
    if restant > 0:
        restant = restant - 1
        fenetre.after(1000, decompter)   # rappel dans 1000 ms
    else:
        affichage.config(text="Temps écoulé !", fg="red")


fenetre = tk.Tk()
fenetre.title("Compte à rebours")
affichage = tk.Label(fenetre, text="", font=("Arial", 40), width=12)
affichage.pack(padx=20, pady=20)

decompter()          # premier appel : la suite se fait toute seule
fenetre.mainloop()
Une fenêtre « Compte à rebours » qui affiche le nombre 7 en grand.

Le compte à rebours, trois secondes après le lancement.

after n’attend pas : il programme un appel et rend la main tout de suite. La fonction se reprogramme elle-même tant que le décompte n’est pas fini, et entre deux appels, la boucle d’événements reste libre de traiter les clics.

InstantAppelAffichérestant après l’appelNouvel appel programmé ?
0 s1er109oui, à 1 s
1 s2e98oui, à 2 s
……………
9 s10e10oui, à 10 s
10 s11e« Temps écoulé ! » (le 0, aussitôt remplacé, n’est jamais visible)0non

Question. Pourquoi ce programme ne fonctionne-t-il pas ?

def decompter():
    for n in range(10, 0, -1):
        affichage.config(text=str(n))
        time.sleep(1)
Réponse

Pendant les dix secondes de la boucle, la fonction ne rend pas la main à mainloop() : la fenêtre n’est jamais redessinée. Elle reste figée, puis affiche directement 1 à la fin. Les modifications d’un widget ne deviennent visibles qu’au retour dans la boucle d’événements.

Dessiner dans un Canvas

Un Canvas est une zone de dessin. On y crée des formes : rectangles, ovales, lignes, polygones, textes, images. Chaque création renvoie un numéro qui identifie la forme ; on s’en sert ensuite pour la déplacer, la modifier ou la supprimer.

Attention au repère : comme dans presque toutes les bibliothèques graphiques, l’origine (0, 0) est le coin supérieur gauche du canevas, et l’axe des y va vers le bas. C’est l’inverse de turtle et des mathématiques.

import tkinter as tk

fenetre = tk.Tk()
fenetre.title("Dessin")
dessin = tk.Canvas(fenetre, width=400, height=250, bg="lightblue")
dessin.pack()

dessin.create_rectangle(0, 200, 400, 250, fill="forestgreen", outline="")  # herbe
dessin.create_oval(300, 30, 360, 90, fill="gold", outline="orange")        # soleil
dessin.create_rectangle(80, 120, 200, 200, fill="wheat")                   # maison
dessin.create_polygon(70, 120, 140, 60, 210, 120, fill="firebrick")        # toit
dessin.create_line(0, 230, 400, 230, fill="darkgreen", width=3)            # chemin
dessin.create_text(140, 225, text="Ma maison", font=("Arial", 14), fill="white")

fenetre.mainloop()
Un canevas bleu clair avec une maison beige au toit rouge, un soleil jaune et une bande d'herbe verte portant le texte « Ma maison ».

Un dessin fait de six formes.

MéthodeCoordonnéesExemple
create_rectangle(x1, y1, x2, y2)deux coins opposéscreate_rectangle(80, 120, 200, 200, fill="wheat")
create_oval(x1, y1, x2, y2)rectangle qui contient l’ovaleun cercle si le rectangle est un carré
create_line(x1, y1, x2, y2, ...)les points successifscreate_line(0, 230, 400, 230, width=3)
create_polygon(x1, y1, x2, y2, ...)les sommetsle toit : trois sommets
create_text(x, y, text=...)centre du textecreate_text(140, 225, text="Ma maison")
move(forme, dx, dy)décalagedessin.move(balle, 5, 0)
coords(forme)renvoie les coordonnées actuelles[x1, y1, x2, y2]
itemconfig(forme, ...)modifie une optiondessin.itemconfig(balle, fill="red")
delete(forme)supprime une forme ; delete("all") efface toutdessin.delete(balle)

Question. Le soleil est créé par create_oval(300, 30, 360, 90). Quel est son rayon, et où est son centre ?

Réponse

Le rectangle qui le contient mesure 60 pixels de côté : c’est un cercle de rayon 30, de centre (330, 60), c’est-à-dire 330 pixels à droite du bord gauche et 60 pixels sous le bord haut.

Un petit jeu : déplacer un objet au clavier

On lie les flèches du clavier à des fonctions qui déplacent une forme avec move. Pour recevoir les touches, on lie l’événement à la fenêtre entière.

import tkinter as tk

LARGEUR, HAUTEUR = 400, 300
PAS = 10

fenetre = tk.Tk()
fenetre.title("Flèches du clavier")
dessin = tk.Canvas(fenetre, width=LARGEUR, height=HAUTEUR, bg="white")
dessin.pack()

joueur = dessin.create_oval(180, 130, 220, 170, fill="royalblue")


def deplacer(event):
    """Déplace le joueur selon la flèche enfoncée, sans sortir du canevas."""
    x1, y1, x2, y2 = dessin.coords(joueur)
    dx, dy = 0, 0
    if event.keysym == "Left" and x1 > 0:
        dx = -PAS
    elif event.keysym == "Right" and x2 < LARGEUR:
        dx = PAS
    elif event.keysym == "Up" and y1 > 0:
        dy = -PAS
    elif event.keysym == "Down" and y2 < HAUTEUR:
        dy = PAS
    dessin.move(joueur, dx, dy)


fenetre.bind("<KeyPress>", deplacer)
fenetre.mainloop()

La flèche du haut diminue y : c’est la conséquence du repère tourné vers le bas. Les tests x1 > 0, x2 < LARGEUR… empêchent le joueur de sortir du canevas.

Une animation avec after

Pour qu’un objet bouge tout seul, on combine move et after : une fonction déplace l’objet d’un petit pas, puis se reprogramme 20 millisecondes plus tard, soit environ 50 images par seconde.

import tkinter as tk

LARGEUR, HAUTEUR = 400, 300

fenetre = tk.Tk()
fenetre.title("Rebonds")
dessin = tk.Canvas(fenetre, width=LARGEUR, height=HAUTEUR, bg="black")
dessin.pack()

balle = dessin.create_oval(20, 20, 50, 50, fill="orange")
vx, vy = 4, 3


def animer():
    global vx, vy
    x1, y1, x2, y2 = dessin.coords(balle)
    if x1 + vx < 0 or x2 + vx > LARGEUR:
        vx = -vx                      # rebond sur un bord vertical
    if y1 + vy < 0 or y2 + vy > HAUTEUR:
        vy = -vy                      # rebond sur un bord horizontal
    dessin.move(balle, vx, vy)
    fenetre.after(20, animer)


animer()
fenetre.mainloop()

Cases à cocher, boutons radio et variables tkinter

Une case à cocher ou un groupe de boutons radio stocke son état dans une variable tkinter : tk.IntVar() contient un entier, tk.StringVar() une chaîne. On lit sa valeur avec get() et on la modifie avec set(). Tous les boutons radio d’un même groupe partagent la même variable : cocher l’un décoche les autres.

import tkinter as tk

question = "Quel est le type de la valeur 3.0 ?"
choix = ["int", "float", "str", "bool"]
bonne = "float"


def valider():
    if reponse.get() == "aucune":
        message.config(text="Choisir une réponse.", fg="black")
    elif reponse.get() == bonne:
        message.config(text="Bonne réponse !", fg="green")
    else:
        message.config(text=f"Non : c'est {bonne}.", fg="red")


fenetre = tk.Tk()
fenetre.title("QCM")
tk.Label(fenetre, text=question, font=("Arial", 16)).pack(padx=20, pady=10)

reponse = tk.StringVar(value="aucune")    # aucune case cochée au départ
for c in choix:
    tk.Radiobutton(fenetre, text=c, variable=reponse, value=c,
                   font=("Arial", 14)).pack(anchor="w", padx=40)

tk.Button(fenetre, text="Valider", command=valider).pack(pady=10)
message = tk.Label(fenetre, text="", font=("Arial", 14))
message.pack(pady=(0, 10))

fenetre.mainloop()
Une fenêtre « QCM » posant la question « Quel est le type de la valeur 3.0 ? », avec quatre boutons radio (int, float coché, str, bool), un bouton « Valider » et le message vert « Bonne réponse ! ».

Le QCM après avoir coché « float » et validé.

Chaque Radiobutton reçoit variable=reponse (la variable du groupe) et value=c (la valeur que prend la variable quand on le coche). Pour une case à cocher, on utilise de même tk.Checkbutton(fenetre, text="...", variable=v) avec v = tk.IntVar() : v.get() vaut 1 si la case est cochée, 0 sinon.

Une image dans la fenêtre

Tkinter affiche directement les images PNG et GIF avec tk.PhotoImage. Pour d’autres formats (JPEG) ou pour redimensionner, on utilise la bibliothèque Pillow (pip install pillow).

import tkinter as tk

fenetre = tk.Tk()
fenetre.title("Image")

logo = tk.PhotoImage(file="logo.png")   # fichier dans le même dossier
cadre_image = tk.Label(fenetre, image=logo)
cadre_image.pack(padx=10, pady=10)

tk.Label(fenetre, text="Une image dans un Label").pack(pady=(0, 10))
fenetre.mainloop()
Une fenêtre « Image » qui affiche un logo, un disque bleu marqué « NSI », au-dessus du texte « Une image dans un Label ».

Une image PNG affichée dans une étiquette.

Deux précautions :

  • le fichier doit se trouver dans le dossier courant, qui n’est pas toujours celui du programme. Le plus sûr est de construire le chemin à partir de l’emplacement du programme, comme dans la page Pygame ;
  • la variable qui contient l’image (logo) doit exister tant que la fenêtre est ouverte. Si l’image est créée dans une fonction et rangée dans une variable locale, Python la supprime à la fin de la fonction et l’étiquette devient vide. On la range alors dans une variable globale, ou on la garde dans le widget : cadre_image.image = logo.

Un projet complet : dix questions de calcul mental

Ce programme réunit tout ce qui précède : il pose dix multiplications tirées au hasard, compte les bonnes réponses, puis affiche le score. Il n’y a pas de boucle while pour enchaîner les questions : c’est chaque validation qui prépare la question suivante.

import random
import tkinter as tk

NB_QUESTIONS = 10
numero = 0          # numéro de la question en cours
score = 0
a, b = 0, 0         # les deux facteurs de la question en cours


def nouvelle_question():
    """Tire une nouvelle multiplication et l'affiche, ou termine la partie."""
    global numero, a, b
    if numero == NB_QUESTIONS:
        question.config(text=f"Score final : {score} / {NB_QUESTIONS}")
        champ.config(state="disabled")
        bouton.config(state="disabled")
        return
    numero = numero + 1
    a = random.randint(2, 9)
    b = random.randint(2, 9)
    question.config(text=f"Question {numero} : {a} x {b} = ?")
    champ.delete(0, "end")
    champ.focus_set()                 # le curseur va dans le champ


def valider(event=None):
    """Vérifie la réponse, met à jour le score, passe à la question suivante."""
    global score
    if champ.cget("state") == "disabled":   # partie terminée : Entrée ne fait plus rien
        return
    saisie = champ.get().strip()
    if not saisie.isdigit():
        message.config(text="Taper un nombre.", fg="black")
        return
    if int(saisie) == a * b:
        score = score + 1
        message.config(text="Juste !", fg="green")
    else:
        message.config(text=f"Faux : {a} x {b} = {a * b}", fg="red")
    affichage_score.config(text=f"Score : {score}")
    nouvelle_question()


fenetre = tk.Tk()
fenetre.title("Calcul mental")

question = tk.Label(fenetre, text="", font=("Arial", 20))
question.pack(padx=30, pady=(15, 5))

champ = tk.Entry(fenetre, width=6, font=("Arial", 20), justify="center")
champ.pack()
champ.bind("<Return>", valider)

bouton = tk.Button(fenetre, text="Valider", command=valider)
bouton.pack(pady=8)

message = tk.Label(fenetre, text="", font=("Arial", 14))
message.pack()
affichage_score = tk.Label(fenetre, text="Score : 0", font=("Arial", 14))
affichage_score.pack(pady=(0, 15))

nouvelle_question()
fenetre.mainloop()
Une fenêtre « Calcul mental » affichant « Question 4 : 9 x 6 = ? », un champ, un bouton « Valider », le message vert « Juste ! » et « Score : 3 ».

La partie en cours : question 4, trois bonnes réponses.

L’état de la partie tient dans quatre variables globales : numero, score, a et b. Les deux fonctions les lisent et les modifient ; c’est pourquoi chacune déclare avec global celles qu’elle modifie. Le déroulement est le suivant :

  1. au lancement, nouvelle_question() affiche la question 1 ;
  2. à chaque validation, valider() corrige la réponse puis appelle nouvelle_question() ;
  3. quand nouvelle_question() est appelée alors que numero vaut déjà 10, c’est-à-dire après la dixième réponse, elle affiche le score et désactive le champ et le bouton (state="disabled") au lieu de poser une onzième question.

Question. On veut rejouer sans relancer le programme. Que doit faire une fonction rejouer(), appelée par un bouton « Rejouer » ?

Réponse

Remettre l’état à zéro, réactiver les widgets, puis poser la première question :

def rejouer():
    global numero, score
    numero = 0
    score = 0
    affichage_score.config(text="Score : 0")
    message.config(text="")
    champ.config(state="normal")
    bouton.config(state="normal")
    nouvelle_question()

Il ne faut pas détruire la fenêtre pour en recréer une : on met simplement à jour les widgets existants.

Plusieurs écrans et boîtes de dialogue

Un jeu a souvent plusieurs écrans : accueil, partie, résultats. Il ne faut pas créer une nouvelle fenêtre tk.Tk() à chaque fois : un programme n’a qu’une fenêtre principale. Deux solutions :

  • changer le contenu de la fenêtre : chaque écran est un Frame ; on cache l’un avec pack_forget() et on affiche l’autre avec pack() ;
  • ouvrir une fenêtre secondaire avec tk.Toplevel(fenetre), qui s’utilise comme la fenêtre principale mais se ferme sans arrêter le programme.

Le module messagebox affiche des boîtes de dialogue toutes prêtes :

from tkinter import messagebox

messagebox.showinfo("Fin de la partie", "Score : 8 / 10")       # information
messagebox.showerror("Erreur", "Le fichier est introuvable.")   # erreur
if messagebox.askyesno("Quitter", "Voulez-vous vraiment quitter ?"):
    fenetre.destroy()                                            # ferme la fenêtre

askyesno renvoie True si l’on clique sur « Oui ». fenetre.destroy() ferme la fenêtre principale, ce qui termine mainloop() et donc le programme.

Erreurs fréquentes

SymptômeCauseCorrection
la fonction s’exécute au lancement, puis le bouton ne fait plus riencommand=valider()command=valider, sans parenthèses
une fonction ne fait rienvalider écrit sans parenthèses à l’intérieur d’une autre fonctionvalider()
la réponse juste est refuséecomparaison du widget au lieu de son texte, ou d’une chaîne à un nombreint(champ.get()) == resultat
ValueError: invalid literal for int() with base 10: ''conversion d’un champ vide ou non numériquetester saisie.isdigit() avant int(), ou try … except ValueError
AttributeError: 'NoneType' object has no attribute 'get'champ = tk.Entry(...).grid(...) : grid renvoie Nonecréer, puis placer, en deux lignes
TypeError: verifier() takes 0 positional arguments but 1 was givenfonction appelée par bind, qui transmet un événementdef verifier(event=None):
UnboundLocalError: cannot access local variable 'score'la fonction modifie une variable globaleglobal score en tête de la fonction
TclError: cannot use geometry manager grid inside . which already has slaves managed by packpack et grid mélangés dans le même conteneurune seule méthode par conteneur, ou des Frame
la fenêtre se figewhile ou time.sleep dans un gestionnairefenetre.after(delai, fonction)
tous les boutons créés dans une boucle font la même choselambda: f(i) lit i au moment du cliclambda i=i: f(i)
l’image n’apparaît pasl’image n’est rangée que dans une variable localela garder dans une variable globale
un widget n’apparaît pasil n’a jamais été placéappeler pack(), grid() ou place()
une fenêtre vide apparaît en plusplusieurs tk.Tk() dans le programmeune seule tk.Tk(), des Toplevel pour les autres fenêtres

Pour aller plus loin

  • Compléter le clavier numérique avec un bouton 0 et un bouton « Valider » qui compare le nombre tapé à un nombre mystère.
  • Ajouter au calcul mental un compte à rebours de 5 secondes par question, avec after.
  • Transformer le QCM en questionnaire de plusieurs questions rangées dans une liste de dictionnaires ({"question": ..., "choix": [...], "bonne": ...}).
  • La bibliothèque customtkinter (pip install customtkinter) donne un aspect plus moderne aux mêmes widgets : customtkinter.CTkButton remplace tk.Button, et tout ce qui précède reste valable.
  • La documentation de tkinter (en français).

À retenir

  1. Une interface graphique est événementielle : le programme crée les widgets, associe des fonctions aux événements, puis lance mainloop(), qui attend et appelle ces fonctions.
  2. Un widget se crée (tk.Label(fenetre, ...)) puis se place (pack, grid ou place), une seule méthode de placement par conteneur.
  3. command=f et bind("<Return>", f) donnent le nom de la fonction, sans parenthèses ; une fonction appelée par bind reçoit un argument event.
  4. champ.get() renvoie toujours une chaîne : on la teste, puis on la convertit avec int() ou float().
  5. widget.config(text=...) modifie un widget déjà affiché.
  6. Une fonction qui modifie une variable du programme principal la déclare avec global.
  7. Jamais de while ni de time.sleep dans un gestionnaire : fenetre.after(delai, f) programme un appel futur.
  8. Dans un Canvas, l’origine est en haut à gauche et y augmente vers le bas.

Résumé

ConceptSyntaxeExemple
Fenêtretk.Tk(), title, geometryfenetre.geometry("400x300")
Textetk.Label(parent, text=...)tk.Label(fenetre, text="Score : 0")
Boutontk.Button(parent, text=..., command=f)command=valider
Champtk.Entry(parent), get(), delete(0, "end")int(champ.get())
Modifierwidget.config(option=valeur)message.config(text="Bravo", fg="green")
Placerpack(), grid(row, column), place(x, y)grid(row=0, column=1, sticky="e")
Regroupertk.Frame(parent)un cadre par zone de la fenêtre
Événementwidget.bind("<Evenement>", f), f(event)champ.bind("<Return>", valider)
Bouton avec valeurcommand=lambda v=v: f(v)lambda c=chiffre: ajouter(c)
Tempsfenetre.after(ms, f)fenetre.after(1000, decompter)
Dessintk.Canvas, create_..., move, coordsdessin.move(balle, vx, vy)
Choixtk.StringVar(), Radiobutton, Checkbuttonreponse.get()
Imagetk.PhotoImage(file=...)garder la variable
Boucle d’événementsfenetre.mainloop()dernière ligne