From 696f6bf27cd5f38960002e3c4d923dfc9daf3300 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Mon, 15 Jun 2026 09:53:56 +0200 Subject: [PATCH] fix(gui): make admin config responsive and mask editor usable --- gui_v6/app.py | 45 +- gui_v6/config_state.py | 7 + gui_v6/tabs/tab_config.py | 1109 +++++++++++++---- .../test_gui_v6_config_mockup_sections.py | 20 +- 4 files changed, 949 insertions(+), 232 deletions(-) diff --git a/gui_v6/app.py b/gui_v6/app.py index 443a5b2..87298fa 100644 --- a/gui_v6/app.py +++ b/gui_v6/app.py @@ -2,8 +2,9 @@ Reproduit l'identité de ``docs/ui_mockup_v6.html`` : shell étroit, header avec identité produit + version + statut licence + liseré accent, barre d'onglets -custom (pas CTkTabview brut), navigation par recréation du contenu, changement -de thème à chaud. La logique (runner moteur, config, licence) est inchangée. +custom (pas CTkTabview brut), navigation par panneaux mis en cache après leur +première ouverture visible, changement de thème à chaud. La logique (runner +moteur, config, licence) est inchangée. La fenêtre n'est créée qu'à l'instanciation de :class:`AnonymisationApp`. """ @@ -41,6 +42,8 @@ class AnonymisationApp(ctk.CTk): self._config = ConfigState() self._active = "use" self._tab_buttons: dict = {} + self._tab_frames: dict = {} + self._visible_tab = None self.title("Pseudonymisation de vos documents") self.geometry("820x880") @@ -130,29 +133,33 @@ class AnonymisationApp(ctk.CTk): # -- contenu ---------------------------------------------------------- - def _show(self, key: str) -> None: - self._active = key - self._refresh_tabbar() - for child in self._content.winfo_children(): - child.destroy() + def _create_tab(self, key: str): p = self._palette status = self._safe_local_status() if key == "use": - tab = UsageTab( + return UsageTab( self._content, palette=p, config_provider=lambda: self._config, on_theme_change=self.set_theme, current_theme=self._theme_name, ) - elif key == "cfg": - tab = ConfigTab(self._content, palette=p, state=self._config) - else: - tab = AboutTab( - self._content, - palette=p, - status=status, - theme_name=self._theme_name, - license_client=self._license_client, - ) - tab.pack(fill="both", expand=True) + if key == "cfg": + return ConfigTab(self._content, palette=p, state=self._config) + return AboutTab( + self._content, + palette=p, + status=status, + theme_name=self._theme_name, + license_client=self._license_client, + ) + + def _show(self, key: str) -> None: + self._active = key + self._refresh_tabbar() + if self._visible_tab is not None: + self._tab_frames[self._visible_tab].pack_forget() + if key not in self._tab_frames: + self._tab_frames[key] = self._create_tab(key) + self._tab_frames[key].pack(fill="both", expand=True) + self._visible_tab = key diff --git a/gui_v6/config_state.py b/gui_v6/config_state.py index 19065ae..d5a05e7 100644 --- a/gui_v6/config_state.py +++ b/gui_v6/config_state.py @@ -25,6 +25,13 @@ class ConfigState: enable_gliner: bool = False output_dir: Optional[Path] = None ogc_label: Optional[str] = None + manual_mask_required: bool = False + manual_mask_template: Optional[Path] = None + mask_color: str = "#000000" + mask_marker_style: str = "brackets" + mask_margin_x: int = 2 + mask_margin_y: int = 1 + mask_rounded_corners: bool = False def to_engine_settings(self, config_path: Optional[Path] = None) -> EngineSettings: return EngineSettings( diff --git a/gui_v6/tabs/tab_config.py b/gui_v6/tabs/tab_config.py index e53c093..2923e87 100644 --- a/gui_v6/tabs/tab_config.py +++ b/gui_v6/tabs/tab_config.py @@ -1,21 +1,27 @@ -"""Onglet « Configuration » de la GUI V6 (G4 — alignement maquette). +"""Onglet Configuration de la GUI V6. -Sous-navigation Réglages / Masquage / Partage / Règles (cf. maquette). Le -sous-onglet Réglages édite un :class:`ConfigState` partagé (profil, NER local, -moteurs). Les autres sous-onglets reprennent le style maquette (cartes denses). -Aucune logique de détection ; ``config_defaults.py`` n'est pas modifié. +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 json +import sys +import webbrowser from pathlib import Path -from tkinter import filedialog +from tkinter import filedialog, messagebox +import tkinter as tk +from typing import Any 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"), @@ -25,17 +31,39 @@ _SUBTABS = [ ] _DETECTION_OPTIONS = [ - ("Noms et prénoms", "Gazetteers INSEE · CamemBERT"), - ("Dates de naissance", "Uniquement la date de naissance"), - ("Établissements", "Répertoire FINESS + contexte"), - ("Adresses et codes postaux", ""), - ("N° sécurité sociale", ""), - ("Téléphones et e-mails", ""), - ("N° adhérent mutuelle", ""), + ("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" CONFIG_MOCKUP_SECTIONS = { "reglages": [ @@ -44,6 +72,8 @@ CONFIG_MOCKUP_SECTIONS = { "Données à détecter", "Termes à toujours conserver", "Termes à toujours masquer", + "Masque manuel obligatoire", + "Template de masque manuel", ], "masquage": [ "Couleur de masquage (PDF)", @@ -51,11 +81,37 @@ CONFIG_MOCKUP_SECTIONS = { "É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() + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + class ConfigTab(ctk.CTkFrame): def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, **kwargs): @@ -63,224 +119,423 @@ class ConfigTab(ctk.CTkFrame): 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 = {} + 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_template_name = ctk.StringVar(value="template_masques") + self._mask_dpi = ctk.IntVar(value=200) + self._mask_zoom = 1.0 + self._mask_zoom_text = ctk.StringVar(value="100%") + self._mask_status_text = ctk.StringVar(value="Aucun PDF chargé") + self._mask_count_text = ctk.StringVar(value="0 masque") + + self._mask_doc = None + self._mask_pdf_path: Path | None = None + self._mask_page_index = 0 + self._mask_page_size = (595.0, 842.0) + self._mask_rects: list[dict[str, Any]] = [] + self._mask_photo = None + self._mask_canvas: tk.Canvas | None = None + self._mask_page_origin = (0.0, 0.0) + self._mask_current_scale = 1.0 + self._mask_drag_start: tuple[float, float] | None = None + self._mask_preview_item: int | None = 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=(14, 4)) + 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="transparent", hover_color=p["card_border"], + 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=0, width=10, + corner_radius=6, + width=10, + height=30, ) - btn.pack(side="left", padx=3) + 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) + 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(): - btn.configure(text_color=p["primary"] if k == key else p["text_dim"], - font=ui_kit.font(13, "bold" if k == key else "normal")) - for w in self._body.winfo_children(): - w.destroy() - {"reg": self._build_reglages, "msk": self._build_masquage, - "shr": self._build_partage, "rul": self._build_regles}[key]() + 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() + if key == "msk": + self.after_idle(self._refresh_mask_canvas) - # -- Réglages (câblé ConfigState) ------------------------------------- + # -- Réglages --------------------------------------------------------- - def _build_reglages(self) -> None: + def _build_reglages(self, parent) -> None: p = self._p + 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)) - cols = self._two_columns(self._body) - left, right = cols - - prof = ui_kit.Card(left, p, title="🗂️ Profil métier") - prof.pack(fill="x", pady=7) - row = ctk.CTkFrame(prof, fg_color="transparent") - row.pack(fill="x", padx=16, pady=(0, 10)) 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(row, text="Profil :", text_color=p["text"], font=ui_kit.font(13)).pack(side="left", padx=(0, 8)) - menu = ctk.CTkOptionMenu(row, values=profiles or ["(aucun profil)"], command=self._on_profile, - fg_color=p["btn_sec_bg"], button_color=p["primary"], text_color=p["text"]) + + 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: - menu.set(current) - menu.pack(side="left") - outrow = ctk.CTkFrame(prof, fg_color="transparent") - outrow.pack(fill="x", padx=16, pady=(0, 14)) - ui_kit.secondary_button(outrow, p, "📁 Dossier de sortie…", command=self._pick_output).pack(side="left", padx=(0, 8)) - self._out_label = ctk.CTkLabel(outrow, text=str(self._state.output_dir or "(défaut anonymise/)"), - text_color=p["text_muted"], font=ui_kit.font(12)) - self._out_label.pack(side="left") + self._profile_menu.set(current) + self._profile_menu.pack(side="left", pady=10) - ner = ui_kit.Card(left, p, title="🧠 Moteurs NER") - ner.pack(fill="x", pady=7) - self._tog_ner = ui_kit.ToggleRow(ner, p, "CamemBERT-bio ⚡", "~10 ms/doc · F1 = 0.963", value=self._state.use_local_ner, command=self._on_ner) - self._tog_ner.pack(fill="x", padx=16, pady=2) - self._tog_eds = ui_kit.ToggleRow(ner, p, "EDS-Pseudo PRÉCIS", "~200 ms/doc · médical français", value=self._state.enable_eds, command=self._on_eds) - self._tog_eds.pack(fill="x", padx=16, pady=2) - self._tog_gli = ui_kit.ToggleRow(ner, p, "GLiNER OPTIONNEL", "~95 ms/doc · vote croisé", value=self._state.enable_gliner, command=self._on_gliner) - self._tog_gli.pack(fill="x", padx=16, pady=(2, 14)) + 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) - det = ui_kit.Card(right, p, title="🔍 Données à détecter") - det.pack(fill="x", pady=7) + 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: - ui_kit.ToggleRow(det, p, label, hint, value=True).pack(fill="x", padx=16, pady=2) - ctk.CTkFrame(det, fg_color="transparent", height=8).pack() + self._mini_toggle(det, label, hint, value=True).pack(fill="x", padx=12, pady=1) - keep = ui_kit.Card(right, p, title="✅ Termes à toujours conserver") - keep.pack(fill="x", pady=7) - self._note(keep, "Ces termes ne seront jamais masqués, même s'ils ressemblent à un nom propre.") - self._tag_editor(keep, "Ex : FUROSEMIDE…", _PRESERVE_TERMS, "keep") + 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)) - mask = ui_kit.Card(right, p, title="🚫 Termes à toujours masquer") - mask.pack(fill="x", pady=7) - self._note(mask, "Ces termes seront toujours masqués, même sans contexte médical autour.") - self._tag_editor(mask, "Ex : CHUXX, Dr Dupont…", _MASK_TERMS, "mask") + 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) + ) - def _build_masquage(self) -> None: + 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 - cols = self._two_columns(self._body) - left, right = cols + top_cols = self._columns(parent, 3, gap=8, height=300) - color = ui_kit.Card(left, p, title="⬛ Couleur de masquage (PDF)") - color.pack(fill="x", pady=7) - self._note(color, "Couleur des rectangles dans le PDF final.") - swatches = ctk.CTkFrame(color, fg_color="transparent") - swatches.pack(fill="x", padx=16, pady=(0, 14)) - for color_value in ["#000000", "#1a1a2e", "#374151", "#92400e", "#1e3a5f"]: - ctk.CTkButton( + 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=30, + height=26, fg_color=color_value, hover_color=color_value, - border_color=p["primary"] if color_value == "#000000" else p["card_border"], - border_width=3 if color_value == "#000000" else 1, + 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: None, - ).pack(side="left", padx=(0, 8)) + command=lambda c=color_value: self._set_mask_color(c), + ) + btn.pack(side="left", padx=(0, 6)) + self._swatch_buttons[color_value] = btn - style = ui_kit.Card(left, p, title="🏷️ Style des marqueurs (texte)") - style.pack(fill="x", pady=7) - self._mask_style_var = ctk.StringVar(value="brackets") + 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", "████"), ]: - row = ctk.CTkFrame(style, fg_color="transparent") - row.pack(fill="x", padx=16, pady=2) ctk.CTkRadioButton( - row, - text=f"{label} — {preview}", + 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(13), - ).pack(anchor="w") + font=ui_kit.font(12), + ).pack(anchor="w", padx=12, pady=2) self._mask_preview = ctk.CTkLabel( - style, + 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(13), + font=ui_kit.font(12), anchor="w", ) - self._mask_preview.pack(fill="x", padx=16, pady=(8, 14), ipady=8) + self._mask_preview.pack(fill="x", padx=12, pady=(8, 10), ipady=7) - thick = ui_kit.Card(right, p, title="📐 Épaisseur du masque") - thick.pack(fill="x", pady=7) - self._note(thick, "Marge autour du texte masqué, en points.") - self._slider_row(thick, "Marge horizontale", 2) - self._slider_row(thick, "Marge verticale", 1) - ui_kit.ToggleRow(thick, p, "Coins arrondis", "", value=False).pack(fill="x", padx=16, pady=(2, 14)) + 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) - codes = ui_kit.Card(right, p, title="🔒 Codes de remplacement") - codes.pack(fill="x", pady=7) - for k, v in [ - ("Nom/Prénom", "[NOM]"), - ("Date naissance", "[DATE_NAISSANCE]"), - ("Établissement", "[ETABLISSEMENT]"), - ("Adresse", "[ADRESSE]"), - ("Téléphone", "[TEL]"), - ("N° sécu", "[NIR]"), - ("IPP", "[IPP]"), - ("Email", "[EMAIL]"), - ]: - r = ctk.CTkFrame(codes, fg_color="transparent") - r.pack(fill="x", padx=16, pady=1) - ctk.CTkLabel(r, text=k, text_color=p["text_muted"], font=ui_kit.font(12)).pack(side="left") - ctk.CTkLabel(r, text=v, text_color=p["primary"], font=ui_kit.font(12, "bold")).pack(side="right") - ctk.CTkFrame(codes, fg_color="transparent", height=8).pack() + editor = ui_kit.Card(parent, p, title="🏠 Masques de zones fixes") + editor.pack(fill="x", pady=(8, 0)) - editor = ui_kit.Card(self._body, p, title="🏠 Masques de zones fixes (logos, en-têtes)") - editor.pack(fill="x", padx=14, pady=7) - top = ctk.CTkFrame(editor, fg_color="transparent") - top.pack(fill="x", padx=16, pady=(0, 8)) - ctk.CTkLabel( - top, - text="Dessinez des rectangles sur un PDF modèle pour masquer systématiquement les logos, en-têtes ou zones fixes.", - text_color=p["text_dim"], - font=ui_kit.font(12), - justify="left", - wraplength=610, - ).pack(side="left", fill="x", expand=True) - ui_kit.primary_button(top, p, "✏ Ouvrir l'éditeur de masques", command=lambda: None).pack(side="right", padx=(8, 0)) - toolbar = ctk.CTkFrame(editor, fg_color=p["divider"], border_color=p["card_border"], border_width=1, corner_radius=8) - toolbar.pack(fill="x", padx=16, pady=(0, 10)) - for label in ["📄 Ouvrir PDF…", "−", "100%", "+", "💾 Sauver", "📁 Charger", "🗑 Effacer page"]: - ui_kit.secondary_button(toolbar, p, label, command=lambda: None).pack(side="left", padx=4, pady=8) - canvas = ctk.CTkFrame(editor, fg_color=p["divider"], border_color=p["card_border"], border_width=1, corner_radius=8, height=150) - canvas.pack(fill="x", padx=16, pady=(0, 12)) - canvas.pack_propagate(False) - ctk.CTkLabel( - canvas, - text="📄\nOuvrez un PDF pour commencer à dessiner des zones de masquage.\nCliquez-glissez pour tracer un rectangle.", - text_color=p["text_muted"], - font=ui_kit.font(12), + toolbar = ctk.CTkFrame(editor, fg_color="transparent") + toolbar.pack(fill="x", padx=12, pady=(0, 8)) + ui_kit.primary_button(toolbar, p, "📄 Ouvrir PDF…", command=self._open_mask_pdf).pack( + side="left", padx=(0, 6) + ) + ui_kit.secondary_button(toolbar, p, "↙ Fenêtre complète", command=self._open_full_mask_editor).pack( + side="left", padx=(0, 6) + ) + self._toolbar_button(toolbar, "←", lambda: self._move_mask_page(-1), width=38).pack(side="left", padx=(0, 4)) + self._toolbar_button(toolbar, "→", lambda: self._move_mask_page(1), width=38).pack(side="left", padx=(0, 8)) + self._toolbar_button(toolbar, "−", lambda: self._zoom_mask(-0.15), width=38).pack(side="left", padx=(0, 4)) + ctk.CTkLabel(toolbar, textvariable=self._mask_zoom_text, text_color=p["text_muted"], font=ui_kit.font(12), width=46).pack( + side="left" + ) + self._toolbar_button(toolbar, "+", lambda: self._zoom_mask(0.15), width=38).pack(side="left", padx=(4, 8)) + + work = ctk.CTkFrame(editor, fg_color="transparent") + work.pack(fill="x", padx=12, pady=(0, 12)) + canvas_wrap = ctk.CTkFrame( + work, + fg_color=p["divider"], + border_color=p["card_border"], + border_width=1, + corner_radius=8, + height=292, + ) + canvas_wrap.pack(side="left", fill="both", expand=True, padx=(0, 8)) + canvas_wrap.pack_propagate(False) + self._mask_canvas = tk.Canvas( + canvas_wrap, + bg=p["divider"], + highlightthickness=0, + bd=0, + height=288, + cursor="crosshair", + ) + self._mask_canvas.pack(fill="both", expand=True, padx=6, pady=6) + self._mask_canvas.bind("", self._on_mask_canvas_down) + self._mask_canvas.bind("", self._on_mask_canvas_drag) + self._mask_canvas.bind("", self._on_mask_canvas_up) + self._mask_canvas.bind("", lambda _event: self._refresh_mask_canvas()) + + side = ctk.CTkFrame( + work, + fg_color=p["divider"], + border_color=p["card_border"], + border_width=1, + corner_radius=8, + width=230, + ) + side.pack(side="left", fill="y") + + ctk.CTkLabel(side, text="Template", text_color=p["text_dim"], font=ui_kit.font(11, "bold"), anchor="w").pack( + fill="x", padx=12, pady=(12, 2) + ) + ctk.CTkEntry( + side, + textvariable=self._mask_template_name, + fg_color=p["btn_sec_bg"], + border_color=p["btn_sec_border"], + text_color=p["text"], + height=30, + ).pack(fill="x", padx=12, pady=(0, 8)) + + dpi_row = ctk.CTkFrame(side, fg_color="transparent") + dpi_row.pack(fill="x", padx=12, pady=(0, 8)) + ctk.CTkLabel(dpi_row, text="DPI raster", text_color=p["text_muted"], font=ui_kit.font(12)).pack(side="left") + ctk.CTkEntry( + dpi_row, + textvariable=self._mask_dpi, + fg_color=p["btn_sec_bg"], + border_color=p["btn_sec_border"], + text_color=p["text"], + width=62, + height=28, justify="center", - ).pack(expand=True) + ).pack(side="right") - def _build_partage(self) -> None: + ui_kit.secondary_button(side, p, "💾 Sauver JSON", command=self._save_mask_template).pack( + fill="x", padx=12, pady=(0, 6) + ) + ui_kit.secondary_button(side, p, "📁 Charger", command=self._load_mask_template).pack( + fill="x", padx=12, pady=(0, 6) + ) + ui_kit.secondary_button(side, p, "👁 Prévisualiser", command=self._preview_mask_template).pack( + fill="x", padx=12, pady=(0, 6) + ) + ui_kit.primary_button(side, p, "▶ Appliquer", command=self._apply_mask_template_selection).pack( + fill="x", padx=12, pady=(0, 10) + ) + + clear_row = ctk.CTkFrame(side, fg_color="transparent") + clear_row.pack(fill="x", padx=12, pady=(0, 8)) + ui_kit.secondary_button(clear_row, p, "Page", command=self._clear_mask_page).pack(side="left", fill="x", expand=True, padx=(0, 4)) + ui_kit.secondary_button(clear_row, p, "Tout", command=self._clear_all_masks).pack(side="left", fill="x", expand=True, padx=(4, 0)) + + ctk.CTkLabel(side, textvariable=self._mask_status_text, text_color=p["text_muted"], font=ui_kit.font(11), wraplength=190, justify="left").pack( + fill="x", padx=12, pady=(4, 4) + ) + ctk.CTkLabel(side, textvariable=self._mask_count_text, text_color=p["primary"], font=ui_kit.font(13, "bold")).pack( + fill="x", padx=12, pady=(0, 12) + ) + + # -- Partage / Règles ------------------------------------------------- + + def _build_partage(self, parent) -> None: p = self._p - export = ui_kit.Card(self._body, p, title="📤 Exporter la configuration") - export.pack(fill="x", padx=14, pady=7) - self._note(export, "Génère un fichier .json avec vos listes, à envoyer par e-mail à d'autres établissements.") - ui_kit.secondary_button(export, p, "⬇ Exporter (.json)", command=lambda: None).pack(anchor="w", padx=16, pady=(0, 14)) + cols = self._columns(parent, 2, gap=8, height=180) + export = ui_kit.Card(cols[0], p, title="📤 Exporter la configuration") + export.pack(fill="both", expand=True) + self._note(export, "Listes locales, règles admin, style de masquage et template actif.") + ui_kit.secondary_button(export, p, "⬇ Exporter (.json)", command=lambda: None).pack(anchor="w", padx=12, pady=(0, 12)) - import_card = ui_kit.Card(self._body, p, title="📥 Importer une configuration") - import_card.pack(fill="x", padx=14, pady=7) - self._note(import_card, "Importez un fichier reçu. Vos réglages locaux ne seront pas supprimés.") - ui_kit.secondary_button(import_card, p, "⬆ Importer (.json)", command=lambda: None).pack(anchor="w", padx=16, pady=(0, 14)) + import_card = ui_kit.Card(cols[1], p, title="📥 Importer une configuration") + import_card.pack(fill="both", expand=True) + self._note(import_card, "Fusionne la configuration reçue avec vos réglages locaux.") + ui_kit.secondary_button(import_card, p, "⬆ Importer (.json)", command=lambda: None).pack(anchor="w", padx=12, pady=(0, 12)) - def _build_regles(self) -> None: + def _build_regles(self, parent) -> None: p = self._p - card = ui_kit.Card(self._body, p, title="🛡️ Règles actives") - card.pack(fill="x", padx=14, pady=7) + 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=16, pady=(0, 4)) + 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 [ @@ -290,22 +545,22 @@ class ConfigTab(ctk.CTkFrame): ]: self._rule_row(card, row) actions = ctk.CTkFrame(card, fg_color="transparent") - actions.pack(fill="x", padx=16, pady=(10, 14)) + actions.pack(fill="x", padx=12, pady=(8, 12)) ui_kit.primary_button(actions, p, "+ Nouvelle règle", command=lambda: None).pack(side="left", padx=(0, 8)) ui_kit.secondary_button(actions, p, "🔄 Recharger", command=lambda: None).pack(side="left") - sim = ui_kit.Card(self._body, p, title="🧪 Testeur de règle") - sim.pack(fill="x", padx=14, pady=7) - ctk.CTkLabel(sim, text="Texte de test", text_color=p["text_muted"], font=ui_kit.font(12)).pack(anchor="w", padx=16) - txt = ctk.CTkTextbox(sim, height=78, fg_color=p["divider"], text_color=p["text"], border_color=p["card_border"], border_width=1) - txt.pack(fill="x", padx=16, pady=(5, 10)) + 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=16, pady=(0, 14)) + btns.pack(fill="x", padx=12, pady=(0, 12)) ui_kit.primary_button(btns, p, "▶ Tester", command=lambda: None).pack(side="left", padx=(0, 8)) ui_kit.secondary_button(btns, p, "✖ Fermer", command=lambda: None).pack(side="left") - # -- callbacks -------------------------------------------------------- + # -- callbacks réglages ---------------------------------------------- def _on_profile(self, value: str) -> None: self._state.profile = value @@ -319,22 +574,431 @@ class ConfigTab(ctk.CTkFrame): 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, + ) + self._refresh_mask_canvas() + + 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_mask_pdf(self) -> None: + path = filedialog.askopenfilename(title="PDF modèle", filetypes=[("PDF", "*.pdf")]) + if not path: + return + try: + import fitz + + doc = fitz.open(path) + if len(doc) == 0: + raise ValueError("PDF vide") + if self._mask_doc is not None: + try: + self._mask_doc.close() + except Exception: + pass + self._mask_doc = doc + self._mask_pdf_path = Path(path) + self._mask_page_index = 0 + self._mask_page_size = (float(doc[0].rect.width), float(doc[0].rect.height)) + self._mask_rects.clear() + self._mask_template_name.set(f"{self._mask_pdf_path.stem}_template") + self._mask_status_text.set(f"{self._mask_pdf_path.name} — page 1/{len(doc)}") + self._update_mask_count() + self._refresh_mask_canvas() + except Exception as exc: + messagebox.showerror("Masques PDF", f"Impossible d'ouvrir le PDF : {exc}") + + def _open_full_mask_editor(self) -> None: + try: + from pdf_mask_designer import MaskDesignerApp + + win = tk.Toplevel(self) + MaskDesignerApp(win, templates_dir=ensure_mask_templates_dir(_app_base_dir())) + except Exception as exc: + messagebox.showerror("Masques PDF", f"Impossible d'ouvrir l'éditeur complet : {exc}") + + def _move_mask_page(self, delta: int) -> None: + if self._mask_doc is None: + return + self._mask_page_index = int(_clamp(self._mask_page_index + delta, 0, len(self._mask_doc) - 1)) + page = self._mask_doc[self._mask_page_index] + self._mask_page_size = (float(page.rect.width), float(page.rect.height)) + self._mask_status_text.set(f"{self._mask_pdf_path.name if self._mask_pdf_path else 'PDF'} — page {self._mask_page_index + 1}/{len(self._mask_doc)}") + self._update_mask_count() + self._refresh_mask_canvas() + + def _zoom_mask(self, delta: float) -> None: + self._mask_zoom = _clamp(self._mask_zoom + delta, 0.55, 2.5) + self._mask_zoom_text.set(f"{round(self._mask_zoom * 100)}%") + self._refresh_mask_canvas() + + def _clear_mask_page(self) -> None: + self._mask_rects = [m for m in self._mask_rects if int(m.get("page", 0)) != self._mask_page_index] + self._mask_status_text.set(f"Masques page {self._mask_page_index + 1} supprimés.") + self._update_mask_count() + self._refresh_mask_canvas() + + def _clear_all_masks(self) -> None: + self._mask_rects.clear() + self._mask_status_text.set("Tous les masques ont été supprimés.") + self._update_mask_count() + self._refresh_mask_canvas() + + def _preview_mask_template(self) -> None: + count = self._current_page_mask_count() + self._mask_status_text.set(f"Prévisualisation : {count} masque(s) sur la page courante.") + self._refresh_mask_canvas() + + def _apply_mask_template_selection(self) -> None: + path = self._save_mask_template(silent=True) + if path is None: + return + self._state.manual_mask_template = path + self._manual_mask_required_var.set(True) + self._on_manual_mask_required() + self._refresh_manual_mask_templates() + self._manual_mask_var.set(mask_template_label(path, _app_base_dir())) + self._mask_status_text.set(f"Template actif : {path.name}") + + def _save_mask_template(self, silent: bool = False) -> Path | None: + tpl = self._current_mask_template_payload() + if not tpl["masks"]: + self._mask_status_text.set("Dessinez au moins un masque avant de sauvegarder.") + if not silent: + messagebox.showwarning("Masques PDF", "Aucun masque défini.") + return None + initial_dir = ensure_mask_templates_dir(_app_base_dir()) + default_name = f"{tpl['name'] or 'template_masques'}.json" + if silent: + path = initial_dir / default_name + else: + selected = filedialog.asksaveasfilename( + title="Sauver le template", + defaultextension=".json", + filetypes=[("JSON", "*.json")], + initialdir=str(initial_dir), + initialfile=default_name, + ) + if not selected: + return None + path = Path(selected) + try: + path.write_text(json.dumps(tpl, ensure_ascii=False, indent=2), encoding="utf-8") + self._mask_status_text.set(f"Template sauvegardé : {path.name}") + self._refresh_manual_mask_templates() + return path + except Exception as exc: + if not silent: + messagebox.showerror("Masques PDF", f"Impossible d'écrire le template : {exc}") + self._mask_status_text.set("Échec sauvegarde template.") + return None + + def _load_mask_template(self) -> None: + initial_dir = ensure_mask_templates_dir(_app_base_dir()) + selected = filedialog.askopenfilename( + title="Charger un template", + filetypes=[("Templates", "*.json *.yml *.yaml")], + initialdir=str(initial_dir), + ) + if not selected: + return + try: + payload = self._read_mask_template(Path(selected)) + self._mask_template_name.set(str(payload.get("name") or Path(selected).stem)) + ps = payload.get("page_size") or {} + self._mask_page_size = (float(ps.get("width", 595)), float(ps.get("height", 842))) + self._mask_rects = [ + { + "page": int(m.get("page", 0)), + "x0": float(m.get("x0", 0)), + "y0": float(m.get("y0", 0)), + "x1": float(m.get("x1", 0)), + "y1": float(m.get("y1", 0)), + "label": str(m.get("label", "MASK")), + } + for m in payload.get("masks", []) + ] + self._state.manual_mask_template = Path(selected) + self._refresh_manual_mask_templates() + self._manual_mask_var.set(mask_template_label(Path(selected), _app_base_dir())) + self._mask_status_text.set(f"Template chargé : {Path(selected).name}") + self._update_mask_count() + self._refresh_mask_canvas() + except Exception as exc: + messagebox.showerror("Masques PDF", f"Template invalide : {exc}") + + def _read_mask_template(self, path: Path) -> dict[str, Any]: + if path.suffix.lower() in {".yml", ".yaml"}: + import yaml + + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return json.loads(path.read_text(encoding="utf-8")) + + def _current_mask_template_payload(self) -> dict[str, Any]: + width, height = self._mask_page_size + return { + "version": 1, + "name": self._mask_template_name.get().strip() or "template_masques", + "page_size": {"width": width, "height": height}, + "masks": [ + { + "page": int(m["page"]), + "x0": round(float(m["x0"]), 2), + "y0": round(float(m["y0"]), 2), + "x1": round(float(m["x1"]), 2), + "y1": round(float(m["y1"]), 2), + "label": str(m.get("label", "MASK")), + } + for m in self._mask_rects + ], + } + + def _refresh_mask_canvas(self) -> None: + canvas = self._mask_canvas + if canvas is None: + return + canvas.delete("all") + p = self._p + width = max(360, canvas.winfo_width() or 420) + height = max(260, canvas.winfo_height() or 318) + page_w, page_h = self._mask_page_size + fit_scale = min((width - 26) / page_w, (height - 26) / page_h) + scale = max(0.1, fit_scale * self._mask_zoom) + page_px_w = page_w * scale + page_px_h = page_h * scale + origin_x = max(12, (width - page_px_w) / 2) + origin_y = 12 + self._mask_page_origin = (origin_x, origin_y) + self._mask_current_scale = scale + + if self._mask_doc is not None: + try: + from PIL import Image, ImageTk + import fitz + + page = self._mask_doc[self._mask_page_index] + pix = page.get_pixmap(matrix=fitz.Matrix(scale, scale), annots=False) + img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) + self._mask_photo = ImageTk.PhotoImage(img) + canvas.create_image(origin_x, origin_y, image=self._mask_photo, anchor="nw") + except Exception: + self._draw_sample_page(canvas, origin_x, origin_y, page_px_w, page_px_h) + else: + self._draw_sample_page(canvas, origin_x, origin_y, page_px_w, page_px_h) + + self._draw_mask_rectangles(canvas) + if self._mask_doc is None: + canvas.create_text( + width / 2, + height - 18, + text="Ouvrez un PDF modèle, ou dessinez directement sur l'aperçu d'exemple.", + fill=p["text_muted"], + font=("TkDefaultFont", 9), + ) + + def _draw_sample_page(self, canvas: tk.Canvas, x: float, y: float, w: float, h: float) -> None: + p = self._p + canvas.create_rectangle(x, y, x + w, y + h, fill="#f8fafc", outline=p["card_border"], width=1) + header_h = min(54, h * 0.16) + canvas.create_rectangle(x, y, x + w, y + header_h, fill="#e5e7eb", outline="") + scale = self._mask_current_scale + canvas.create_text(x + 18 * scale, y + 35 * scale, text="EN-TÊTE ÉTABLISSEMENT [LOGO]", fill="#6b7280", anchor="w", font=("TkDefaultFont", 10, "bold")) + canvas.create_text(x + 18 * scale, y + 72 * scale, text="Service de cardiologie | Tel : 05.59.XX.XX.XX", fill="#6b7280", anchor="w", font=("TkDefaultFont", 8)) + for idx, line in enumerate( + [ + "Patient : Dupont Jean Né le : 12/03/1955", + "IPP : 1234567 NDA : 8901234", + "Motif : Insuffisance cardiaque décompensée.", + "Traitement : FUROSEMIDE 40mg, BISOPROLOL 5mg.", + "Signé : Dr Martin RPPS 12345678", + ] + ): + canvas.create_text(x + 28 * scale, y + (128 + idx * 32) * scale, text=line, fill="#111827", anchor="w", font=("TkDefaultFont", 8)) + + def _draw_mask_rectangles(self, canvas: tk.Canvas) -> None: + x0, y0 = self._mask_page_origin + scale = self._mask_current_scale + for idx, mask in enumerate(self._mask_rects): + if int(mask.get("page", 0)) != self._mask_page_index: + continue + rx0 = x0 + float(mask["x0"]) * scale + ry0 = y0 + float(mask["y0"]) * scale + rx1 = x0 + float(mask["x1"]) * scale + ry1 = y0 + float(mask["y1"]) * scale + canvas.create_rectangle(rx0, ry0, rx1, ry1, fill=self._mask_color, outline=self._p["primary"], width=2, tags=(f"mask-{idx}",)) + canvas.create_text((rx0 + rx1) / 2, (ry0 + ry1) / 2, text="×", fill="#ffffff", font=("TkDefaultFont", 10, "bold")) + + def _on_mask_canvas_down(self, event) -> None: + canvas = self._mask_canvas + if canvas is None: + return + point = self._canvas_to_pdf(event.x, event.y) + if point is None: + return + hit = self._mask_at(point) + if hit is not None: + del self._mask_rects[hit] + self._mask_status_text.set("Masque supprimé.") + self._update_mask_count() + self._refresh_mask_canvas() + return + self._mask_drag_start = point + x, y = event.x, event.y + self._mask_preview_item = canvas.create_rectangle(x, y, x, y, outline=self._p["primary"], width=2, dash=(4, 2)) + + def _on_mask_canvas_drag(self, event) -> None: + canvas = self._mask_canvas + if canvas is None or self._mask_drag_start is None or self._mask_preview_item is None: + return + start = self._pdf_to_canvas(self._mask_drag_start) + if start is None: + return + canvas.coords(self._mask_preview_item, start[0], start[1], event.x, event.y) + + def _on_mask_canvas_up(self, event) -> None: + canvas = self._mask_canvas + if canvas is None or self._mask_drag_start is None: + return + end = self._canvas_to_pdf(event.x, event.y) + if self._mask_preview_item is not None: + canvas.delete(self._mask_preview_item) + self._mask_preview_item = None + start = self._mask_drag_start + self._mask_drag_start = None + if end is None: + return + x0, y0 = start + x1, y1 = end + rx0, rx1 = sorted([x0, x1]) + ry0, ry1 = sorted([y0, y1]) + if (rx1 - rx0) < 5 or (ry1 - ry0) < 5: + return + self._mask_rects.append( + {"page": self._mask_page_index, "x0": rx0, "y0": ry0, "x1": rx1, "y1": ry1, "label": "MASK"} + ) + self._mask_status_text.set(f"Masque ajouté page {self._mask_page_index + 1}.") + self._update_mask_count() + self._refresh_mask_canvas() + + def _canvas_to_pdf(self, x: float, y: float) -> tuple[float, float] | None: + ox, oy = self._mask_page_origin + scale = self._mask_current_scale + page_w, page_h = self._mask_page_size + px = (x - ox) / scale + py = (y - oy) / scale + if px < 0 or py < 0 or px > page_w or py > page_h: + return None + return px, py + + def _pdf_to_canvas(self, point: tuple[float, float]) -> tuple[float, float] | None: + ox, oy = self._mask_page_origin + scale = self._mask_current_scale + return ox + point[0] * scale, oy + point[1] * scale + + def _mask_at(self, point: tuple[float, float]) -> int | None: + px, py = point + for idx in range(len(self._mask_rects) - 1, -1, -1): + mask = self._mask_rects[idx] + if int(mask.get("page", 0)) != self._mask_page_index: + continue + if float(mask["x0"]) <= px <= float(mask["x1"]) and float(mask["y0"]) <= py <= float(mask["y1"]): + return idx + return None + + def _current_page_mask_count(self) -> int: + return sum(1 for m in self._mask_rects if int(m.get("page", 0)) == self._mask_page_index) + + def _update_mask_count(self) -> None: + page_count = self._current_page_mask_count() + total = len(self._mask_rects) + self._mask_count_text.set(f"{page_count} masque(s) page · {total} total") + # -- helpers UI ------------------------------------------------------- - def _two_columns(self, parent) -> tuple[ctk.CTkFrame, ctk.CTkFrame]: + 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="both", expand=True, padx=14) - left = ctk.CTkFrame(row, fg_color="transparent") - right = ctk.CTkFrame(row, fg_color="transparent") - left.pack(side="left", fill="both", expand=True, padx=(0, 7)) - right.pack(side="left", fill="both", expand=True, padx=(7, 0)) - return left, right + 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 @@ -348,69 +1012,90 @@ class ConfigTab(ctk.CTkFrame): anchor="w", justify="left", wraplength=330, - ).pack(fill="x", padx=16, pady=(0, 10), ipady=5) + ).pack(fill="x", padx=12, pady=(0, 10), ipady=5) - def _tag_editor(self, parent, placeholder: str, terms: list[str], kind: str) -> None: + 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=16, pady=(0, 8)) - entry = ctk.CTkEntry( + 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=32, - ) - entry.pack(side="left", fill="x", expand=True, padx=(0, 8)) - ui_kit.secondary_button(row, p, "+ Ajouter", command=lambda: None).pack(side="right") + 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=16, pady=(0, 14)) - tag_color = p["success"] if kind == "keep" else p["primary"] - line = ctk.CTkFrame(cloud, fg_color="transparent") - line.pack(fill="x", anchor="w") - used_width = 0 - for term in terms: - estimated_width = min(230, max(88, len(term) * 8 + 34)) - if used_width and used_width + estimated_width > 360: - line = ctk.CTkFrame(cloud, fg_color="transparent") - line.pack(fill="x", anchor="w") - used_width = 0 + 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( - line, - text=f"{term[:30]}{'…' if len(term) > 30 else ''} ×", - width=estimated_width, - text_color=tag_color, + cloud, + text=display, + width=150, + anchor="w", + text_color=color, fg_color=p["btn_sec_bg"], corner_radius=99, - font=ui_kit.font(12), - ).pack(side="left", padx=(0, 6), pady=3, ipadx=4, ipady=3) - used_width += estimated_width + 6 + font=ui_kit.font(10), + ).pack(anchor="w", fill="x", pady=2, ipadx=5, ipady=2) - def _slider_row(self, parent, label: str, value: int) -> None: + 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=16, pady=3) - ctk.CTkLabel(row, text=label, text_color=p["text"], font=ui_kit.font(13)).pack(side="left") - slider = ctk.CTkSlider(row, from_=0, to=6, number_of_steps=6, progress_color=p["primary"], width=125) - slider.set(value) - slider.pack(side="right") + 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 _update_mask_preview(self) -> None: - value = self._mask_style_var.get() - 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) + def _toolbar_button(self, parent, text: str, command, width: int = 42): + p = self._p + return ctk.CTkButton( + parent, + text=text, + command=command, + fg_color=p["btn_sec_bg"], + hover_color=p["card_border"], + text_color=p["text"], + border_color=p["btn_sec_border"], + border_width=1, + corner_radius=8, + height=32, + width=width, + font=ui_kit.font(13, "bold"), + ) 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=16, pady=1) + 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") diff --git a/tests/unit/test_gui_v6_config_mockup_sections.py b/tests/unit/test_gui_v6_config_mockup_sections.py index ae49326..a136ab1 100644 --- a/tests/unit/test_gui_v6_config_mockup_sections.py +++ b/tests/unit/test_gui_v6_config_mockup_sections.py @@ -2,7 +2,7 @@ from __future__ import annotations -from gui_v6.tabs.tab_config import CONFIG_MOCKUP_SECTIONS +from gui_v6.tabs.tab_config import CONFIG_INTERACTION_CONTRACT, CONFIG_MOCKUP_SECTIONS def test_config_mockup_sections_cover_admin_surface(): @@ -13,6 +13,8 @@ def test_config_mockup_sections_cover_admin_surface(): "Données à détecter", "Termes à toujours conserver", "Termes à toujours masquer", + "Masque manuel obligatoire", + "Template de masque manuel", ], "masquage": [ "Couleur de masquage (PDF)", @@ -20,7 +22,23 @@ def test_config_mockup_sections_cover_admin_surface(): "É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"], } + + +def test_config_interaction_contract_prebuilds_panels_and_mask_editor(): + assert CONFIG_INTERACTION_CONTRACT["subtabs"] == "prebuilt_panels" + assert CONFIG_INTERACTION_CONTRACT["reglages_columns"] == 3 + assert CONFIG_INTERACTION_CONTRACT["mask_editor"] == [ + "open_pdf", + "draw_rectangle", + "delete_rectangle_on_click", + "zoom", + "save_template_json", + "load_template_json_or_yaml", + "clear_page", + "apply_template_selection", + ]