Python · Interfaces graphiques

Tkinter

Créer des interfaces graphiques natives en Python — widgets, layouts, événements, formulaires, dialogues, Canvas et architecture POO. Inclus dans Python, aucune installation requise.

Introduction & fenêtre principale

🖥️

Tkinter est la bibliothèque GUI standard de Python — elle est incluse dans toute installation Python, aucun pip install nécessaire. Elle fournit des widgets natifs (look Windows/macOS/Linux) et une boucle d'événements intégrée.

Fenêtre minimale
import tkinter as tk
from tkinter import ttk, messagebox, filedialog

# Fenêtre principale
root = tk.Tk()
root.title("Mon Application")
root.geometry("600x400")       # largeur x hauteur
root.minsize(400, 300)          # taille minimale
root.resizable(True, True)      # redimensionnable H/V

# Centrer la fenêtre sur l'écran
w, h = 600, 400
x = (root.winfo_screenwidth()  - w) // 2
y = (root.winfo_screenheight() - h) // 2
root.geometry(f"{w}x{h}+{x}+{y}")

# Icône (fichier .ico Windows)
# root.iconbitmap("icon.ico")

# Boucle d'événements — TOUJOURS EN DERNIER
root.mainloop()
Mon Application
Contenu de la fenêtre ici
💡

mainloop() est la boucle d'événements — elle garde la fenêtre ouverte et attend les actions de l'utilisateur. Tout le code avant mainloop() est de l'initialisation. Tout le code après ne s'exécute qu'à la fermeture de la fenêtre.

🎨

Tkinter utilise le look natif de l'OS. Pour un look plus moderne et cohérent entre plateformes, utiliser ttk (Themed Tk) qui sera couvert en section 10.

Widgets de base

Label
Affiche du texte ou une image. Non interactif.
Button
Bouton cliquable qui déclenche un callback.
Entry
Champ de saisie texte sur une ligne.
Text
Zone de texte multi-lignes éditable.
Checkbutton
Case à cocher (BooleanVar).
Radiobutton
Bouton radio — un seul actif dans le groupe.
Listbox
Liste de choix sélectionnables.
Combobox (ttk)
Liste déroulante avec saisie libre possible.
Scale
Curseur pour valeur numérique.
Spinbox
Champ numérique avec boutons ±.
Frame
Conteneur invisible pour grouper des widgets.
LabelFrame
Frame avec titre affiché en bordure.
Canvas
Zone de dessin 2D (formes, images, animations).
Scrollbar
Barre de défilement à coupler avec Text/Listbox.
Menu
Barre de menu et menus contextuels.
Notebook (ttk)
Onglets pour organiser les vues.
Widgets essentiels — syntaxe
# Label
lbl = tk.Label(root, text="Nom :", font=("Arial", 12),
               fg="#333", bg="#f0f0f0")

# Button
btn = tk.Button(root, text="Valider", command=on_submit,
                bg="#0078d4", fg="white", relief="flat",
                padx=10, pady=5, cursor="hand2")

# Entry (champ texte)
entry = tk.Entry(root, width=30, show="")      # show="*" = mot de passe
entry.insert(0, "Texte par défaut")
valeur = entry.get()

# Text (multi-lignes)
txt = tk.Text(root, width=40, height=8, wrap=tk.WORD)
txt.insert(tk.END, "Contenu initial\n")
contenu = txt.get("1.0", tk.END)  # "ligne.colonne"

# Checkbutton
var_cb = tk.BooleanVar(value=False)
cb = tk.Checkbutton(root, text="Accepter les CGU", variable=var_cb)
if var_cb.get(): pass  # lire la valeur

# Radiobutton
choix = tk.StringVar(value="option1")
tk.Radiobutton(root, text="Option A", variable=choix, value="option1")
tk.Radiobutton(root, text="Option B", variable=choix, value="option2")
Listbox, Scale, Combobox
# Listbox avec scrollbar
frame_lb = tk.Frame(root)
scrollbar = tk.Scrollbar(frame_lb)
listbox = tk.Listbox(frame_lb, yscrollcommand=scrollbar.set,
                     selectmode=tk.SINGLE, height=6)
