Files
anonymisation/gui_v6/tabs/tab_config.py
Domi31tls 6a0a5811a5 fix(gui): retours Dom GUI V6 — thème, Administration, Règles, aide
Cinq retours utilisateur sur l'exécutable Windows GUI V6.

- Thème : `_render()` vidait les widgets mais conservait le cache
  `_tab_frames`/`_visible_tab` → l'onglet Utilisation se vidait (TclError
  sur widget détruit) au changement de thème. Reset du cache dans
  `_render()` → onglet actif recréé proprement.
- Onglet principal « Configuration » → « Administration » (clé interne
  inchangée).
- Sous-onglet « Règles  2 » → « Règles » (le « 2 » était un badge non
  câblé).
- Actions de maquette non câblées (Partage Export/Import, Règles Nouvelle
  règle/Recharger/Tester/Fermer) désactivées + suffixe « (à venir) » via
  `_mockup_button` : plus aucune action morte qui semble fonctionner.
- Aide « ? » restaurée (façon V5) : `ui_kit.HelpButton`/`help_button`
  réutilisable ouvrant une fenêtre d'aide en français simple, posée sur
  Utilisation, Administration (Réglages/Masquage/Partage/Règles) et
  À propos. Partage : phrase visible + aide expliquant qu'on partage les
  réglages, jamais les documents patients.

`tests/unit/test_gui_v6_app_shell.py` : régression thème, libellés,
présence d'aide, navigation. 228 tests unit OK (0 régression), self-test
GUI V6 OK. V5/moteur/app_aivanov non touchés, aucune dépendance ajoutée.
Verdict Qwen requis avant push/build/diffusion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:39:53 +02:00

