1110 lines
50 KiB
Python
1110 lines
50 KiB
Python
"""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"),
|
|
]
|
|
|
|
_DETECTION_OPTIONS = [
|
|
("Noms et prénoms", "Annuaire + 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"),
|
|
]
|
|
|
|
_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)
|
|
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",
|
|
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_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")
|