scrollbar.config(command=listbox.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

for item in ["Alice", "Bob", "Charlie"]:
    listbox.insert(tk.END, item)

selected = listbox.get(listbox.curselection())  # élément sélectionné

# Scale (curseur)
scale = tk.Scale(root, from_=0, to=100,
                 orient=tk.HORIZONTAL, label="Volume")
val = scale.get()

# Combobox (ttk)
combo = ttk.Combobox(root, values=["Python", "Java", "C++"],
                     state="readonly")
combo.current(0)        # sélectionner le premier
lang = combo.get()

# Menu barre principale
menubar = tk.Menu(root)
file_menu = tk.Menu(menubar, tearoff=0)
file_menu.add_command(label="Ouvrir", command=open_file, accelerator="Ctrl+O")
file_menu.add_separator()
file_menu.add_command(label="Quitter", command=root.quit)
menubar.add_cascade(label="Fichier", menu=file_menu)
root.config(menu=menubar)

Layouts — pack, grid, place

pack()
Empilement automatique (haut/bas/gauche/droite). Simple mais peu précis. Idéal pour les layouts linéaires.
grid()
Grille de lignes/colonnes. Le plus utilisé pour les formulaires et les interfaces structurées.
place()
Position absolue (x, y) ou relative. Précis mais ne s'adapte pas au redimensionnement.
pack() — empilement
# side : TOP (défaut), BOTTOM, LEFT, RIGHT
# fill : NONE, X, Y, BOTH
# expand : True = prendre l'espace disponible

tk.Label(root, text="En-tête").pack(
    side=tk.TOP, fill=tk.X, padx=5, pady=5)

frame = tk.Frame(root)
frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True)

sidebar = tk.Frame(frame, width=150, bg="#2d2d2d")
sidebar.pack(side=tk.LEFT, fill=tk.Y)

content = tk.Frame(frame, bg="white")
content.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)

tk.Label(root, text="Barre de statut").pack(
    side=tk.BOTTOM, fill=tk.X)
place() — position absolue
# Position absolue
btn.place(x=100, y=200)

# Position relative (0.0 → 1.0)
btn.place(relx=0.5, rely=0.5,   # centre
          anchor=tk.CENTER)       # point d'ancrage

# Taille relative
entry.place(relx=0.1, rely=0.3,
            relwidth=0.8, height=30)
grid() — formulaire structuré
# row, column — index de ligne/colonne (commence à 0)
# sticky — N S E W ou combinaisons (comme CSS)
# columnspan / rowspan — fusion de cellules
# padx/pady — espacement externe
# ipadx/ipady — espacement interne

for i, (label, var) in enumerate([
    ("Prénom :", "prenom"),
    ("Nom :",    "nom"),
    ("Email :",  "email"),
]):
    tk.Label(root, text=label).grid(
        row=i, column=0, sticky=tk.E, padx=5, pady=3)
    tk.Entry(root, width=25).grid(
        row=i, column=1, sticky=tk.EW, padx=5, pady=3)

# Fusionner sur 2 colonnes
btn.grid(row=3, column=0, columnspan=2, pady=10)

# Pondération — colonne 1 s'étire au redimensionnement
root.columnconfigure(1, weight=1)
root.rowconfigure(4, weight=1)  # ligne 4 s'étire
⚠️

Ne jamais mélanger pack() et grid() dans le même conteneur — cela provoque une erreur au runtime. On peut en revanche utiliser grid() dans un Frame et pack() dans un autre Frame.

Variables Tkinter

StringVar, IntVar, BooleanVar, DoubleVar
# Les variables Tkinter sont des "observables" —
# un widget lié se met à jour automatiquement

nom_var    = tk.StringVar(value="")
age_var    = tk.IntVar(value=0)
actif_var  = tk.BooleanVar(value=False)
note_var   = tk.DoubleVar(value=0.0)

# Lier à un widget
entry_nom = tk.Entry(root, textvariable=nom_var)
label_val = tk.Label(root, textvariable=nom_var)  # miroir live !
scale     = tk.Scale(root, variable=age_var, from_=0, to=120)

