diff --git a/gui_v6/app.py b/gui_v6/app.py index 87298fa..886797f 100644 --- a/gui_v6/app.py +++ b/gui_v6/app.py @@ -25,7 +25,7 @@ from gui_v6.tabs.tab_usage import UsageTab _TABS = [ ("use", "📄 Utilisation"), - ("cfg", "⚙️ Configuration"), + ("cfg", "⚙️ Administration"), ("about", "ℹ️ À propos"), ] @@ -65,6 +65,11 @@ class AnonymisationApp(ctk.CTk): pass for child in self.winfo_children(): child.destroy() + # Les frames d'onglets mis en cache étaient des enfants détruits ci-dessus : + # on vide le cache pour que ``_show`` recrée proprement l'onglet actif + # (sinon on re-packe un widget mort → onglet vide / TclError au changement de thème). + self._tab_frames = {} + self._visible_tab = None self._build_header(p) self._build_tabsbar(p) self._content = ctk.CTkScrollableFrame(self, fg_color=p["bg"]) diff --git a/gui_v6/tabs/tab_about.py b/gui_v6/tabs/tab_about.py index 7903010..f55fb35 100644 --- a/gui_v6/tabs/tab_about.py +++ b/gui_v6/tabs/tab_about.py @@ -17,6 +17,14 @@ from gui_v6 import ui_kit from gui_v6.license_client import LicenseClient, LicenseStatus from gui_v6.machine_id import default_machine_id +_HELP_ABOUT = ( + "Cet écran affiche la version de l'application, les moteurs utilisés et " + "l'identifiant de ce poste.\n\n" + "La licence s'active avec une clef fournie par votre administrateur. " + "L'activation se fait sans envoyer aucun document : seule la clef est vérifiée.\n\n" + "Le traitement des documents reste 100 % local sur ce poste." +) + _STATUS_LABELS = { "active": "Licence active", "grace": "Licence en période de grâce", @@ -58,6 +66,18 @@ class AboutTab(ctk.CTkFrame): def _build(self) -> None: p = self._p + # Bandeau d'introduction + aide « ? » + intro = ctk.CTkFrame(self, fg_color="transparent") + intro.pack(fill="x", padx=14, pady=(12, 0)) + ctk.CTkLabel( + intro, + text="Informations sur l'application et activation de votre licence.", + text_color=p["text_dim"], + font=ui_kit.font(12), + anchor="w", + ).pack(side="left", padx=(2, 6)) + ui_kit.help_button(intro, p, _HELP_ABOUT, title="À propos / Licence").pack(side="right", padx=2) + # Grille d'informations info = ui_kit.Card(self, p, title="ℹ️ Informations") info.pack(fill="x", padx=14, pady=(14, 7)) diff --git a/gui_v6/tabs/tab_config.py b/gui_v6/tabs/tab_config.py index b7f19b4..a4c7ff4 100644 --- a/gui_v6/tabs/tab_config.py +++ b/gui_v6/tabs/tab_config.py @@ -24,7 +24,7 @@ _SUBTABS = [ ("reg", "⚙️ Réglages"), ("msk", "🎭 Masquage"), ("shr", "🔄 Partage"), - ("rul", "🛡️ Règles 2"), + ("rul", "🛡️ Règles"), ] _DETECTION_OPTIONS = [ @@ -62,6 +62,40 @@ _STOPWORDS = ["hospitalisation", "contrôle", "prescription"] MANUAL_MASK_NONE_LABEL = "Aucun masque manuel" +# Textes d'aide « ? » (français simple, pour utilisateurs non informaticiens). +_HELP_REGLAGES = ( + "Réglages de l'anonymisation.\n\n" + "• Profil métier : choisit un jeu de réglages adapté à votre service.\n" + "• Moteurs NER : les modèles qui détectent les noms et données personnelles.\n" + "• Données à détecter : ce qui sera masqué (noms, dates de naissance, etc.).\n" + "• Listes locales : vos termes à toujours masquer ou toujours conserver.\n\n" + "Tout fonctionne 100 % en local sur ce poste. Aucun document patient n'est envoyé sur Internet." +) +_HELP_MASQUAGE = ( + "Masquage des documents.\n\n" + "• Couleur, style et marges du masque appliqué sur les PDF.\n" + "• Codes de remplacement affichés à la place des données ([NOM], [DATE_NAISSANCE]…).\n" + "• Masques de zones fixes : ouvrez l'éditeur pour dessiner les zones à masquer " + "(en-têtes, blocs identité) directement sur un PDF modèle, puis enregistrez un modèle réutilisable.\n\n" + "Le traitement reste local ; rien n'est envoyé sur Internet." +) +_HELP_PARTAGE = ( + "À quoi sert le Partage ?\n\n" + "Il permet d'échanger les RÉGLAGES de l'application (listes de termes, règles, " + "style de masquage, modèle de masque) entre plusieurs postes, ou avec votre administrateur.\n\n" + "• Exporter : enregistre vos réglages dans un fichier .json à transmettre.\n" + "• Importer : fusionne des réglages reçus avec les vôtres.\n\n" + "IMPORTANT : seuls les réglages sont partagés. Vos documents patients ne sont JAMAIS " + "partagés ni envoyés sur Internet." +) +_HELP_REGLES = ( + "Les Règles adaptent le moteur à votre établissement (ex. : toujours masquer un sigle, " + "toujours conserver un terme métier).\n\n" + "Chaque règle est validée avant d'être activée.\n\n" + "Cette section est en cours de finalisation : les actions marquées « à venir » " + "ne sont pas encore disponibles." +) + CONFIG_MOCKUP_SECTIONS = { "reglages": [ "Profil métier", @@ -198,6 +232,12 @@ class ConfigTab(ctk.CTkFrame): def _build_reglages(self, parent) -> None: p = self._p + self._section_intro( + parent, + "Choisissez ce que l'application doit détecter et masquer. Tout reste local.", + _HELP_REGLAGES, + "Les Réglages", + ) top = ctk.CTkFrame( parent, fg_color=p["card"], @@ -308,6 +348,12 @@ class ConfigTab(ctk.CTkFrame): def _build_masquage(self, parent) -> None: p = self._p + self._section_intro( + parent, + "Apparence des masques et éditeur de zones fixes à masquer sur vos PDF.", + _HELP_MASQUAGE, + "Le Masquage", + ) top_cols = self._columns(parent, 3, gap=8, height=300) pdf_opts = ui_kit.Card(top_cols[0], p, title="⬛ PDF") @@ -423,19 +469,31 @@ class ConfigTab(ctk.CTkFrame): def _build_partage(self, parent) -> None: p = self._p + self._section_intro( + parent, + "Partagez vos réglages (jamais vos documents) entre postes ou avec l'administrateur.", + _HELP_PARTAGE, + "À quoi sert le Partage ?", + ) cols = self._columns(parent, 2, gap=8, height=180) export = ui_kit.Card(cols[0], p, title="📤 Exporter la configuration") export.pack(fill="both", expand=True) self._note(export, "Listes locales, règles admin, style de masquage et template actif.") - ui_kit.secondary_button(export, p, "⬇ Exporter (.json)", command=lambda: None).pack(anchor="w", padx=12, pady=(0, 12)) + self._mockup_button(export, "⬇ Exporter (.json)").pack(anchor="w", padx=12, pady=(0, 12)) import_card = ui_kit.Card(cols[1], p, title="📥 Importer une configuration") import_card.pack(fill="both", expand=True) self._note(import_card, "Fusionne la configuration reçue avec vos réglages locaux.") - ui_kit.secondary_button(import_card, p, "⬆ Importer (.json)", command=lambda: None).pack(anchor="w", padx=12, pady=(0, 12)) + self._mockup_button(import_card, "⬆ Importer (.json)").pack(anchor="w", padx=12, pady=(0, 12)) def _build_regles(self, parent) -> None: p = self._p + self._section_intro( + parent, + "Règles d'adaptation du moteur à votre établissement.", + _HELP_REGLES, + "Les Règles", + ) card = ui_kit.Card(parent, p, title="🛡️ Règles actives") card.pack(fill="x", pady=(0, 8)) self._note(card, "Ces règles adaptent le moteur à votre établissement. Chaque règle est validée avant activation.") @@ -451,8 +509,8 @@ class ConfigTab(ctk.CTkFrame): self._rule_row(card, row) actions = ctk.CTkFrame(card, fg_color="transparent") actions.pack(fill="x", padx=12, pady=(8, 12)) - ui_kit.primary_button(actions, p, "+ Nouvelle règle", command=lambda: None).pack(side="left", padx=(0, 8)) - ui_kit.secondary_button(actions, p, "🔄 Recharger", command=lambda: None).pack(side="left") + self._mockup_button(actions, "+ Nouvelle règle", primary=True).pack(side="left", padx=(0, 8)) + self._mockup_button(actions, "🔄 Recharger").pack(side="left") sim = ui_kit.Card(parent, p, title="🧪 Testeur de règle") sim.pack(fill="x") @@ -462,8 +520,29 @@ class ConfigTab(ctk.CTkFrame): txt.insert("1.0", "Compte rendu CHUXX, patient N° 1234567.") btns = ctk.CTkFrame(sim, fg_color="transparent") btns.pack(fill="x", padx=12, pady=(0, 12)) - ui_kit.primary_button(btns, p, "▶ Tester", command=lambda: None).pack(side="left", padx=(0, 8)) - ui_kit.secondary_button(btns, p, "✖ Fermer", command=lambda: None).pack(side="left") + self._mockup_button(btns, "▶ Tester", primary=True).pack(side="left", padx=(0, 8)) + self._mockup_button(btns, "✖ Fermer").pack(side="left") + + # -- helpers aide / maquette ----------------------------------------- + + def _section_intro(self, parent, sentence: str, help_text: str, help_title: str) -> None: + """Ligne d'introduction d'une sous-section : phrase courte + bouton d'aide « ? ».""" + p = self._p + intro = ctk.CTkFrame(parent, fg_color="transparent") + intro.pack(fill="x", pady=(0, 6)) + ctk.CTkLabel( + intro, text=sentence, text_color=p["text_dim"], font=ui_kit.font(12), anchor="w", justify="left" + ).pack(side="left", padx=(2, 6)) + ui_kit.help_button(intro, p, help_text, title=help_title).pack(side="right", padx=2) + + def _mockup_button(self, parent, text: str, primary: bool = False): + """Bouton de maquette non câblé : désactivé + suffixe « (à venir) » pour + ne pas laisser croire qu'il fonctionne.""" + p = self._p + factory = ui_kit.primary_button if primary else ui_kit.secondary_button + btn = factory(parent, p, f"{text} (à venir)", command=lambda: None) + btn.configure(state="disabled") + return btn # -- callbacks réglages ---------------------------------------------- @@ -714,4 +793,4 @@ class ConfigTab(ctk.CTkFrame): ctk.CTkLabel(row, text=target, width=210, anchor="w", text_color=p["primary"], font=ui_kit.font(12, "bold")).pack(side="left") color = p["success"] if status == "Actif" else p["warning"] ctk.CTkLabel(row, text=status, width=70, anchor="w", text_color=color, font=ui_kit.font(11, "bold")).pack(side="left") - ui_kit.secondary_button(row, p, "▶ Tester", command=lambda: None).pack(side="left") + self._mockup_button(row, "▶ Tester").pack(side="left") diff --git a/gui_v6/tabs/tab_usage.py b/gui_v6/tabs/tab_usage.py index 3ace90b..41ec6ee 100644 --- a/gui_v6/tabs/tab_usage.py +++ b/gui_v6/tabs/tab_usage.py @@ -22,6 +22,16 @@ from gui_v6.processing_runner import ProcessingRunner, default_output_dir _STEPS = ["📖 Extraction", "🧠 Détection", "🔒 Masquage", "📄 PDF final"] +_HELP_USAGE = ( + "Anonymiser vos documents.\n\n" + "1) Choisissez un fichier ou un dossier de documents.\n" + "2) Vérifiez le format de sortie.\n" + "3) Cliquez sur « Lancer » : l'application détecte et masque les données " + "personnelles, puis écrit les documents anonymisés dans un dossier de sortie.\n\n" + "Tout le traitement se fait 100 % en local sur ce poste. Aucun document " + "n'est envoyé sur Internet." +) + class UsageTab(ctk.CTkFrame): def __init__( @@ -57,6 +67,18 @@ class UsageTab(ctk.CTkFrame): def _build(self) -> None: p = self._p + # Bandeau d'introduction + aide « ? » + intro = ctk.CTkFrame(self, fg_color="transparent") + intro.pack(fill="x", padx=14, pady=(12, 0)) + ctk.CTkLabel( + intro, + text="Sélectionnez vos documents puis lancez l'anonymisation (100 % local).", + text_color=p["text_dim"], + font=ui_kit.font(12), + anchor="w", + ).pack(side="left", padx=(2, 6)) + ui_kit.help_button(intro, p, _HELP_USAGE, title="Comment ça marche ?").pack(side="right", padx=2) + # Carte Apparence (sélecteur de thème) appearance = ui_kit.Card(self, p, title="🎨 Apparence") appearance.pack(fill="x", padx=14, pady=(14, 7)) diff --git a/gui_v6/ui_kit.py b/gui_v6/ui_kit.py index 65176e5..cdd2cc0 100644 --- a/gui_v6/ui_kit.py +++ b/gui_v6/ui_kit.py @@ -146,3 +146,88 @@ class ToggleRow(ctk.CTkFrame): 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) diff --git a/tests/unit/test_gui_v6_app_shell.py b/tests/unit/test_gui_v6_app_shell.py new file mode 100644 index 0000000..c505f79 --- /dev/null +++ b/tests/unit/test_gui_v6_app_shell.py @@ -0,0 +1,108 @@ +"""Shell GUI V6 : robustesse du changement de thème, libellés d'onglets, aide. + +Smokes headless (Xvfb) — skip propre si pas de display. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def app(): + pytest.importorskip("customtkinter") + try: + from gui_v6.app import AnonymisationApp + + a = AnonymisationApp() + except Exception as exc: # pas de display + pytest.skip(f"display Tk indisponible: {exc}") + a.withdraw() + try: + yield a + finally: + try: + a.destroy() + except Exception: + pass + + +def test_usage_tab_survives_theme_change(app): + """Retour Dom #1 : l'onglet Utilisation ne doit pas se vider au changement + de thème (le cache d'onglets ne doit pas conserver de widgets détruits).""" + app._show("use") + app.update_idletasks() + assert app._active == "use" + + other = "clair" if app._theme_name != "clair" else "sombre" + app.set_theme(other) + app.update_idletasks() + + assert app._active == "use" + assert "use" in app._tab_frames + frame = app._tab_frames["use"] + assert frame.winfo_exists() # onglet recréé et vivant, pas un widget mort + + +def test_main_tab_renamed_to_administration(): + """Retour Dom #2 : l'onglet principal Configuration devient Administration.""" + from gui_v6.app import _TABS + + labels = [label for _, label in _TABS] + assert any("Administration" in lbl for lbl in labels) + assert not any("Configuration" in lbl for lbl in labels) + + +def test_rules_subtab_has_no_unexplained_2(): + """Retour Dom #3 : « Règles 2 » incompréhensible → simple « Règles ».""" + from gui_v6.tabs.tab_config import _SUBTABS + + labels = [label for _, label in _SUBTABS] + assert any(lbl.strip() == "🛡️ Règles" for lbl in labels) + assert not any("Règles 2" in lbl or "Règles 2" in lbl for lbl in labels) + + +def test_help_button_opens_help_window(app): + """Retours Dom #4/#5 : affordance d'aide « ? » réutilisable qui ouvre une + fenêtre d'aide en français.""" + from gui_v6 import theme as theme_mod + from gui_v6 import ui_kit + + p = theme_mod.get_palette(theme_mod.DEFAULT_THEME) + btn = ui_kit.help_button(app, p, "Cette section reste 100 % locale.", title="Aide test") + assert btn.cget("text") == "?" + win = btn.open_help() + app.update_idletasks() + assert win.winfo_exists() + win.destroy() + + +def _count_help_buttons(widget) -> int: + from gui_v6.ui_kit import HelpButton + + total = 1 if isinstance(widget, HelpButton) else 0 + for child in widget.winfo_children(): + total += _count_help_buttons(child) + return total + + +def test_each_tab_exposes_help(app): + """Retour Dom #5 : une affordance d'aide « ? » est présente sur chaque onglet.""" + for key in ("use", "cfg", "about"): + app._show(key) + app.update_idletasks() + assert _count_help_buttons(app._tab_frames[key]) >= 1, key + + +def test_navigation_and_theme_change_keep_tabs_alive(app): + """Navigation + changement de thème : aucun onglet vide/mort.""" + for key in ("use", "cfg", "about"): + app._show(key) + app.update_idletasks() + assert app._tab_frames[key].winfo_exists() + app.set_theme("medical") + app.update_idletasks() + for key in ("use", "cfg", "about"): + app._show(key) + app.update_idletasks() + assert app._tab_frames[key].winfo_exists()