797 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
import webbrowser
from pathlib import Path
from tkinter import filedialog, messagebox
import customtkinter as ctk
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"),
("msk", "🎭 Masquage"),
("shr", "🔄 Partage"),
("rul", "🛡️ Règles"),
]
_DETECTION_OPTIONS = [
("Noms et prénoms", "Gazetteers + IA"),
("Dates de naissance", "Contexte naissance"),
("Établissements", "FINESS + contexte"),
("Adresses / CP", "Voie, ville, code"),
("N° sécurité sociale", "NIR"),
("Téléphones / e-mails", "Contact"),
("N° adhérent mutuelle", "Identifiant local"),
]
_REPLACEMENT_CODES = [
("Nom/Prénom", "[NOM]"),
("Date naissance", "[DATE_NAISSANCE]"),
("Établissement", "[ETABLISSEMENT]"),
("Adresse", "[ADRESSE]"),
("Téléphone", "[TEL]"),
("N° sécu", "[NIR]"),
("IPP", "[IPP]"),
("Email", "[EMAIL]"),
]
_MASK_COLORS = [
("Noir", "#000000"),
("Bleu nuit", "#1a1a2e"),
("Gris", "#374151"),
("Marron", "#92400e"),
("Bleu marine", "#1e3a5f"),
]
_PRESERVE_TERMS = ["FUROSEMIDE", "rééducation fonctionnelle", "classification internationale"]
_MASK_TERMS = ["CHUXX"]
_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",
"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
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,
"msk": self._build_masquage,
"shr": self._build_partage,
"rul": self._build_regles,
}
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 métier", 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.secondary_button(top, p, "📁 Sortie…", command=self._pick_output).pack(
side="left", padx=(12, 6), pady=10
)
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")
det.pack(fill="both", expand=True)
for label, hint in _DETECTION_OPTIONS:
self._mini_toggle(det, label, hint, value=True).pack(fill="x", padx=12, pady=1)
ner = ui_kit.Card(cols[1], p, title="🧠 Moteurs et masques")
ner.pack(fill="both", expand=True)
self._tog_ner = self._mini_toggle(
ner, "CamemBERT-bio", "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", "médical français", value=self._state.enable_eds, command=self._on_eds
)
self._tog_eds.pack(fill="x", padx=12, pady=1)
self._tog_gli = self._mini_toggle(
ner, "GLiNER", "vote croisé", value=self._state.enable_gliner, command=self._on_gliner
)
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, padx=(0, 4)
)
ui_kit.secondary_button(mask_actions, p, "📁 Dossier", command=self._open_templates_dir).pack(
side="left", fill="x", expand=True, padx=(4, 0)
)
terms = ui_kit.Card(cols[2], p, title="✅ Listes locales")
terms.pack(fill="both", expand=True)
self._compact_tag_editor(terms, "Termes à conserver", "Ex : FUROSEMIDE", _PRESERVE_TERMS, "keep")
self._compact_tag_editor(terms, "Termes à masquer", "Ex : CHUXX", _MASK_TERMS, "mask")
self._compact_tag_editor(terms, "Mots à ignorer", "Ex : prescription", _STOPWORDS, "stop")
# -- Masquage ---------------------------------------------------------
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")
pdf_opts.pack(fill="both", expand=True)
ctk.CTkLabel(
pdf_opts, text="Couleur de masquage", text_color=p["text"], font=ui_kit.font(12, "bold")
).pack(anchor="w", padx=12, pady=(0, 4))
swatches = ctk.CTkFrame(pdf_opts, fg_color="transparent")
swatches.pack(fill="x", padx=12, pady=(0, 8))
self._swatch_buttons: dict[str, ctk.CTkButton] = {}
for label, color_value in _MASK_COLORS:
btn = ctk.CTkButton(
swatches,
text="",
width=30,
height=26,
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
self._slider_row(pdf_opts, "Marge H", self._mask_margin_x_var, self._on_mask_margin_x)
self._slider_row(pdf_opts, "Marge V", self._mask_margin_y_var, self._on_mask_margin_y)
self._mini_toggle(
pdf_opts,
"Coins arrondis",
"",
value=self._state.mask_rounded_corners,
variable=self._mask_rounded_var,
command=self._on_rounded_corners,
).pack(fill="x", padx=12, pady=(4, 10))
text_opts = ui_kit.Card(top_cols[1], p, title="🏷️ Texte")
text_opts.pack(fill="both", expand=True)
for label, value, preview in [
("Crochets", "brackets", "[NOM]"),
("Étoiles", "stars", "***"),
("Noirci", "blackout", "████"),
]:
ctk.CTkRadioButton(
text_opts,
text=f"{label} {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(12),
).pack(anchor="w", padx=12, pady=2)
self._mask_preview = ctk.CTkLabel(
text_opts,
text="Patient [NOM], né le [DATE_NAISSANCE]",
text_color=p["text_dim"],
fg_color=p["divider"],
corner_radius=6,
font=ui_kit.font(12),
anchor="w",
)
self._mask_preview.pack(fill="x", padx=12, pady=(8, 10), ipady=7)
codes = ui_kit.Card(top_cols[2], p, title="🔒 Codes")
codes.pack(fill="both", expand=True)
grid = ctk.CTkFrame(codes, fg_color="transparent")
grid.pack(fill="both", expand=True, padx=12, pady=(0, 10))
for idx, (label, code) in enumerate(_REPLACEMENT_CODES):
ctk.CTkLabel(grid, text=label, text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").grid(
row=idx, column=0, sticky="w", pady=1
)
ctk.CTkLabel(grid, text=code, text_color=p["primary"], font=ui_kit.font(11, "bold"), anchor="w").grid(
row=idx, column=1, sticky="w", padx=(8, 0), pady=1
)
grid.grid_columnconfigure(0, weight=1)
editor = ui_kit.Card(parent, p, title="🏠 Masques de zones fixes")
editor.pack(fill="x", pady=(8, 0))
ctk.CTkLabel(
editor,
text=(
"Définissez les zones à masquer (en-têtes, blocs identité…) directement sur "
"votre PDF, dans une fenêtre dédiée où le document est affiché en grand et "
"défilable (scroll, zoom, ajuster largeur/page). Les templates enregistrés "
"apparaissent ensuite dans « Template de masque manuel » (onglet Réglages)."
),
text_color=p["text_muted"],
font=ui_kit.font(12),
justify="left",
wraplength=760,
anchor="w",
).pack(fill="x", padx=14, pady=(0, 10))
actions = ctk.CTkFrame(editor, fg_color="transparent")
actions.pack(fill="x", padx=14, pady=(0, 6))
ui_kit.primary_button(
actions, p, "🖊 Ouvrir l'éditeur de masques", command=self._open_full_mask_editor
).pack(side="left")
ui_kit.secondary_button(
actions, p, "📁 Dossier des templates", command=self._open_templates_dir
).pack(side="left", padx=(8, 0))
ctk.CTkLabel(
editor,
textvariable=self._mask_status_text,
text_color=p["text_muted"],
font=ui_kit.font(11),
anchor="w",
).pack(fill="x", padx=14, pady=(2, 12))
# -- 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")
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")
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))
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.")
headers = ctk.CTkFrame(card, fg_color="transparent")
headers.pack(fill="x", padx=12, pady=(0, 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(card, row)
actions = ctk.CTkFrame(card, fg_color="transparent")
actions.pack(fill="x", padx=12, pady=(8, 12))
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")
ctk.CTkLabel(sim, text="Texte de test", text_color=p["text_muted"], font=ui_kit.font(12)).pack(anchor="w", padx=12)
txt = ctk.CTkTextbox(sim, height=74, fg_color=p["divider"], text_color=p["text"], border_color=p["card_border"], border_width=1)
txt.pack(fill="x", padx=12, pady=(5, 8))
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))
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 ----------------------------------------------
def _on_profile(self, value: str) -> None:
self._state.profile = value
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())
try:
webbrowser.open(path.as_uri())
except Exception:
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."""
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
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):
p = self._p
row = ctk.CTkFrame(parent, fg_color="transparent", height=34)
row.pack_propagate(False)
left = ctk.CTkFrame(row, fg_color="transparent")
left.pack(side="left", fill="x", expand=True)
ctk.CTkLabel(left, text=label, text_color=p["text"], font=ui_kit.font(12), anchor="w").pack(anchor="w")
if hint:
ctk.CTkLabel(left, text=hint, text_color=p["text_muted"], font=ui_kit.font(10), anchor="w").pack(anchor="w")
var = variable if variable is not None else ctk.BooleanVar(value=value)
switch = ctk.CTkSwitch(row, text="", variable=var, command=command, progress_color=p["primary"], width=38)
switch.pack(side="right", padx=(6, 0))
row.var = var # type: ignore[attr-defined]
row.get = lambda: bool(var.get()) # type: ignore[attr-defined]
return row
def _compact_tag_editor(self, parent, title: str, placeholder: str, terms: list[str], kind: str) -> None:
p = self._p
color = {"keep": p["success"], "mask": p["primary"], "stop": p["warning"]}.get(kind, p["primary"])
ctk.CTkLabel(parent, text=title, text_color=p["text"], font=ui_kit.font(12, "bold"), anchor="w").pack(
fill="x", padx=12, pady=(0, 2)
)
row = ctk.CTkFrame(parent, fg_color="transparent")
row.pack(fill="x", padx=12, pady=(0, 5))
ctk.CTkEntry(
row,
placeholder_text=placeholder,
fg_color=p["btn_sec_bg"],
border_color=p["btn_sec_border"],
text_color=p["text"],
height=28,
).pack(side="left", fill="x", expand=True, padx=(0, 6))
ui_kit.secondary_button(row, p, "+").pack(side="right")
cloud = ctk.CTkFrame(parent, fg_color="transparent")
cloud.pack(fill="x", padx=12, pady=(0, 8))
for term in terms[:2]:
display = f"{term[:18]}{'' if len(term) > 18 else ''} ×"
ctk.CTkLabel(
cloud,
text=display,
width=150,
anchor="w",
text_color=color,
fg_color=p["btn_sec_bg"],
corner_radius=99,
font=ui_kit.font(10),
).pack(anchor="w", fill="x", pady=2, ipadx=5, ipady=2)
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")