# Lire / écrire
nom = nom_var.get()
nom_var.set("Alice")

# Trace — réagir à un changement de valeur
def on_nom_change(name, index, mode):
    print(f"Nom changé : {nom_var.get()}")

nom_var.trace_add("write", on_nom_change)

# Modes de trace : "write" | "read" | "unset"
🔗

Les variables Tkinter créent un lien bidirectionnel entre la donnée et le widget. Modifier la variable avec .set() met à jour immédiatement l'affichage du widget lié, et inversement. C'est le mécanisme de base pour le data-binding en Tkinter.

Exemple — label qui reflète un Entry en live
root = tk.Tk()
texte = tk.StringVar()

tk.Label(root, text="Saisir :").pack()
tk.Entry(root, textvariable=texte).pack()

# Ce label se met à jour à chaque frappe
tk.Label(root, textvariable=texte,
         font=("Arial", 18), fg="#818cf8").pack(pady=10)

root.mainloop()

Événements & callbacks

bind() — lier un événement à un widget
# Syntaxe : widget.bind("<Événement>", callback)
# Le callback reçoit un objet Event

def on_click(event):
    print(f"Clic en ({event.x}, {event.y})")

def on_key(event):
    print(f"Touche : {event.keysym}  char : {event.char}")

label.bind("<Button-1>",  on_click)   # clic gauche
label.bind("<Button-3>",  on_rclick)  # clic droit
entry.bind("<KeyPress>",  on_key)     # toute touche
entry.bind("<Return>",    on_submit)  # touche Entrée
entry.bind("<FocusIn>",   on_focus)   # focus reçu
entry.bind("<FocusOut>",  on_blur)    # focus perdu
root.bind("<Escape>",    lambda e: root.quit())
root.bind("<Control-s>", save)        # Ctrl+S

# Survol souris
widget.bind("<Enter>",  lambda e: widget.config(bg="lightblue"))
widget.bind("<Leave>",  lambda e: widget.config(bg="white"))

# Redimensionnement de la fenêtre
root.bind("<Configure>", lambda e: on_resize(e.width, e.height))
after() — exécution différée & boucles
# Exécuter une fonction après N millisecondes
root.after(2000, on_timeout)   # une seule fois

# Boucle récurrente (animation, horloge, polling…)
def update_clock():
    from datetime import datetime
    lbl_time.config(text=datetime.now().strftime("%H:%M:%S"))
    root.after(1000, update_clock)  # se reprogramme

update_clock()  # démarrer la boucle

# Annuler une tâche planifiée
task_id = root.after(5000, some_func)
root.after_cancel(task_id)

# Mise à jour de l'interface depuis un thread
# (ne jamais modifier les widgets depuis un thread secondaire)
root.after(0, lambda: label.config(text="Terminé"))
ÉvénementDescription
<Button-1/2/3>Clic gauche / milieu / droit
<Double-Button-1>Double-clic
<Motion>Mouvement souris (bouton enfoncé)
<MouseWheel>Molette (event.delta)
<Return>Touche Entrée
<Escape>Touche Échap
<Control-z>Ctrl+Z (modifier au besoin)

Formulaires & validation

Formulaire avec validation
import re

