import subprocess import sys import tkinter as tk from collections import deque from os import system from pathlib import Path from time import sleep, time from tkinter import PhotoImage from tkinter.font import Font from typing import ( TYPE_CHECKING, Any, Callable, Deque, Dict, List, Optional, Set, Tuple, TypeVar, Union, ) try: # noinspection PyUnresolvedReferences from PIL import Image, ImageTk print("Bibliothèque PIL chargée.", file=sys.stderr) PIL_AVAILABLE = True except ImportError as e: PIL_AVAILABLE = False if TYPE_CHECKING: from typing_extensions import Literal Anchor = Literal["nw", "n", "ne", "w", "center", "e", "sw", "s", "se"] TkEvent = tk.Event[tk.BaseWidget] else: Anchor = str TkEvent = tk.Event FltkEvent = Tuple[str, Optional[TkEvent]] __all__ = [ # gestion de fenêtre "cree_fenetre", "ferme_fenetre", "redimensionne_fenetre", "mise_a_jour", # dessin "ligne", "fleche", "polygone", "rectangle", "cercle", "point", "image", "texte", "taille_texte", # effacer "efface_tout", "efface", # utilitaires "attente", "capture_ecran", "touche_pressee", "abscisse_souris", "ordonnee_souris", "hauteur_fenetre", "largeur_fenetre", # événements "donne_ev", "attend_ev", "attend_clic_gauche", "attend_fermeture", "type_ev", "abscisse", "ordonnee", "touche", ] class CustomCanvas: """ Classe qui encapsule tous les objets tkinter nécessaires à la création d'un canevas. """ _on_osx = sys.platform.startswith("darwin") _ev_mapping = { "ClicGauche": "", "ClicMilieu": "", "ClicDroit": "" if _on_osx else "", "Deplacement": "", "Touche": "", "Redimension": "", } _default_ev = ["ClicGauche", "ClicDroit", "Touche"] def __init__( self, width: int, height: int, refresh_rate: int = 100, events: Optional[List[str]] = None, resizing: bool = False, ) -> None: # width and height of the canvas self.width = width self.height = height self.interval = 1 / refresh_rate # root Tk object self.root = tk.Tk() # canvas attached to the root object self.canvas = tk.Canvas( self.root, width=width, height=height, highlightthickness=0 ) # adding the canvas to the root window and giving it focus self.canvas.pack(fill=tk.BOTH, expand=tk.YES) self.root.resizable(width=resizing, height=resizing) self.canvas.focus_set() self.first_resize = True # binding events self.ev_queue: Deque[FltkEvent] = deque() self.pressed_keys: Set[str] = set() self.events = CustomCanvas._default_ev if events is None else events self.bind_events() # update for the first time self.last_update = time() self.root.update() if CustomCanvas._on_osx: system( """/usr/bin/osascript -e 'tell app "Finder" \ to set frontmost of process "Python" to true' """ ) def update(self) -> None: t = time() self.root.update() sleep(max(0.0, self.interval - (t - self.last_update))) self.last_update = time() def resize(self, width: int, height: int) -> None: self.root.geometry(f"{int(width)}x{int(height)}") def bind_events(self) -> None: self.root.protocol("WM_DELETE_WINDOW", self.event_quit) self.canvas.bind("", self.event_resize) self.canvas.bind("", self.register_key) self.canvas.bind("", self.release_key) for name in self.events: self.bind_event(name) # noinspection PyUnresolvedReferences def register_key(self, ev: TkEvent) -> None: self.pressed_keys.add(ev.keysym) # noinspection PyUnresolvedReferences def release_key(self, ev: TkEvent) -> None: if ev.keysym in self.pressed_keys: self.pressed_keys.remove(ev.keysym) def event_quit(self) -> None: self.ev_queue.append(("Quitte", None)) # noinspection PyUnresolvedReferences def event_resize(self, event: TkEvent) -> None: if event.widget.widgetName == "canvas": if self.width != event.width or self.height != event.height: self.width, self.height = event.width, event.height if not self.ev_queue or self.ev_queue[-1][0] != "Redimension": self.ev_queue.append(("Redimension", event)) def bind_event(self, name: str) -> None: e_type = CustomCanvas._ev_mapping.get(name, name) def handler(event: TkEvent, _name: str = name) -> None: self.ev_queue.append((_name, event)) self.canvas.bind(e_type, handler, "+") def unbind_event(self, name: str) -> None: e_type = CustomCanvas._ev_mapping.get(name, name) self.canvas.unbind(e_type) __canevas: Optional[CustomCanvas] = None __img: Dict[Tuple[Path, int, int], PhotoImage] = {} ############################################################################# # Exceptions ############################################################################# class TypeEvenementNonValide(Exception): pass class FenetreNonCree(Exception): pass class FenetreDejaCree(Exception): pass Ret = TypeVar("Ret") def _fenetre_cree(func: Callable[..., Ret]) -> Callable[..., Ret]: def new_func(*args: Any, **kwargs: Any) -> Ret: if __canevas is None: raise FenetreNonCree( 'La fenêtre n\'a pas été crée avec la fonction "cree_fenetre".' ) return func(*args, **kwargs) return new_func ############################################################################# # Initialisation, mise à jour et fermeture ############################################################################# def cree_fenetre( largeur: int, hauteur: int, frequence: int = 100, redimension: bool = False ) -> None: """ Crée une fenêtre de dimensions ``largeur`` x ``hauteur`` pixels. :rtype: """ global __canevas if __canevas is not None: raise FenetreDejaCree( 'La fenêtre a déjà été crée avec la fonction "cree_fenetre".' ) __canevas = CustomCanvas(largeur, hauteur, frequence, resizing=redimension) @_fenetre_cree def ferme_fenetre() -> None: """ Détruit la fenêtre. """ global __canevas assert __canevas is not None __canevas.root.destroy() __canevas = None @_fenetre_cree def redimensionne_fenetre(largeur: int, hauteur: int) -> None: """ Fixe les dimensions de la fenêtre à (``hauteur`` x ``largeur``) pixels. Le contenu du canevas n'est pas automatiquement mis à l'échelle et doit être redessiné si nécessaire. """ assert __canevas is not None __canevas.resize(width=largeur, height=hauteur) @_fenetre_cree def mise_a_jour() -> None: """ Met à jour la fenêtre. Les dessins ne sont affichés qu'après l'appel à cette fonction. """ assert __canevas is not None __canevas.update() ############################################################################# # Fonctions de dessin ############################################################################# # Formes géométriques @_fenetre_cree def ligne( ax: float, ay: float, bx: float, by: float, couleur: str = "black", epaisseur: float = 1, tag: str = "", ) -> int: """ Trace un segment reliant le point ``(ax, ay)`` au point ``(bx, by)``. :param float ax: abscisse du premier point :param float ay: ordonnée du premier point :param float bx: abscisse du second point :param float by: ordonnée du second point :param str couleur: couleur de trait (défaut 'black') :param float epaisseur: épaisseur de trait en pixels (défaut 1) :param str tag: étiquette d'objet (défaut : pas d'étiquette) :return: identificateur d'objet """ assert __canevas is not None return __canevas.canvas.create_line( ax, ay, bx, by, fill=couleur, width=epaisseur, tags=tag ) @_fenetre_cree def fleche( ax: float, ay: float, bx: float, by: float, couleur: str = "black", epaisseur: float = 1, tag: str = "", ) -> int: """ Trace une flèche du point ``(ax, ay)`` au point ``(bx, by)``. :param float ax: abscisse du premier point :param float ay: ordonnée du premier point :param float bx: abscisse du second point :param float by: ordonnée du second point :param str couleur: couleur de trait (défaut 'black') :param float epaisseur: épaisseur de trait en pixels (défaut 1) :param str tag: étiquette d'objet (défaut : pas d'étiquette) :return: identificateur d'objet """ x, y = (bx - ax, by - ay) n = (x ** 2 + y ** 2) ** 0.5 x, y = x / n, y / n points = [ bx, by, bx - x * 5 - 2 * y, by - 5 * y + 2 * x, bx - x * 5 + 2 * y, by - 5 * y - 2 * x, ] assert __canevas is not None return __canevas.canvas.create_polygon( points, fill=couleur, outline=couleur, width=epaisseur, tags=tag ) @_fenetre_cree def polygone( points: List[float], couleur: str = "black", remplissage: str = "", epaisseur: float = 1, tag: str = "", ) -> int: """ Trace un polygone dont la liste de points est fournie. :param list points: liste de couples (abscisse, ordonnee) de points :param str couleur: couleur de trait (défaut 'black') :param str remplissage: couleur de fond (défaut transparent) :param float epaisseur: épaisseur de trait en pixels (défaut 1) :param str tag: étiquette d'objet (défaut : pas d'étiquette) :return: identificateur d'objet """ assert __canevas is not None return __canevas.canvas.create_polygon( points, fill=remplissage, outline=couleur, width=epaisseur, tags=tag ) @_fenetre_cree def rectangle( ax: float, ay: float, bx: float, by: float, couleur: str = "black", remplissage: str = "", epaisseur: float = 1, tag: str = "", ) -> int: """ Trace un rectangle noir ayant les point ``(ax, ay)`` et ``(bx, by)`` comme coins opposés. :param float ax: abscisse du premier coin :param float ay: ordonnée du premier coin :param float bx: abscisse du second coin :param float by: ordonnée du second coin :param str couleur: couleur de trait (défaut 'black') :param str remplissage: couleur de fond (défaut transparent) :param float epaisseur: épaisseur de trait en pixels (défaut 1) :param str tag: étiquette d'objet (défaut : pas d'étiquette) :return: identificateur d'objet """ assert __canevas is not None return __canevas.canvas.create_rectangle( ax, ay, bx, by, outline=couleur, fill=remplissage, width=epaisseur, tags=tag ) @_fenetre_cree def cercle( x: float, y: float, r: float, couleur: str = "black", remplissage: str = "", epaisseur: float = 1, tag: str = "", ) -> int: """ Trace un cercle de centre ``(x, y)`` et de rayon ``r`` en noir. :param float x: abscisse du centre :param float y: ordonnée du centre :param float r: rayon :param str couleur: couleur de trait (défaut 'black') :param str remplissage: couleur de fond (défaut transparent) :param float epaisseur: épaisseur de trait en pixels (défaut 1) :param str tag: étiquette d'objet (défaut : pas d'étiquette) :return: identificateur d'objet """ assert __canevas is not None return __canevas.canvas.create_oval( x - r, y - r, x + r, y + r, outline=couleur, fill=remplissage, width=epaisseur, tags=tag, ) @_fenetre_cree def arc( x: float, y: float, r: float, ouverture: float = 90, depart: float = 0, couleur: str = "black", remplissage: str = "", epaisseur: float = 1, tag: str = "", ) -> int: """ Trace un arc de cercle de centre ``(x, y)``, de rayon ``r`` et d'angle d'ouverture ``ouverture`` (défaut : 90 degrés, dans le sens contraire des aiguilles d'une montre) depuis l'angle initial ``depart`` (défaut : direction 'est'). :param float x: abscisse du centre :param float y: ordonnée du centre :param float r: rayon :param float ouverture: abscisse du centre :param float depart: ordonnée du centre :param str couleur: couleur de trait (défaut 'black') :param str remplissage: couleur de fond (défaut transparent) :param float epaisseur: épaisseur de trait en pixels (défaut 1) :param str tag: étiquette d'objet (défaut : pas d'étiquette) :return: identificateur d'objet """ assert __canevas is not None return __canevas.canvas.create_arc( x - r, y - r, x + r, y + r, extent=ouverture, start=depart, style=tk.ARC, outline=couleur, fill=remplissage, width=epaisseur, tags=tag, ) @_fenetre_cree def point( x: float, y: float, couleur: str = "black", epaisseur: float = 1, tag: str = "" ) -> int: """ Trace un point aux coordonnées ``(x, y)`` en noir. :param float x: abscisse :param float y: ordonnée :param str couleur: couleur du point (défaut 'black') :param float epaisseur: épaisseur de trait en pixels (défaut 1) :param str tag: étiquette d'objet (défaut : pas d'étiquette) :return: identificateur d'objet """ assert __canevas is not None return cercle(x, y, epaisseur, couleur=couleur, remplissage=couleur, tag=tag) # Image @_fenetre_cree def image( x: float, y: float, fichier: str, largeur: Optional[int] = None, hauteur: Optional[int] = None, ancrage: Anchor = "center", tag: str = "", ) -> int: """ Affiche l'image contenue dans ``fichier`` avec ``(x, y)`` comme centre. Les valeurs possibles du point d'ancrage sont ``'center'``, ``'nw'``, etc. Les arguments optionnels ``largeur`` et ``hauteur`` permettent de spécifier des dimensions maximales pour l'image (sans changement de proportions). :param largeur: largeur de l'image :param hauteur: hauteur de l'image :param float x: abscisse du point d'ancrage :param float y: ordonnée du point d'ancrage :param str fichier: nom du fichier contenant l'image :param ancrage: position du point d'ancrage par rapport à l'image :param str tag: étiquette d'objet (défaut : pas d'étiquette) :return: identificateur d'objet """ assert __canevas is not None if PIL_AVAILABLE: tk_image = _load_pil_image(fichier, hauteur, largeur) else: tk_image = _load_tk_image(fichier, hauteur, largeur) img_object = __canevas.canvas.create_image( x, y, anchor=ancrage, image=tk_image, tags=tag ) return img_object def _load_tk_image(fichier: str, hauteur: Optional[int] = None, largeur: Optional[int] = None) -> PhotoImage: chemin = Path(fichier) ph_image = PhotoImage(file=fichier) largeur_o = ph_image.width() hauteur_o = ph_image.height() if largeur is None: largeur = largeur_o if hauteur is None: hauteur = hauteur_o zoom_l = max(1, largeur // largeur_o) zoom_h = max(1, hauteur // hauteur_o) red_l = max(1, largeur_o // largeur) red_h = max(1, hauteur_o // hauteur) largeur = largeur_o * zoom_l // red_l hauteur = hauteur_o * zoom_h // red_h if (chemin, largeur, hauteur) in __img: return __img[(chemin, largeur, hauteur)] ph_image = ph_image.zoom(zoom_l, zoom_h) ph_image = ph_image.subsample(red_l, red_h) __img[(chemin, largeur, hauteur)] = ph_image return ph_image def _load_pil_image(fichier: str, hauteur: Optional[int] = None, largeur: Optional[int] = None) -> PhotoImage: chemin = Path(fichier) img = Image.open(fichier) if largeur is None: largeur = img.width if hauteur is None: hauteur = img.height if (chemin, largeur, hauteur) in __img: return __img[(chemin, largeur, hauteur)] img = img.resize((largeur, hauteur)) ph_image = ImageTk.PhotoImage(img) __img[(chemin, largeur, hauteur)] = ph_image # type:ignore return ph_image # type:ignore # Texte @_fenetre_cree def texte( x: float, y: float, chaine: str, couleur: str = "black", ancrage: Anchor = "nw", police: str = "Helvetica", taille: int = 24, tag: str = "", ) -> int: """ Affiche la chaîne ``chaine`` avec ``(x, y)`` comme point d'ancrage (par défaut le coin supérieur gauche). :param float x: abscisse du point d'ancrage :param float y: ordonnée du point d'ancrage :param str chaine: texte à afficher :param str couleur: couleur de trait (défaut 'black') :param ancrage: position du point d'ancrage (défaut 'nw') :param police: police de caractères (défaut : `Helvetica`) :param taille: taille de police (défaut 24) :param tag: étiquette d'objet (défaut : pas d'étiquette :return: identificateur d'objet """ assert __canevas is not None return __canevas.canvas.create_text( x, y, text=chaine, font=(police, taille), tags=tag, fill=couleur, anchor=ancrage ) def taille_texte( chaine: str, police: str = "Helvetica", taille: int = 24 ) -> Tuple[int, int]: """ Donne la largeur et la hauteur en pixel nécessaires pour afficher ``chaine`` dans la police et la taille données. :param str chaine: chaîne à mesurer :param police: police de caractères (défaut : `Helvetica`) :param taille: taille de police (défaut 24) :return: couple (w, h) constitué de la largeur et la hauteur de la chaîne en pixels (int), dans la police et la taille données. """ font = Font(family=police, size=taille) return font.measure(chaine), font.metrics("linespace") ############################################################################# # Effacer ############################################################################# @_fenetre_cree def efface_tout() -> None: """ Efface la fenêtre. """ assert __canevas is not None __canevas.canvas.delete("all") @_fenetre_cree def efface(objet_ou_tag: Union[int, str]) -> None: """ Efface ``objet`` de la fenêtre. :param: objet ou étiquette d'objet à supprimer :type: ``int`` ou ``str`` """ assert __canevas is not None __canevas.canvas.delete(objet_ou_tag) ############################################################################# # Utilitaires ############################################################################# def attente(temps: float) -> None: start = time() while time() - start < temps: mise_a_jour() @_fenetre_cree def capture_ecran(file: str) -> None: """ Fait une capture d'écran sauvegardée dans ``file.png``. """ assert __canevas is not None __canevas.canvas.postscript( # type: ignore file=file + ".ps", height=__canevas.height, width=__canevas.width, colormode="color", ) subprocess.call( "convert -density 150 -geometry 100% -background white -flatten" " " + file + ".ps " + file + ".png", shell=True, ) subprocess.call("rm " + file + ".ps", shell=True) @_fenetre_cree def touche_pressee(keysym: str) -> bool: """ Renvoie `True` si ``keysym`` est actuellement pressée. :param keysym: symbole associé à la touche à tester. :return: `True` si ``keysym`` est actuellement pressée, `False` sinon. """ assert __canevas is not None return keysym in __canevas.pressed_keys ############################################################################# # Gestions des évènements ############################################################################# @_fenetre_cree def donne_ev() -> Optional[FltkEvent]: """ Renvoie immédiatement l'événement en attente le plus ancien, ou ``None`` si aucun événement n'est en attente. """ assert __canevas is not None if not __canevas.ev_queue: return None return __canevas.ev_queue.popleft() def attend_ev() -> FltkEvent: """Attend qu'un événement ait lieu et renvoie le premier événement qui se produit.""" while True: ev = donne_ev() if ev is not None: return ev mise_a_jour() def attend_clic_gauche() -> Tuple[int, int]: """Attend qu'un clic gauche sur la fenêtre ait lieu et renvoie ses coordonnées. **Attention**, cette fonction empêche la détection d'autres événements ou la fermeture de la fenêtre.""" while True: ev = donne_ev() if ev is not None and type_ev(ev) == "ClicGauche": x, y = abscisse(ev), ordonnee(ev) assert isinstance(x, int) and isinstance(y, int) return x, y mise_a_jour() def attend_fermeture() -> None: """Attend la fermeture de la fenêtre. Cette fonction renvoie None. **Attention**, cette fonction empêche la détection d'autres événements.""" while True: ev = donne_ev() if ev is not None and type_ev(ev) == "Quitte": ferme_fenetre() return mise_a_jour() def type_ev(ev: Optional[FltkEvent]) -> Optional[str]: """ Renvoie une chaîne donnant le type de ``ev``. Les types possibles sont 'ClicDroit', 'ClicGauche', 'Touche' et 'Quitte'. Renvoie ``None`` si ``evenement`` vaut ``None``. """ return ev if ev is None else ev[0] def abscisse(ev: Optional[FltkEvent]) -> Optional[int]: """ Renvoie la coordonnée x associé à ``ev`` si elle existe, None sinon. """ x = attribut(ev, "x") assert isinstance(x, int) or x is None return x def ordonnee(ev: Optional[FltkEvent]) -> Optional[int]: """ Renvoie la coordonnée y associé à ``ev`` si elle existe, None sinon. """ y = attribut(ev, "y") assert isinstance(y, int) or y is None return y def touche(ev: Optional[FltkEvent]) -> str: """ Renvoie une chaîne correspondant à la touche associé à ``ev``, si elle existe. """ keysym = attribut(ev, "keysym") assert isinstance(keysym, str) return keysym def attribut(ev: Optional[FltkEvent], nom: str) -> Any: if ev is None: raise TypeEvenementNonValide( f"Accès à l'attribut {nom} impossible sur un événement vide" ) tev, evtk = ev if not hasattr(evtk, nom): raise TypeEvenementNonValide( f"Accès à l'attribut {nom} impossible " f"sur un événement de type {tev}" ) attr = getattr(evtk, nom) return attr if attr != "??" else None @_fenetre_cree def abscisse_souris() -> int: assert __canevas is not None return __canevas.canvas.winfo_pointerx() - __canevas.canvas.winfo_rootx() @_fenetre_cree def ordonnee_souris() -> int: assert __canevas is not None return __canevas.canvas.winfo_pointery() - __canevas.canvas.winfo_rooty() @_fenetre_cree def largeur_fenetre() -> int: assert __canevas is not None return __canevas.width @_fenetre_cree def hauteur_fenetre() -> int: assert __canevas is not None return __canevas.height