"""Composants UI stylés de la GUI V6 (G4), alignés sur la maquette. Chaque helper reçoit une ``palette`` (cf. theme.py) et applique les couleurs correspondantes à des widgets customtkinter. Aucune logique métier ici. Les widgets ne sont créés qu'à l'appel (import sûr pour ``--self-test``). """ from __future__ import annotations import tkinter as tk from typing import Optional import customtkinter as ctk CARD_RADIUS = 8 def font(size: int = 13, weight: str = "normal") -> "ctk.CTkFont": return ctk.CTkFont(size=size, weight=weight) class Card(ctk.CTkFrame): """Carte maquette : fond `card`, bordure `card_border`, titre uppercase optionnel.""" def __init__( self, master, palette: dict, title: Optional[str] = None, help_text: Optional[str] = None, help_title: Optional[str] = None, **kwargs, ): super().__init__( master, fg_color=palette["card"], border_color=palette["card_border"], border_width=1, corner_radius=CARD_RADIUS, **kwargs, ) self._palette = palette self.body = self # alias pour clarté if title: header = ctk.CTkFrame(self, fg_color="transparent") header.pack(fill="x", padx=16, pady=(14, 8)) ctk.CTkLabel( header, text=title.upper(), text_color=palette["text_dim"], font=font(11, "bold"), anchor="w", ).pack(side="left", fill="x", expand=True) if help_text: HelpButton( header, palette, help_text, title=help_title or title.lstrip("⚙️👤🏷️⬛🧠📝🛡️🔍✅📤📥 ").strip() or "Aide", ).pack(side="right", padx=(8, 0)) def primary_button(master, palette: dict, text: str, command=None, large: bool = False): return ctk.CTkButton( master, text=text, command=command, fg_color=palette["primary"], hover_color=palette["primary_dim"], text_color="#ffffff", corner_radius=CARD_RADIUS, height=38 if large else 32, font=font(14 if large else 13, "bold"), ) def secondary_button(master, palette: dict, text: str, command=None): return ctk.CTkButton( master, text=text, command=command, fg_color=palette["btn_sec_bg"], hover_color=palette["card_border"], text_color=palette["text"], border_color=palette["btn_sec_border"], border_width=1, corner_radius=CARD_RADIUS, height=32, font=font(13), ) def success_button(master, palette: dict, text: str, command=None): return ctk.CTkButton( master, text=text, command=command, fg_color=palette["success"], hover_color=palette["success"], text_color="#ffffff", corner_radius=CARD_RADIUS, height=32, font=font(13, "bold"), ) def pill_button(master, palette: dict, text: str, command=None, active: bool = False): """Bouton pilule (sélecteur de thème / sous-onglet).""" return ctk.CTkButton( master, text=text, command=command, fg_color=palette["primary"] if active else "transparent", hover_color=palette["primary_dim"], text_color="#ffffff" if active else palette["text_dim"], border_color=palette["card_border"] if not active else palette["primary"], border_width=2, corner_radius=99, height=30, font=font(12, "bold" if active else "normal"), ) class StatCard(ctk.CTkFrame): """Carte statistique (rgrid/sc) : grande valeur + label.""" def __init__(self, master, palette: dict, value: str, label: str, value_color: Optional[str] = None, **kwargs): super().__init__( master, fg_color=palette["btn_sec_bg"], border_color=palette["btn_sec_border"], border_width=1, corner_radius=CARD_RADIUS, **kwargs, ) ctk.CTkLabel( self, text=value, text_color=value_color or palette["primary"], font=font(22, "bold") ).pack(pady=(10, 0)) ctk.CTkLabel(self, text=label, text_color=palette["text_muted"], font=font(11)).pack( pady=(0, 10) ) class ToggleRow(ctk.CTkFrame): """Ligne de réglage (srow) : libellé + indice + interrupteur.""" def __init__(self, master, palette: dict, label: str, hint: str = "", value: bool = True, command=None, **kwargs): super().__init__(master, fg_color="transparent", **kwargs) left = ctk.CTkFrame(self, fg_color="transparent") left.pack(side="left", fill="x", expand=True) ctk.CTkLabel(left, text=label, text_color=palette["text"], font=font(13), anchor="w").pack(anchor="w") if hint: ctk.CTkLabel(left, text=hint, text_color=palette["text_muted"], font=font(11), anchor="w").pack(anchor="w") self.var = ctk.BooleanVar(value=value) self.switch = ctk.CTkSwitch( self, text="", variable=self.var, command=command, progress_color=palette["primary"], width=44, ) self.switch.pack(side="right", padx=(8, 0)) def get(self) -> bool: return bool(self.var.get()) class HelpButton(ctk.CTkButton): """Petit bouton « ? » ouvrant une fenêtre d'aide en français simple. Restaure l'affordance d'aide de la V5 (``ToolTip`` / « Comment ça marche ? ») pour les utilisateurs non informaticiens. """ def __init__(self, master, palette: dict, text: str, *, title: str = "Aide", **kwargs): self._palette = palette self._help_text = text self._help_title = title self._window = None super().__init__( master, text="?", command=self.open_help, width=26, height=26, corner_radius=13, fg_color=palette["btn_sec_bg"], hover_color=palette["card_border"], text_color=palette["text_dim"], border_color=palette["btn_sec_border"], border_width=1, font=font(13, "bold"), **kwargs, ) def open_help(self): if self._window is not None: try: if self._window.winfo_exists(): self._window.lift() self._window.focus_force() return self._window except Exception: pass p = self._palette win = ctk.CTkToplevel(self) win.title(self._help_title) win.geometry("480x380") win.minsize(360, 240) try: win.configure(fg_color=p["bg"]) except Exception: pass ctk.CTkLabel( win, text=self._help_title, text_color=p["text"], font=font(15, "bold"), anchor="w" ).pack(fill="x", padx=16, pady=(14, 4)) box = ctk.CTkScrollableFrame(win, fg_color=p["card"]) box.pack(fill="both", expand=True, padx=12, pady=(0, 8)) ctk.CTkLabel( box, text=self._help_text, text_color=p["text_dim"], font=font(12), justify="left", wraplength=420, anchor="w", ).pack(fill="x", padx=10, pady=10) ctk.CTkButton( win, text="Fermer", command=win.destroy, fg_color=p["btn_sec_bg"], hover_color=p["card_border"], text_color=p["text"], border_color=p["btn_sec_border"], border_width=1, corner_radius=CARD_RADIUS, height=30, ).pack(padx=12, pady=(0, 12)) try: win.transient(self.winfo_toplevel()) win.after(120, lambda: (win.lift(), win.focus_force())) except Exception: pass self._window = win return win def help_button(master, palette: dict, text: str, title: str = "Aide") -> "HelpButton": return HelpButton(master, palette, text, title=title) class Tooltip: """Infobulle au survol (façon V5 ``ToolTip``), pour les éléments ambigus.""" def __init__(self, widget, text: str, delay: int = 450): self.widget = widget self.text = text self.delay = delay self._tip = None self._after = None widget.bind("", self._schedule, add="+") widget.bind("", self.hide, add="+") widget.bind("", self.hide, add="+") def _schedule(self, *_): self._cancel() try: self._after = self.widget.after(self.delay, self.show) except Exception: pass def _cancel(self): if self._after is not None: try: self.widget.after_cancel(self._after) except Exception: pass self._after = None def show(self, *_): if self._tip is not None or not self.text: return self._tip try: x = self.widget.winfo_rootx() + 16 y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4 except Exception: return None self._tip = tw = tk.Toplevel(self.widget) tw.wm_overrideredirect(True) tw.wm_geometry(f"+{x}+{y}") tk.Label( tw, text=self.text, justify="left", background="#1f2937", foreground="#f9fafb", relief="solid", borderwidth=1, wraplength=320, padx=8, pady=5, ).pack() return tw def hide(self, *_): self._cancel() if self._tip is not None: try: self._tip.destroy() except Exception: pass self._tip = None def attach_tooltip(widget, text: str, delay: int = 450) -> "Tooltip": return Tooltip(widget, text, delay)