class FormFrame(tk.Frame):
    def __init__(self, parent):
        super().__init__(parent, padx=20, pady=20)
        self.vars = {
            "nom":   tk.StringVar(),
            "email": tk.StringVar(),
            "age":   tk.StringVar(),
        }
        self._build()

    def _build(self):
        champs = [("Nom :", "nom"), ("Email :", "email"), ("Âge :", "age")]
        self.entries = {}
        self.errors  = {}

        for i, (label, key) in enumerate(champs):
            tk.Label(self, text=label, anchor="e").grid(
                row=i*2, column=0, sticky=tk.E, pady=(4,0))
            e = tk.Entry(self, textvariable=self.vars[key], width=28)
            e.grid(row=i*2, column=1, sticky=tk.EW, pady=(4,0))
            self.entries[key] = e
            err = tk.Label(self, text="", fg="red", font=("Arial",8))
            err.grid(row=i*2+1, column=1, sticky=tk.W)
            self.errors[key] = err

        tk.Button(self, text="Valider", command=self.validate).grid(
            row=6, column=0, columnspan=2, pady=10)
        self.columnconfigure(1, weight=1)

    def validate(self):
        rules = {
            "nom":   lambda v: len(v) >= 2 or "Min. 2 caractères",
            "email": lambda v: bool(re.match(r".+@.+\..+", v)) or "Email invalide",
            "age":   lambda v: v.isdigit() or "Entier requis",
        }
        valid = True
        for key, rule in rules.items():
            result = rule(self.vars[key].get())
            if result is True:
                self.errors[key].config(text="")
                self.entries[key].config(bg="white")
            else:
                self.errors[key].config(text=result)
                self.entries[key].config(bg="#fff0f0")
                valid = False
        if valid:
            messagebox.showinfo("Succès", "Formulaire valide !")
Inscription
Nom :
Alice
Email :
alice@
Email invalide
Âge :
25
Valider
Validation native (validatecommand)
# Validation en temps réel — empêche certains caractères
def only_digits(new_val):
    return new_val.isdigit() or new_val == ""

vcmd = root.register(only_digits)
entry_age = tk.Entry(
    root,
    validate="key",          # valider à chaque frappe
    validatecommand=(vcmd, "%P")  # %P = valeur après saisie
)

Fenêtres secondaires & dialogues

messagebox & filedialog
from tkinter import messagebox, filedialog, simpledialog, colorchooser

# Boîtes de message
messagebox.showinfo(   "Titre", "Message d'information")
messagebox.showwarning("Titre", "Attention !")
messagebox.showerror(  "Titre", "Une erreur est survenue")

# Confirmation
ok = messagebox.askyesno("Confirmer", "Vraiment quitter ?")
if ok: root.quit()

# Dialogues de fichier
path = filedialog.askopenfilename(
    title="Ouvrir un fichier",
    filetypes=[("Fichiers texte", "*.txt"), ("Tous", "*.*")]
)
save_path = filedialog.asksaveasfilename(
    defaultextension=".txt",
    filetypes=[("Texte", "*.txt")]
)
folder = filedialog.askdirectory(title="Choisir un dossier")

# Saisie simple
nom = simpledialog.askstring("Saisie", "Votre nom ?")
age = simpledialog.askinteger("Saisie", "Votre âge ?", minvalue=0)

# Sélecteur de couleur
color = colorchooser.askcolor(title="Choisir une couleur")
# → ((r, g, b), "#rrggbb")
Toplevel — fenêtre secondaire custom
class SettingsDialog(tk.Toplevel):
    def __init__(self, parent):
        super().__init__(parent)
        self.title("Paramètres")
        self.geometry("350x200")
        self.resizable(False, False)
        self.result = None

        # Rendre modale (bloque la fenêtre parent)
        self.transient(parent)
        self.grab_set()

        self._build()

        # Attendre la fermeture avant de rendre la main
        parent.wait_window(self)

    def _build(self):
        self.theme_var = tk.StringVar(value="sombre")
        tk.Label(self, text="Thème :").pack(pady=(15,5))
        for t in ["clair", "sombre"]:
            tk.Radiobutton(self, text=t.capitalize(),
                           variable=self.theme_var, value=t).pack()
        tk.Button(self, text="OK", command=self.on_ok).pack(pady=15)

    def on_ok(self):
        self.result = self.theme_var.get()
        self.destroy()

# Utilisation
dlg = SettingsDialog(root)
if dlg.result:
    print(f"Thème choisi : {dlg.result}")

Canvas & dessin

Formes et manipulation
canvas = tk.Canvas(root, width=500, height=400,
                   bg="white", cursor="crosshair")
canvas.pack(fill=tk.BOTH, expand=True)

# Toutes les méthodes retournent un ID d'élément
rect_id = canvas.create_rectangle(
    50, 50, 150, 100,
    fill="#818cf8", outline="#4f46e5", width=2)

