"""Onglet Configuration de la GUI V6. L'onglet est construit une seule fois : les sous-sections sont des panneaux préchargés puis simplement relevés avec ``tkraise()``. Cela évite le rendu progressif et les micro-latences visibles lors du passage Réglages/Masquage/ Partage/Règles. """ from __future__ import annotations import sys from pathlib import Path from tkinter import filedialog, messagebox import customtkinter as ctk import engine_capabilities from gui_v6 import theme as theme_mod from gui_v6 import ui_kit from gui_v6.config_state import ConfigState, default_profile_key, list_profile_keys from manual_masking import ensure_mask_templates_dir, list_mask_templates, mask_template_label _SUBTABS = [ ("reg", "⚙️ Réglages"), ("pro", "👤 Profils"), ("shr", "🔄 Partage"), ] # Chaque ligne = (libellé, aide, champ ConfigState). Le champ relie le toggle # à la catégorie moteur (cf. gui_v6.config_state.CATEGORY_FIELDS). ON = détecter # (masquer) ; OFF = laisser en clair (entre dans disabled_kinds). _DETECTION_OPTIONS = [ ("Noms et prénoms", "Annuaire + IA", "detect_nom"), ("Dates de naissance", "Contexte naissance", "detect_date_naissance"), ("Établissements", "FINESS + contexte", "detect_etab"), ("Adresses / CP", "Voie + code postal", "detect_adresse"), ("N° sécurité sociale", "NIR", "detect_nir"), ("Téléphones", "Fixe + mobile", "detect_tel"), ("N° adhérent mutuelle", "Identifiant local", "detect_adherent"), ] _MASK_COLORS = [ ("Noir", "#000000"), ("Bleu nuit", "#1a1a2e"), ("Gris", "#374151"), ("Marron", "#92400e"), ("Bleu marine", "#1e3a5f"), ] MANUAL_MASK_NONE_LABEL = "Aucun masque manuel" MINI_TOGGLE_HEIGHT = 46 MINI_TOGGLE_LABEL_FONT_SIZE = 12 MINI_TOGGLE_HINT_FONT_SIZE = 11 # Textes d'aide « ? » (français simple, pour utilisateurs non informaticiens). _HELP_REGLAGES = ( "Réglages de l'anonymisation.\n\n" "• Profil d'anonymisation : choisit un jeu de réglages adapté à votre usage.\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_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." ) _HELP_PROFIL = ( "Un profil d'anonymisation regroupe tous les réglages adaptés à un usage " "(ex. : interne standard, diffusion prudente, recherche…).\n\n" "Il définit les moteurs utilisés, les données détectées, les termes à conserver " "ou à masquer, et si un masque manuel est requis.\n\n" "Choisissez un profil ici ; ses termes sont consultables via « Ouvrir le tableau des termes »." ) _HELP_MOTEURS = ( "Les moteurs détectent les données personnelles.\n\n" "• CamemBERT-bio : moteur standard, rapide et fiable — activé par défaut.\n" "• EDS-Pseudo et GLiNER : optionnels. Ils renforcent la détection mais sont " "plus lents et ne sont pas toujours installés sur le poste.\n\n" "Si tout n'est pas coché, c'est que les moteurs optionnels ne sont pas requis " "par le profil ou pas disponibles." ) _HELP_LISTES = ( "Les listes locales personnalisent la détection pour votre établissement :\n\n" "• À conserver : termes à ne jamais masquer (vocabulaire métier).\n" "• À masquer : termes à toujours masquer (sigles, en-têtes…).\n" "• À ignorer : mots à ne pas considérer.\n\n" "Pour une liste longue, ouvrez le tableau des termes : " "il reste lisible et permet la recherche." ) _HELP_DONNEES_DETECTER = ( "Cette zone indique les familles de données que le profil cherche à anonymiser : " "noms, dates de naissance, établissements, adresses, identifiants, téléphones et e-mails.\n\n" "Ces options décrivent le périmètre fonctionnel attendu. Les règles exactes restent " "contrôlées par le moteur et par le profil actif." ) _HELP_MOTEURS_MASQUES = ( "Cette zone regroupe les moteurs de détection et les réglages de masque manuel.\n\n" "Le masque manuel sert aux zones fixes d'un document que le texte ne suffit pas à détecter " "correctement : logo, en-tête, coordonnées, bloc institutionnel ou tampon scanné.\n\n" "Si « Masque manuel obligatoire » est actif, le profil impose cette étape de contrôle " "avant de considérer le traitement complet." ) _HELP_PROFIL_CHOIX = ( "Choisissez ici le profil à modifier.\n\n" "Les profils livrés par défaut sont en lecture seule pour éviter une modification accidentelle. " "Dupliquez un profil pour créer une version adaptée à votre établissement." ) _HELP_PROFIL_IDENTITE = ( "Nom et description visibles dans l'interface.\n\n" "Utilisez un nom simple que les utilisateurs comprendront, par exemple « Standard local », " "« Recherche » ou « Diffusion externe prudente »." ) _HELP_PROFIL_MASQUAGE = ( "Cette zone règle les masques propres au profil.\n\n" "Masquage manuel obligatoire : le profil impose une vérification avec un masque de zones fixes " "avant le traitement. C'est utile pour les documents qui ont toujours les mêmes zones sensibles " "au même endroit : logos, en-têtes, coordonnées, tampons ou blocs scannés.\n\n" "Template de masque préféré : modèle proposé automatiquement par ce profil. " "L'éditeur de masque permet de créer ou ajuster ces zones visuellement." ) _HELP_PROFIL_MOTEURS = ( "Cette zone précise les moteurs utilisés par le profil.\n\n" "CamemBERT-bio est le moteur standard. Les moteurs optionnels ne sont proposés que s'ils sont " "réellement embarqués dans cette version. Le moteur VLM concerne surtout les documents images." ) _HELP_PROFIL_MOTS = ( "Ces listes appartiennent au profil.\n\n" "À masquer : termes à remplacer systématiquement.\n" "À conserver : termes à ne jamais masquer, même s'ils ressemblent à des noms.\n" "À ignorer : mots qui ne doivent pas déclencher de détection.\n\n" "Pour de longues listes, utilisez le tableau des termes afin de rechercher et vérifier plus facilement." ) _HELP_EXPORT_CONFIG = ( "Exporte uniquement les réglages de l'application : profils, listes locales, règles et style de masque.\n\n" "Les documents patients, résultats d'anonymisation et audits ne sont pas exportés." ) _HELP_IMPORT_CONFIG = ( "Importe des réglages reçus d'un administrateur ou d'un autre poste.\n\n" "L'import ne lit pas de documents patients. Vérifiez toujours le profil actif après import." ) CONFIG_MOCKUP_SECTIONS = { "reglages": [ "Profil d'anonymisation", "Moteurs NER", "Données à détecter", "Termes à toujours conserver", "Termes à toujours masquer", "Masque manuel obligatoire", "Template de masque manuel", ], "masquage": [ "Couleur de masquage (PDF)", "Style des marqueurs (texte)", "Épaisseur du masque", "Codes de remplacement", "Masques de zones fixes", "Éditeur interactif de masques", ], "partage": ["Exporter la configuration", "Importer une configuration"], "regles": ["Règles actives", "Testeur de règle"], } CONFIG_INTERACTION_CONTRACT = { "subtabs": "prebuilt_panels", "reglages_columns": 3, "mask_editor": [ "open_pdf", "draw_rectangle", "delete_rectangle_on_click", "zoom", "save_template_json", "load_template_json_or_yaml", "clear_page", "apply_template_selection", ], } def _app_base_dir() -> Path: if getattr(sys, "frozen", False): return Path(sys.executable).resolve().parent return Path.cwd() class ConfigTab(ctk.CTkFrame): def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, **kwargs): self._p = palette or theme_mod.get_palette(theme_mod.DEFAULT_THEME) super().__init__(master, fg_color=self._p["bg"], **kwargs) self._state = state if state is not None else ConfigState() self._sub = "reg" self._sub_buttons: dict[str, ctk.CTkButton] = {} self._panels: dict[str, ctk.CTkFrame] = {} self._manual_mask_templates: dict[str, Path | None] = {MANUAL_MASK_NONE_LABEL: None} self._manual_mask_var = ctk.StringVar(value=MANUAL_MASK_NONE_LABEL) self._manual_mask_required_var = ctk.BooleanVar(value=self._state.manual_mask_required) self._mask_style_var = ctk.StringVar(value=self._state.mask_marker_style) self._mask_color = self._state.mask_color self._mask_margin_x_var = ctk.IntVar(value=self._state.mask_margin_x) self._mask_margin_y_var = ctk.IntVar(value=self._state.mask_margin_y) self._mask_rounded_var = ctk.BooleanVar(value=self._state.mask_rounded_corners) self._mask_status_text = ctk.StringVar(value="Éditeur de masques en fenêtre dédiée.") # L'édition interactive des masques se fait dans une fenêtre séparée # (gui_v6.mask_editor_window) ; on garde juste une référence à l'instance # ouverte pour éviter d'en empiler plusieurs. self._mask_editor_window = None # Éditeur de profils : chemin overlay runtime (None = config/profiles.yml standard, # surchargeable en test), clé en cours d'édition + widgets. self._profiles_path = None self._pro_edit_key: str | None = None self._pro_term_lists: dict = {} self._profile_scroll = None self._build() @property def state(self) -> ConfigState: return self._state # -- construction ----------------------------------------------------- def _build(self) -> None: p = self._p bar = ctk.CTkFrame(self, fg_color="transparent") bar.pack(fill="x", padx=14, pady=(10, 2)) for key, label in _SUBTABS: btn = ctk.CTkButton( bar, text=label, command=lambda k=key: self._show_sub(k), fg_color=p["btn_sec_bg"] if key == self._sub else "transparent", hover_color=p["card_border"], text_color=p["primary"] if key == self._sub else p["text_dim"], border_color=p["card_border"], border_width=1 if key == self._sub else 0, font=ui_kit.font(13, "bold" if key == self._sub else "normal"), corner_radius=6, width=10, height=30, ) btn.pack(side="left", padx=(0, 6)) self._sub_buttons[key] = btn self._body = ctk.CTkFrame(self, fg_color="transparent") self._body.pack(fill="both", expand=True, padx=14, pady=(4, 0)) self._body.grid_columnconfigure(0, weight=1) self._body.grid_rowconfigure(0, weight=1) builders = { "reg": self._build_reglages, "pro": self._build_profils, "shr": self._build_partage, } for key, builder in builders.items(): panel = ctk.CTkFrame(self._body, fg_color="transparent") panel.grid(row=0, column=0, sticky="nsew") self._panels[key] = panel builder(panel) self._refresh_manual_mask_templates() self._show_sub("reg") def _show_sub(self, key: str) -> None: self._sub = key p = self._p for k, btn in self._sub_buttons.items(): active = k == key btn.configure( fg_color=p["btn_sec_bg"] if active else "transparent", border_width=1 if active else 0, text_color=p["primary"] if active else p["text_dim"], font=ui_kit.font(13, "bold" if active else "normal"), ) self._panels[key].tkraise() # -- Réglages --------------------------------------------------------- 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"], border_color=p["card_border"], border_width=1, corner_radius=8, ) top.pack(fill="x", pady=(0, 8)) profiles = list_profile_keys() current = self._state.profile or default_profile_key() or (profiles[0] if profiles else "") self._state.profile = current or None ctk.CTkLabel(top, text="Profil d'anonymisation", text_color=p["text_dim"], font=ui_kit.font(11, "bold")).pack( side="left", padx=(12, 8), pady=10 ) self._profile_menu = ctk.CTkOptionMenu( top, values=profiles or ["(aucun profil)"], command=self._on_profile, fg_color=p["btn_sec_bg"], button_color=p["primary"], button_hover_color=p["primary_dim"], text_color=p["text"], width=180, height=30, ) if current: self._profile_menu.set(current) self._profile_menu.pack(side="left", pady=10) ui_kit.help_button(top, p, _HELP_PROFIL, title="Profil d'anonymisation").pack(side="left", padx=(6, 0), pady=10) ui_kit.secondary_button(top, p, "✏️ Modifier le profil…", command=self._open_profile_editor).pack( side="left", padx=(10, 4), pady=10 ) sortie = ui_kit.secondary_button(top, p, "📁 Dossier de sortie…", command=self._pick_output) sortie.pack(side="left", padx=(6, 6), pady=10) ui_kit.attach_tooltip( sortie, "Dossier où seront écrits les documents anonymisés.\nRéglage local de traitement (pas une règle du profil)." ) self._out_label = ctk.CTkLabel( top, text=str(self._state.output_dir or "anonymise/"), text_color=p["text_muted"], font=ui_kit.font(12), ) self._out_label.pack(side="left", fill="x", expand=True, padx=(0, 12), pady=10) cols = self._columns(parent, 3, gap=8, height=455) det = ui_kit.Card( cols[0], p, title="🔍 Données à détecter", help_text=_HELP_DONNEES_DETECTER, help_title="Données à détecter", ) det.pack(fill="both", expand=True) # Les 7 toggles « Données à détecter » sont câblés sur les booléens # detect_* de ConfigState (lecture initiale + écriture au changement). # ON = détecter/masquer ; OFF = laisser en clair (→ disabled_kinds). self._detect_toggles: dict[str, object] = {} for label, hint, field_name in _DETECTION_OPTIONS: toggle = self._mini_toggle( det, label, hint, value=bool(getattr(self._state, field_name)), command=lambda f=field_name: self._on_detect_toggle(f), ) toggle.pack(fill="x", padx=12, pady=1) self._detect_toggles[field_name] = toggle ner = ui_kit.Card( cols[1], p, title="🧠 Moteurs et masques", help_text=_HELP_MOTEURS_MASQUES, help_title="Moteurs et masques", ) ner.pack(fill="both", expand=True) hint_row = ctk.CTkFrame(ner, fg_color="transparent") hint_row.pack(fill="x", padx=12, pady=(0, 2)) ctk.CTkLabel( hint_row, text="Pourquoi pas tout coché ?", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w" ).pack(side="left") ui_kit.help_button(hint_row, p, _HELP_MOTEURS, title="Moteurs de détection").pack(side="right") # Honnêteté moteurs : ne pas proposer un moteur que ce build n'embarque pas. caps = engine_capabilities.capabilities_map() eds_off = not caps["eds"].available gli_off = not caps["gliner"].available if eds_off: self._state.enable_eds = False if gli_off: self._state.enable_gliner = False self._tog_ner = self._mini_toggle( ner, "CamemBERT-bio", "standard · rapide · F1 0.963", value=self._state.use_local_ner, command=self._on_ner ) self._tog_ner.pack(fill="x", padx=12, pady=1) self._tog_eds = self._mini_toggle( ner, "EDS-Pseudo", "optionnel · médical français · plus lent", value=self._state.enable_eds, command=self._on_eds, disabled=eds_off, disabled_hint="non embarqué dans cette version", ) self._tog_eds.pack(fill="x", padx=12, pady=1) self._tog_gli = self._mini_toggle( ner, "GLiNER", "optionnel · vote croisé · plus lent", value=self._state.enable_gliner, command=self._on_gliner, disabled=gli_off, disabled_hint="non embarqué dans cette version", ) self._tog_gli.pack(fill="x", padx=12, pady=1) self._mini_toggle( ner, "Masque manuel obligatoire", "bloque le traitement si absent", value=self._state.manual_mask_required, variable=self._manual_mask_required_var, command=self._on_manual_mask_required, ).pack(fill="x", padx=12, pady=(8, 1)) ctk.CTkLabel( ner, text="Template de masque manuel", text_color=p["text_muted"], font=ui_kit.font(11, "bold"), anchor="w", ).pack(fill="x", padx=12, pady=(10, 2)) self._manual_mask_menu = ctk.CTkOptionMenu( ner, values=[MANUAL_MASK_NONE_LABEL], variable=self._manual_mask_var, command=self._on_manual_mask_template, fg_color=p["btn_sec_bg"], button_color=p["primary"], button_hover_color=p["primary_dim"], text_color=p["text"], height=30, ) self._manual_mask_menu.pack(fill="x", padx=12, pady=(0, 6)) mask_actions = ctk.CTkFrame(ner, fg_color="transparent") mask_actions.pack(fill="x", padx=12, pady=(0, 12)) ui_kit.secondary_button(mask_actions, p, "🔄 Actualiser", command=self._refresh_manual_mask_templates).pack( side="left", fill="x", expand=True ) terms = ui_kit.Card( cols[2], p, title="✅ Listes locales", help_text=_HELP_LISTES, help_title="Listes locales", ) terms.pack(fill="both", expand=True) terms_help = ctk.CTkFrame(terms, fg_color="transparent") terms_help.pack(fill="x", padx=12, pady=(0, 2)) ctk.CTkLabel( terms_help, text="Termes propres à votre établissement", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w" ).pack(side="left") ui_kit.help_button(terms_help, p, _HELP_LISTES, title="Listes locales").pack(side="right") ctk.CTkLabel( terms, text="Les termes du profil actif sont consultables dans un tableau dédié.", text_color=p["text_dim"], font=ui_kit.font(12), justify="left", wraplength=240, anchor="w", ).pack(fill="x", padx=12, pady=(2, 6)) counts = self._active_profile_summary().list_counts chips = ctk.CTkFrame(terms, fg_color="transparent") chips.pack(fill="x", padx=12, pady=(0, 8)) for label, count in counts.items(): ctk.CTkLabel( chips, text=f"{label} : {count}", text_color=p["text"], fg_color=p["divider"], corner_radius=8, font=ui_kit.font(11, "bold"), ).pack(side="left", padx=(0, 6), ipadx=7, ipady=2) ui_kit.primary_button( terms, p, "📋 Ouvrir le tableau des termes", command=self._open_terms_table ).pack(fill="x", padx=12, pady=(2, 12)) # -- Profil actif / tableau des termes -------------------------------- def _active_profile_dict(self) -> dict: try: from profile_defaults import list_effective_profiles key = self._state.profile or default_profile_key() if not key: return {} return list_effective_profiles().get(key, {}) or {} except Exception: return {} def _active_profile_summary(self): from gui_v6.profile_view import summarize_profile key = self._state.profile or default_profile_key() or "" return summarize_profile(key, self._active_profile_dict()) def _open_terms_table(self) -> None: from gui_v6.profile_view import profile_term_rows from gui_v6.terms_table_window import TermsTableWindow summary = self._active_profile_summary() rows = profile_term_rows(self._active_profile_dict()) TermsTableWindow(self.winfo_toplevel(), self._p, rows, profile_label=summary.label) # -- Profils (éditeur) ------------------------------------------------ def _build_profils(self, parent) -> None: p = self._p from gui_v6.editable_list import EditableTermList # L'application fournit déjà un scroll vertical global. Un second # CTkScrollableFrame imbriqué coupe la page Profils sous Windows et # laisse un grand bloc vide en bas de fenêtre. self._profile_scroll = None self._section_intro( parent, "Un profil regroupe les moteurs, les masques, les règles et les mots à conserver ou masquer.", _HELP_PROFIL, "Profils d'anonymisation", ) bar = ui_kit.Card( parent, p, title="👤 Profil à modifier", help_text=_HELP_PROFIL_CHOIX, help_title="Profil à modifier", ) bar.pack(fill="x", pady=(0, 8)) top = ctk.CTkFrame(bar, fg_color="transparent") top.pack(fill="x", padx=12, pady=(0, 4)) self._pro_menu_var = ctk.StringVar(value="") self._pro_menu = ctk.CTkOptionMenu( top, values=["—"], variable=self._pro_menu_var, command=self._pro_on_select, fg_color=p["btn_sec_bg"], button_color=p["primary"], button_hover_color=p["primary_dim"], text_color=p["text"], width=260, height=30, ) self._pro_menu.pack(side="left") self._pro_status = ctk.CTkLabel(top, text="", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w") self._pro_status.pack(side="left", padx=(10, 0)) actions = ctk.CTkFrame(bar, fg_color="transparent") actions.pack(fill="x", padx=12, pady=(0, 12)) ui_kit.secondary_button(actions, p, "+ Nouveau", command=self._pro_new).pack(side="left", padx=(0, 6)) ui_kit.secondary_button(actions, p, "⧉ Dupliquer", command=self._pro_duplicate).pack(side="left", padx=(0, 6)) self._pro_save_btn = ui_kit.primary_button(actions, p, "💾 Enregistrer", command=self._pro_save) self._pro_save_btn.pack(side="left", padx=(6, 6)) ui_kit.secondary_button(actions, p, "↩ Annuler", command=self._pro_cancel).pack(side="left") self._pro_default_btn = ui_kit.secondary_button(actions, p, "⭐ Définir par défaut", command=self._pro_set_default) self._pro_default_btn.pack(side="right") cols = self._columns(parent, 2, gap=8) left, right = cols[0], cols[1] ident = ui_kit.Card( left, p, title="🏷️ Identité", help_text=_HELP_PROFIL_IDENTITE, help_title="Identité du profil", ) ident.pack(fill="x", pady=(0, 8)) self._pro_label_var = ctk.StringVar() self._pro_label_entry = ctk.CTkEntry(ident, textvariable=self._pro_label_var, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=30) ctk.CTkLabel(ident, text="Nom du profil", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=12, pady=(0, 2)) self._pro_label_entry.pack(fill="x", padx=12, pady=(0, 6)) self._pro_desc_var = ctk.StringVar() self._pro_desc_entry = ctk.CTkEntry(ident, textvariable=self._pro_desc_var, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=30) ctk.CTkLabel(ident, text="Description", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=12, pady=(0, 2)) self._pro_desc_entry.pack(fill="x", padx=12, pady=(0, 12)) eng = ui_kit.Card( right, p, title="🧠 Moteurs", help_text=_HELP_PROFIL_MOTEURS, help_title="Moteurs du profil", ) eng.pack(fill="x", pady=(0, 8)) self._pro_disable_vlm_var = ctk.BooleanVar(value=False) self._pro_vlm_switch = ctk.CTkSwitch(eng, text="Désactiver le moteur VLM (images)", variable=self._pro_disable_vlm_var, progress_color=p["primary"], text_color=p["text"], font=ui_kit.font(12)) self._pro_vlm_switch.pack(anchor="w", padx=12, pady=(0, 6)) # Note honnête : reflète les moteurs réellement embarqués par ce build. caps_pro = engine_capabilities.capabilities_map() opt = [c.label.split(" (")[0] for c in (caps_pro["eds"], caps_pro["gliner"]) if c.available] if opt: moteurs_note = "CamemBERT-bio (standard) toujours actif ; " + " / ".join(opt) + " disponibles (optionnels)." else: moteurs_note = "CamemBERT-bio (standard) toujours actif ; EDS-Pseudo / GLiNER non embarqués dans cette version." ctk.CTkLabel(eng, text=moteurs_note, text_color=p["text_muted"], font=ui_kit.font(11), anchor="w", wraplength=300, justify="left").pack(fill="x", padx=12, pady=(0, 12)) mask = ui_kit.Card( right, p, title="⬛ Masquage", help_text=_HELP_PROFIL_MASQUAGE, help_title="Masquage manuel", ) mask.pack(fill="x", pady=(0, 8)) self._pro_require_mask_var = ctk.BooleanVar(value=False) self._pro_require_switch = ctk.CTkSwitch(mask, text="Masque manuel obligatoire", variable=self._pro_require_mask_var, progress_color=p["primary"], text_color=p["text"], font=ui_kit.font(12)) self._pro_require_switch.pack(anchor="w", padx=12, pady=(0, 6)) ctk.CTkLabel(mask, text="Template de masque préféré", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=12, pady=(0, 2)) self._pro_template_var = ctk.StringVar() self._pro_template_entry = ctk.CTkEntry(mask, textvariable=self._pro_template_var, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=30) self._pro_template_entry.pack(fill="x", padx=12, pady=(0, 6)) mask_actions = ctk.CTkFrame(mask, fg_color="transparent") mask_actions.pack(fill="x", padx=12, pady=(0, 6)) ui_kit.secondary_button(mask_actions, p, "🖊 Ouvrir l'éditeur de masque", command=self._open_full_mask_editor).pack(side="left") # Apparence du masque (couleur / style / marges) — réglage global appliqué aux PDF. ctk.CTkLabel(mask, text="Apparence du masque", text_color=p["text_muted"], font=ui_kit.font(11, "bold"), anchor="w").pack(fill="x", padx=12, pady=(6, 2)) swatches = ctk.CTkFrame(mask, fg_color="transparent") swatches.pack(fill="x", padx=12, pady=(0, 6)) self._swatch_buttons: dict[str, ctk.CTkButton] = {} for _label, color_value in _MASK_COLORS: btn = ctk.CTkButton( swatches, text="", width=28, height=24, fg_color=color_value, hover_color=color_value, border_color=p["primary"] if color_value == self._mask_color else p["card_border"], border_width=3 if color_value == self._mask_color else 1, corner_radius=6, command=lambda c=color_value: self._set_mask_color(c), ) btn.pack(side="left", padx=(0, 6)) self._swatch_buttons[color_value] = btn style_row = ctk.CTkFrame(mask, fg_color="transparent") style_row.pack(fill="x", padx=12, pady=(0, 6)) for _label, value, preview in [("Crochets", "brackets", "[NOM]"), ("Étoiles", "stars", "***"), ("Noirci", "blackout", "████")]: ctk.CTkRadioButton( style_row, text=preview, variable=self._mask_style_var, value=value, command=self._update_mask_preview, text_color=p["text"], fg_color=p["primary"], hover_color=p["primary_dim"], font=ui_kit.font(11), ).pack(side="left", padx=(0, 10)) self._slider_row(mask, "Marge H", self._mask_margin_x_var, self._on_mask_margin_x) self._slider_row(mask, "Marge V", self._mask_margin_y_var, self._on_mask_margin_y) self._mini_toggle( mask, "Coins arrondis", "", value=self._state.mask_rounded_corners, variable=self._mask_rounded_var, command=self._on_rounded_corners, ).pack(fill="x", padx=12, pady=(2, 12)) words = ui_kit.Card( parent, p, title="📝 Mots du profil", help_text=_HELP_PROFIL_MOTS, help_title="Mots du profil", ) words.pack(fill="x", pady=(8, 0)) self._pro_term_lists = { "blacklist": EditableTermList(words, p, title="À masquer", height=78), "whitelist": EditableTermList(words, p, title="À conserver", height=78), "stopwords": EditableTermList(words, p, title="À ignorer", height=66), } for tl in self._pro_term_lists.values(): tl.pack(fill="x", padx=12, pady=(0, 8)) rules = ui_kit.Card( parent, p, title="🛡️ Règles du profil", help_text=_HELP_REGLES, help_title="Règles du profil", ) rules.pack(fill="x", pady=(8, 0)) rules_intro = ctk.CTkFrame(rules, fg_color="transparent") rules_intro.pack(fill="x", padx=12, pady=(0, 2)) ctk.CTkLabel( rules_intro, text="Règles d'anonymisation portées par ce profil (adaptées à votre établissement).", text_color=p["text_dim"], font=ui_kit.font(12), anchor="w", justify="left", wraplength=520, ).pack(side="left", padx=(0, 6)) ui_kit.help_button(rules_intro, p, _HELP_REGLES, title="Les Règles du profil").pack(side="right") headers = ctk.CTkFrame(rules, fg_color="transparent") headers.pack(fill="x", padx=12, pady=(2, 4)) for text, width in [("Label", 190), ("Type", 80), ("Cible → Résultat", 210), ("Statut", 70), ("", 70)]: ctk.CTkLabel(headers, text=text.upper(), width=width, anchor="w", text_color=p["text_muted"], font=ui_kit.font(10, "bold")).pack(side="left") for row in [ ("Masquer le sigle CHUXX", "exact", "CHUXX → [MASK]", "Actif"), ("Préserver “classification internationale”", "preserve", "conservé tel quel", "Actif"), ("Identifier N° 1234567", "norm-id", "N° 1234567 → [NDA]", "Candidat"), ]: self._rule_row(rules, row) self._note(rules, "Aperçu illustratif. L'édition fine des règles du profil arrivera dans une prochaine version.") self._mockup_button(rules, "+ Ajouter une règle").pack(anchor="w", padx=12, pady=(0, 12)) self._pro_refresh_and_load() # -- Profils : logique ----------------------------------------------- def _pro_choices(self) -> list: from gui_v6.profile_editor import list_profile_choices return list_profile_choices(self._profiles_path) @staticmethod def _pro_label_for(choice: dict) -> str: if choice["is_default"]: return f"{choice['label']} (défaut)" if not choice["editable"]: return f"{choice['label']} (lecture seule)" return choice["label"] def _pro_refresh_and_load(self, select_key: str | None = None) -> None: choices = self._pro_choices() self._pro_choice_by_label = {self._pro_label_for(c): c for c in choices} labels = list(self._pro_choice_by_label) or ["—"] self._pro_menu.configure(values=labels) target = None if select_key is not None: target = next((lbl for lbl, c in self._pro_choice_by_label.items() if c["key"] == select_key), None) if target is None: target = self._pro_menu_var.get() if self._pro_menu_var.get() in self._pro_choice_by_label else labels[0] self._pro_menu_var.set(target) self._pro_on_select(target) def _pro_on_select(self, label: str) -> None: choice = getattr(self, "_pro_choice_by_label", {}).get(label) if choice is None: return self._pro_load(choice["key"]) def _profile_dict_for(self, key: str) -> dict: from profile_defaults import list_effective_profiles try: return list_effective_profiles(self._profiles_path).get(key, {}) or {} except Exception: return {} def _pro_load(self, key: str) -> None: from gui_v6.profile_editor import profile_is_editable profile = self._profile_dict_for(key) self._pro_edit_key = key self._pro_label_var.set(str(profile.get("label") or key)) self._pro_desc_var.set(str(profile.get("description") or "")) self._pro_require_mask_var.set(bool(profile.get("require_manual_mask"))) self._pro_disable_vlm_var.set(bool(profile.get("force_disable_vlm"))) self._pro_template_var.set(str(profile.get("preferred_manual_mask_template") or "")) param_lists = profile.get("param_lists") or {} self._pro_term_lists["blacklist"].set_terms(param_lists.get("blacklist_force_mask_terms") or []) self._pro_term_lists["whitelist"].set_terms(param_lists.get("whitelist_phrases") or []) self._pro_term_lists["stopwords"].set_terms(param_lists.get("additional_stopwords") or []) editable = profile_is_editable(key, self._profiles_path) self._pro_set_editable(editable) self._pro_status.configure( text="Éditable" if editable else "Profil par défaut — lecture seule (dupliquez pour modifier)" ) def _pro_set_editable(self, editable: bool) -> None: state = "normal" if editable else "disabled" for widget in (self._pro_label_entry, self._pro_desc_entry, self._pro_template_entry, self._pro_require_switch, self._pro_vlm_switch, self._pro_save_btn): widget.configure(state=state) for term_list in self._pro_term_lists.values(): term_list.set_editable(editable) def _pro_collect_spec(self) -> dict: from gui_v6.profile_editor import build_profile_spec return build_profile_spec( label=self._pro_label_var.get(), description=self._pro_desc_var.get(), require_manual_mask=bool(self._pro_require_mask_var.get()), force_disable_vlm=bool(self._pro_disable_vlm_var.get()), preferred_manual_mask_template=self._pro_template_var.get(), whitelist=self._pro_term_lists["whitelist"].terms(), blacklist=self._pro_term_lists["blacklist"].terms(), stopwords=self._pro_term_lists["stopwords"].terms(), ) def _pro_unique_key(self, base: str) -> str: from gui_v6.profile_editor import slug_for_copy existing = {c["key"] for c in self._pro_choices()} if base not in existing: return base return slug_for_copy(base, existing) def _pro_new(self) -> None: from gui_v6.profile_editor import build_profile_spec, save_profile key = self._pro_unique_key("nouveau_profil") spec = build_profile_spec(label="Nouveau profil") try: save_profile(key, spec, path=self._profiles_path) except Exception as exc: # pragma: no cover messagebox.showerror("Profils", f"Impossible de créer le profil : {exc}") return self._pro_refresh_and_load(select_key=key) def _pro_duplicate(self) -> None: from gui_v6.profile_editor import save_profile, slug_for_copy if not self._pro_edit_key: return existing = {c["key"] for c in self._pro_choices()} new_key = slug_for_copy(self._pro_edit_key, existing) spec = self._pro_collect_spec() spec["label"] = f"{spec['label']} (copie)" try: save_profile(new_key, spec, path=self._profiles_path) except Exception as exc: # pragma: no cover messagebox.showerror("Profils", f"Impossible de dupliquer : {exc}") return self._pro_refresh_and_load(select_key=new_key) def _pro_save(self) -> None: from gui_v6.profile_editor import save_profile key = self._pro_edit_key if not key: return spec = self._pro_collect_spec() try: save_profile(key, spec, path=self._profiles_path) except Exception as exc: # pragma: no cover messagebox.showerror("Profils", f"Impossible d'enregistrer le profil : {exc}") return self._pro_refresh_and_load(select_key=key) # Confirmation non bloquante (pas de modale qui fige l'app). self._pro_status.configure(text=f"✓ Profil « {spec['label']} » enregistré.") def _pro_cancel(self) -> None: if self._pro_edit_key: self._pro_load(self._pro_edit_key) def _pro_set_default(self) -> None: from gui_v6.profile_editor import set_default_profile if not self._pro_edit_key: return try: set_default_profile(self._pro_edit_key, path=self._profiles_path) except Exception as exc: # pragma: no cover messagebox.showerror("Profils", f"Impossible de définir par défaut : {exc}") return self._pro_refresh_and_load(select_key=self._pro_edit_key) def _open_profile_editor(self) -> None: """Ouvre le sous-onglet Profils sur le profil actif (depuis Réglages).""" self._show_sub("pro") active = self._state.profile if active: self._pro_refresh_and_load(select_key=active) # -- Masquage --------------------------------------------------------- # -- Partage / Règles ------------------------------------------------- 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", help_text=_HELP_EXPORT_CONFIG, help_title="Exporter la configuration", ) export.pack(fill="both", expand=True) self._note(export, "Listes locales, règles admin, style de masquage et template actif.") 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", help_text=_HELP_IMPORT_CONFIG, help_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.") self._mockup_button(import_card, "⬆ Importer (.json)").pack(anchor="w", padx=12, pady=(0, 12)) # -- 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 ---------------------------------------------- def _on_profile(self, value: str) -> None: self._state.profile = value def _on_detect_toggle(self, field_name: str) -> None: """Recopie l'état d'un toggle « Données à détecter » dans ConfigState. ON = détecter (masquer) ; OFF = laisser en clair. ``disabled_kinds()`` de ConfigState dérive ensuite le set des catégories désactivées. """ toggle = self._detect_toggles.get(field_name) if toggle is not None: setattr(self._state, field_name, bool(toggle.get())) def _on_ner(self) -> None: self._state.use_local_ner = self._tog_ner.get() def _on_eds(self) -> None: self._state.enable_eds = self._tog_eds.get() def _on_gliner(self) -> None: self._state.enable_gliner = self._tog_gli.get() def _on_manual_mask_required(self) -> None: self._state.manual_mask_required = bool(self._manual_mask_required_var.get()) def _on_manual_mask_template(self, label: str) -> None: self._state.manual_mask_template = self._manual_mask_templates.get(label) def _pick_output(self) -> None: path = filedialog.askdirectory(title="Dossier de sortie") if path: self._state.output_dir = Path(path) self._out_label.configure(text=str(self._state.output_dir)) def _refresh_manual_mask_templates(self) -> None: selected = self._state.manual_mask_template options: dict[str, Path | None] = {MANUAL_MASK_NONE_LABEL: None} try: for path in list_mask_templates(_app_base_dir()): options[mask_template_label(path, _app_base_dir())] = path except Exception: pass self._manual_mask_templates = options labels = list(options) if hasattr(self, "_manual_mask_menu"): self._manual_mask_menu.configure(values=labels) selected_label = MANUAL_MASK_NONE_LABEL if selected is not None: for label, path in options.items(): if path == selected: selected_label = label break self._manual_mask_var.set(selected_label) self._state.manual_mask_template = options.get(selected_label) def _open_templates_dir(self) -> None: path = ensure_mask_templates_dir(_app_base_dir()) messagebox.showinfo("Dossier modèles", str(path)) # -- callbacks masquage ---------------------------------------------- def _set_mask_color(self, color: str) -> None: self._mask_color = color self._state.mask_color = color p = self._p for value, btn in self._swatch_buttons.items(): btn.configure( border_color=p["primary"] if value == color else p["card_border"], border_width=3 if value == color else 1, ) def _on_mask_margin_x(self, value: float) -> None: self._state.mask_margin_x = int(round(value)) self._mask_margin_x_var.set(self._state.mask_margin_x) def _on_mask_margin_y(self, value: float) -> None: self._state.mask_margin_y = int(round(value)) self._mask_margin_y_var.set(self._state.mask_margin_y) def _on_rounded_corners(self) -> None: self._state.mask_rounded_corners = bool(self._mask_rounded_var.get()) def _update_mask_preview(self) -> None: value = self._mask_style_var.get() self._state.mask_marker_style = value if value == "stars": text = "Patient ***, né le ***" elif value == "blackout": text = "Patient ████, né le ████" else: text = "Patient [NOM], né le [DATE_NAISSANCE]" self._mask_preview.configure(text=text) # -- éditeur masques -------------------------------------------------- def _open_full_mask_editor(self) -> None: existing = self._mask_editor_window if existing is not None: try: if existing.winfo_exists(): existing.lift() existing.focus_force() return except Exception: pass try: from gui_v6.mask_editor_window import MaskEditorWindow active = self._state.manual_mask_template initial_template = active if (active and Path(active).exists()) else None win = MaskEditorWindow( self.winfo_toplevel(), templates_dir=ensure_mask_templates_dir(_app_base_dir()), initial_template=initial_template, on_template_saved=self._on_mask_template_saved, ) self._mask_editor_window = win self._mask_status_text.set("Éditeur de masques ouvert.") except Exception as exc: messagebox.showerror("Masques PDF", f"Impossible d'ouvrir l'éditeur : {exc}") def _on_mask_template_saved(self, path: Path) -> None: """Callback déclenché par la fenêtre dédiée après sauvegarde d'un template. Lie le template au profil en cours d'édition (`preferred_manual_mask_template`). """ self._refresh_manual_mask_templates() try: self._manual_mask_var.set(mask_template_label(path, _app_base_dir())) self._state.manual_mask_template = path except Exception: pass # Renseigne le champ Template du profil édité (section Profils > Masquage). if hasattr(self, "_pro_template_var"): try: self._pro_template_var.set(str(path)) except Exception: pass self._mask_status_text.set(f"Template enregistré : {path.name}") # -- helpers UI ------------------------------------------------------- def _columns(self, parent, count: int, gap: int = 8, height: int | None = None) -> list[ctk.CTkFrame]: row = ctk.CTkFrame(parent, fg_color="transparent") row.pack(fill="x") if height is not None: row.configure(height=height) row.pack_propagate(False) row.grid_rowconfigure(0, weight=1) frames: list[ctk.CTkFrame] = [] for idx in range(count): row.grid_columnconfigure(idx, weight=1, uniform="config-cols") frame = ctk.CTkFrame(row, fg_color="transparent") frame.grid(row=0, column=idx, sticky="nsew", padx=(0 if idx == 0 else gap // 2, 0 if idx == count - 1 else gap // 2)) frames.append(frame) return frames def _note(self, parent, text: str) -> None: p = self._p ctk.CTkLabel( parent, text=text, text_color=p["text_muted"], fg_color=p["divider"], corner_radius=4, font=ui_kit.font(11), anchor="w", justify="left", wraplength=330, ).pack(fill="x", padx=12, pady=(0, 10), ipady=5) def _mini_toggle(self, parent, label: str, hint: str, value: bool = True, variable=None, command=None, disabled: bool = False, disabled_hint: str | None = None): p = self._p row = ctk.CTkFrame(parent, fg_color="transparent", height=MINI_TOGGLE_HEIGHT) row.pack_propagate(False) left = ctk.CTkFrame(row, fg_color="transparent") left.pack(side="left", fill="both", expand=True, pady=(3, 2)) lbl_color = p["text_muted"] if disabled else p["text"] ctk.CTkLabel( left, text=label, text_color=lbl_color, font=ui_kit.font(MINI_TOGGLE_LABEL_FONT_SIZE, "bold"), anchor="w", ).pack(anchor="w") shown_hint = disabled_hint if (disabled and disabled_hint) else hint if shown_hint: ctk.CTkLabel( left, text=shown_hint, text_color=p["text_muted"] if disabled else p["text_dim"], font=ui_kit.font(MINI_TOGGLE_HINT_FONT_SIZE), anchor="w", ).pack(anchor="w", pady=(1, 0)) # Moteur indisponible : on force l'état à False (jamais « coché mais absent »). if disabled and variable is None: value = False var = variable if variable is not None else ctk.BooleanVar(value=value) if disabled: var.set(False) switch = ctk.CTkSwitch(row, text="", variable=var, command=command, progress_color=p["primary"], width=38) if disabled: switch.configure(state="disabled") switch.pack(side="right", padx=(8, 0), pady=(8, 0)) row.var = var # type: ignore[attr-defined] row.switch = switch # type: ignore[attr-defined] row.get = lambda: bool(var.get()) # type: ignore[attr-defined] return row def _slider_row(self, parent, label: str, variable: ctk.IntVar, command) -> None: p = self._p row = ctk.CTkFrame(parent, fg_color="transparent") row.pack(fill="x", padx=12, pady=2) ctk.CTkLabel(row, text=label, text_color=p["text"], font=ui_kit.font(12)).pack(side="left") ctk.CTkLabel(row, textvariable=variable, text_color=p["primary"], font=ui_kit.font(11, "bold"), width=20).pack( side="right" ) slider = ctk.CTkSlider(row, from_=0, to=6, number_of_steps=6, progress_color=p["primary"], width=96, command=command) slider.set(variable.get()) slider.pack(side="right", padx=(8, 4)) def _rule_row(self, parent, values: tuple[str, str, str, str]) -> None: p = self._p label, rule_type, target, status = values row = ctk.CTkFrame(parent, fg_color="transparent") row.pack(fill="x", padx=12, pady=1) ctk.CTkLabel(row, text=label, width=190, anchor="w", text_color=p["text"], font=ui_kit.font(12)).pack(side="left") ctk.CTkLabel(row, text=rule_type, width=80, anchor="w", text_color=p["text_dim"], font=ui_kit.font(11)).pack(side="left") 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") self._mockup_button(row, "▶ Tester").pack(side="left")