cercle_id = canvas.create_oval(
    200, 50, 280, 130,
    fill="#f97316", outline="")

ligne_id = canvas.create_line(
    0, 200, 500, 200, fill="gray", dash=(4, 2))

texte_id = canvas.create_text(
    250, 30, text="Mon Canvas",
    font=("Arial", 16, "bold"), fill="#333")

img = tk.PhotoImage(file="icon.png")
img_id = canvas.create_image(400, 100, image=img, anchor=tk.NW)

# Modifier un élément par son ID
canvas.itemconfig(rect_id, fill="red")
canvas.move(cercle_id, 10, 0)        # déplacer de (dx, dy)
canvas.coords(rect_id, 60, 60, 160, 110)  # changer les coords
canvas.delete(ligne_id)              # supprimer
canvas.delete("all")               # tout effacer

# Bind sur un élément canvas
canvas.tag_bind(rect_id, "<Button-1>",
    lambda e: canvas.itemconfig(rect_id, fill="green"))
Application de dessin libre
class DrawApp:
    def __init__(self, root):
        self.canvas = tk.Canvas(root, bg="white",
                                cursor="crosshair")
        self.canvas.pack(fill=tk.BOTH, expand=True)
        self.last_x = self.last_y = None
        self.color = "black"
        self.width = 3

        self.canvas.bind("<ButtonPress-1>",   self.start)
        self.canvas.bind("<B1-Motion>",       self.draw)
        self.canvas.bind("<ButtonRelease-1>", self.stop)

    def start(self, e):
        self.last_x, self.last_y = e.x, e.y

    def draw(self, e):
        if self.last_x:
            self.canvas.create_line(
                self.last_x, self.last_y, e.x, e.y,
                fill=self.color, width=self.width,
                capstyle=tk.ROUND, smooth=True)
        self.last_x, self.last_y = e.x, e.y

    def stop(self, e):
        self.last_x = self.last_y = None

    def clear(self):
        self.canvas.delete("all")

Architecture POO — Application structurée

Pattern App + Frame par vue
class App(tk.Tk):
    """Fenêtre principale — hérite de Tk."""
    def __init__(self):
        super().__init__()
        self.title("Mon App")
        self.geometry("800x600")
        self._center()
        self._build_menu()

        # Conteneur pour toutes les vues
        container = tk.Frame(self)
        container.pack(fill=tk.BOTH, expand=True)
        container.grid_rowconfigure(0, weight=1)
        container.grid_columnconfigure(0, weight=1)

        self.frames = {}
        for F in (HomeView, EditorView, SettingsView):
            frame = F(container, self)
            self.frames[F] = frame
            frame.grid(row=0, column=0, sticky="nsew")

        self.show_frame(HomeView)

    def show_frame(self, view_class):
        """Afficher une vue et masquer les autres."""
        frame = self.frames[view_class]
        frame.tkraise()

    def _center(self):
        w, h = 800, 600
        x = (self.winfo_screenwidth()  - w) // 2
        y = (self.winfo_screenheight() - h) // 2
        self.geometry(f"{w}x{h}+{x}+{y}")

    def _build_menu(self):
        menubar = tk.Menu(self)
        nav = tk.Menu(menubar, tearoff=0)
        nav.add_command(label="Accueil",    command=lambda: self.show_frame(HomeView))
        nav.add_command(label="Éditeur",   command=lambda: self.show_frame(EditorView))
        nav.add_command(label="Paramètres",command=lambda: self.show_frame(SettingsView))
        menubar.add_cascade(label="Navigation", menu=nav)
        self.config(menu=menubar)


class HomeView(tk.Frame):
    def __init__(self, parent, controller):
        super().__init__(parent)
        self.controller = controller
        tk.Label(self, text="Accueil", font=("Arial", 24)).pack(pady=50)
        tk.Button(self, text="Ouvrir l'éditeur",
                  command=lambda: controller.show_frame(EditorView)).pack()


if __name__ == "__main__":
    app = App()
    app.mainloop()
🏗️

Le pattern App + Frame par vue empile toutes les vues dans un même conteneur et utilise tkraise() pour afficher la vue active. Les vues reçoivent une référence au controller (l'App) pour pouvoir naviguer entre elles.

🎨

Style personnalisé global :

Thème ttk personnalisé
style = ttk.Style()
style.theme_use("clam")  # alt|clam|classic|default

style.configure("TButton",
    background="#818cf8", foreground="white",
    font=("Arial", 11), padding=8, relief="flat")
style.map("TButton",
    background=[("active", "#6366f1")])

style.configure("TEntry",
    fieldbackground="#f8f8ff", borderwidth=1)

style.configure("TFrame", background="#1e1e2e")

ttk — Widgets modernes

Notebook (onglets) & Treeview
# Notebook — onglets
notebook = ttk.Notebook(root)
notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)

tab1 = ttk.Frame(notebook)
tab2 = ttk.Frame(notebook)
notebook.add(tab1, text="Général")
notebook.add(tab2, text="Avancé")

# Détecter le changement d'onglet
notebook.bind("<<NotebookTabChanged>>",
    lambda e: print(notebook.index(notebook.select())))

# Treeview — tableau / arbre
tree = ttk.Treeview(root, columns=("nom", "age", "ville"),
                    show="headings", height=8)

tree.heading("nom",   text="Nom")
tree.heading("age",   text="Âge")
tree.heading("ville", text="Ville")
tree.column("nom",   width=120)
tree.column("age",   width=60,  anchor=tk.CENTER)
tree.column("ville", width=120)

data = [("Alice", 28, "Paris"), ("Bob", 34, "Lyon")]
for row in data:
    tree.insert("", tk.END, values=row)

# Lire la sélection
def on_select(e):
    item = tree.selection()[0]
    vals = tree.item(item, "values")  # tuple

tree.bind("<<TreeviewSelect>>", on_select)

# Trier en cliquant sur l'en-tête
def sort_col(col):
    items = [(tree.set(k, col), k) for k in tree.get_children()]
    for i, (_, k) in enumerate(sorted(items)):
        tree.move(k, "", i)
Progressbar & Separator
# Progressbar déterminée
progress = ttk.Progressbar(root, length=300,
                           mode="determinate",
                           maximum=100)
progress.pack(pady=10)
progress["value"] = 65   # 65%

# Progressbar indéterminée (chargement)
busy = ttk.Progressbar(root, mode="indeterminate")
busy.start(50)   # intervalle en ms
busy.stop()

# Séparateur horizontal
ttk.Separator(root, orient=tk.HORIZONTAL).pack(
    fill=tk.X, padx=10, pady=5)

# PanedWindow — zones redimensionnables
paned = ttk.PanedWindow(root, orient=tk.HORIZONTAL)
paned.pack(fill=tk.BOTH, expand=True)

left  = ttk.Frame(paned, width=200)
right = ttk.Frame(paned)
paned.add(left,  weight=1)
paned.add(right, weight=3)

Préférer les widgets ttk aux widgets tk classiques dès que possible — ils s'adaptent mieux au thème de l'OS et permettent une stylisation cohérente via ttk.Style.

Projet — Gestionnaire de tâches

📋

Application complète : liste de tâches avec ajout, suppression, marquage "fait", persistance JSON, et interface structurée en POO.

Structure du projet
todo_app/
├── main.py          ← point d'entrée
├── app.py           ← classe App (Tk)
├── views/
│   ├── task_view.py ← vue principale
│   └── add_dialog.py ← dialogue ajout
├── models/
│   └── task.py      ← dataclass Task
└── data/
    └── tasks.json   ← persistance
task_view.py — vue principale
import json, tkinter as tk
from tkinter import ttk, messagebox
from dataclasses import dataclass, asdict
from pathlib import Path

@dataclass
class Task:
    title: str
    done: bool = False

class TaskView(tk.Frame):
    DATA_FILE = Path("data/tasks.json")

    def __init__(self, parent):
        super().__init__(parent, padx=15, pady=15)
        self.tasks: list[Task] = []
        self._build()
        self._load()

    def _build(self):
        # Barre d'ajout
        top = tk.Frame(self)
        top.pack(fill=tk.X, pady=(0, 10))
        self.entry_var = tk.StringVar()
        entry = tk.Entry(top, textvariable=self.entry_var, width=35)
        entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
        entry.bind("<Return>", lambda e: self.add_task())
        tk.Button(top, text="+ Ajouter", command=self.add_task).pack(
            side=tk.LEFT, padx=(5, 0))

        # Liste des tâches
        self.tree = ttk.Treeview(self,
            columns=("status", "title"),
            show="headings", height=15)
        self.tree.heading("status", text="✓")
        self.tree.heading("title",  text="Tâche")
        self.tree.column("status", width=40, anchor=tk.CENTER)
        self.tree.column("title",  width=350)
        self.tree.pack(fill=tk.BOTH, expand=True)
        self.tree.bind("<Double-Button-1>", lambda e: self.toggle_task())

        # Boutons d'action
        btns = tk.Frame(self)
        btns.pack(fill=tk.X, pady=(10, 0))
        tk.Button(btns, text="✓ Fait",       command=self.toggle_task).pack(side=tk.LEFT)
        tk.Button(btns, text="🗑 Supprimer",  command=self.delete_task).pack(side=tk.LEFT, padx=5)
        tk.Button(btns, text="💾 Sauvegarder", command=self._save).pack(side=tk.RIGHT)

    def _refresh(self):
        self.tree.delete(*self.tree.get_children())
        for t in self.tasks:
            tag = "done" if t.done else ""
            self.tree.insert("", tk.END,
                values=("✓" if t.done else "○", t.title), tags=(tag,))
        self.tree.tag_configure("done", foreground="gray")

    def add_task(self):
        title = self.entry_var.get().strip()
        if not title: return
        self.tasks.append(Task(title))
        self.entry_var.set("")
        self._refresh()

    def toggle_task(self):
        sel = self.tree.selection()
        if not sel: return
        idx = self.tree.index(sel[0])
        self.tasks[idx].done = not self.tasks[idx].done
        self._refresh()

    def delete_task(self):
        sel = self.tree.selection()
        if not sel: return
        if messagebox.askyesno("Confirmer", "Supprimer ?"):
            self.tasks.pop(self.tree.index(sel[0]))
            self._refresh()

    def _save(self):
        self.DATA_FILE.parent.mkdir(exist_ok=True)
        self.DATA_FILE.write_text(
            json.dumps([asdict(t) for t in self.tasks], indent=2))
        messagebox.showinfo("Sauvegardé", "Tâches enregistrées.")

    def _load(self):
        if self.DATA_FILE.exists():
            data = json.loads(self.DATA_FILE.read_text())
            self.tasks = [Task(**d) for d in data]
            self._refresh()

Cheat Sheet Tkinter

🪟 Fenêtre & boucle

tk.Tk()Fenêtre principale
.geometry("WxH+X+Y")Taille et position
.title("...")Titre de la fenêtre
.mainloop()Démarrer la boucle
.after(ms, fn)Exécution différée
tk.Toplevel()Fenêtre secondaire

🧩 Widgets courants

tk.Label(p, text=)Texte statique
tk.Button(p, command=)Bouton cliquable
tk.Entry(p, textvariable=)Saisie 1 ligne
tk.Text(p, wrap=WORD)Saisie multi-lignes
ttk.Combobox(p, values=)Liste déroulante
ttk.Treeview(p, columns=)Tableau de données

📐 Layouts

.pack(side=, fill=, expand=)Empilement
.grid(row=, column=, sticky=)Grille
.place(x=, y=)Absolu
columnconfigure(i, weight=1)Colonne extensible
sticky="nsew"Remplir la cellule
columnspan=2Fusionner colonnes

⚡ Événements

.bind("<Return>", fn)Touche Entrée
.bind("<Button-1>", fn)Clic gauche
.bind("<Control-s>", fn)Ctrl+S
StringVar().trace_add()Réagir au changement
messagebox.askyesno()Dialogue Oui/Non
filedialog.askopenfilename()Ouvrir un fichier