Files
anonymisation/gui_v6/tabs/tab_config.py
Domi31tls 13b79db417 feat(gui): éditeur de masques en fenêtre dédiée (GUI V6)
Remplace l'éditeur de masquage encastré dans l'onglet Configuration —
jugé inutilisable par Dom (document trop à l'étroit, non défilable) —
par une fenêtre dédiée où le document est majoritaire et réellement
navigable.

- gui_v6/mask_editor_model.py : couche logique pure (rectangles par
  page, conversions écran↔PDF, hit-test, sérialisation template)
  testable sans display ; réutilise MaskRect/Template de
  pdf_mask_designer → format de template inchangé (compat moteur).
- gui_v6/mask_editor_window.py : MaskEditorWindow (CTkToplevel)
  redimensionnable — canvas + scrollbars H+V câblées + molette (le
  manque qui rendait l'éditeur inutilisable), zoom + ajuster
  largeur/page, navigation pages, rectangles au glisser-déposer,
  sélection (clic) + suppression (Suppr / clic-droit), templates
  JSON/YAML, mode aperçu d'exemple sans PDF.
- tab_config.py : l'onglet Masquage lance la fenêtre dédiée ; retrait
  du canvas encastré et de ~290 lignes de code mort associé.
- tests/unit/test_gui_v6_mask_editor.py : 13 tests logique + 3 smoke
  headless (scrollbars, ajout/sélection/suppression, save/load
  roundtrip, câblage onglet→fenêtre).

Sans nouvelle dépendance. V5, moteur et app_aivanov non touchés.
221 tests unit OK (0 régression), self-test GUI V6 OK.
Verdict Qwen requis avant push/build/diffusion.

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

718 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Onglet Configuration de la GUI V6.
L'onglet est construit une seule fois : les sous-sections sont des panneaux
préchargés puis simplement relevés avec ``tkraise()``. Cela évite le rendu
progressif et les micro-latences visibles lors du passage Réglages/Masquage/
Partage/Règles.
"""
from __future__ import annotations
import sys
import webbrowser
from pathlib import Path
from tkinter import filedialog, messagebox
import customtkinter as ctk
from gui_v6 import theme as theme_mod
from gui_v6 import ui_kit
from gui_v6.config_state import ConfigState, default_profile_key, list_profile_keys
from manual_masking import ensure_mask_templates_dir, list_mask_templates, mask_template_label
_SUBTABS = [
("reg", "⚙️ Réglages"),
("msk", "🎭 Masquage"),
("shr", "🔄 Partage"),
("rul", "🛡️ Règles 2"),
]
_DETECTION_OPTIONS = [
("Noms et prénoms", "Gazetteers + IA"),
("Dates de naissance", "Contexte naissance"),
("Établissements", "FINESS + contexte"),
("Adresses / CP", "Voie, ville, code"),
("N° sécurité sociale", "NIR"),
("Téléphones / e-mails", "Contact"),
("N° adhérent mutuelle", "Identifiant local"),
]
_REPLACEMENT_CODES = [
("Nom/Prénom", "[NOM]"),
("Date naissance", "[DATE_NAISSANCE]"),
("Établissement", "[ETABLISSEMENT]"),
("Adresse", "[ADRESSE]"),
("Téléphone", "[TEL]"),
("N° sécu", "[NIR]"),
("IPP", "[IPP]"),
("Email", "[EMAIL]"),
]
_MASK_COLORS = [
("Noir", "#000000"),
("Bleu nuit", "#1a1a2e"),
("Gris", "#374151"),
("Marron", "#92400e"),
("Bleu marine", "#1e3a5f"),
]
_PRESERVE_TERMS = ["FUROSEMIDE", "rééducation fonctionnelle", "classification internationale"]
_MASK_TERMS = ["CHUXX"]
_STOPWORDS = ["hospitalisation", "contrôle", "prescription"]
MANUAL_MASK_NONE_LABEL = "Aucun masque manuel"
CONFIG_MOCKUP_SECTIONS = {
"reglages": [
"Profil métier",
"Moteurs NER",
"Données à détecter",
"Termes à toujours conserver",
"Termes à toujours masquer",
"Masque manuel obligatoire",
"Template de masque manuel",
],
"masquage": [
"Couleur de masquage (PDF)",
"Style des marqueurs (texte)",
"Épaisseur du masque",
"Codes de remplacement",
"Masques de zones fixes",
"Éditeur interactif de masques",
],
"partage": ["Exporter la configuration", "Importer une configuration"],
"regles": ["Règles actives", "Testeur de règle"],
}
CONFIG_INTERACTION_CONTRACT = {
"subtabs": "prebuilt_panels",
"reglages_columns": 3,
"mask_editor": [
"open_pdf",
"draw_rectangle",
"delete_rectangle_on_click",
"zoom",
"save_template_json",
"load_template_json_or_yaml",
"clear_page",
"apply_template_selection",
],
}
def _app_base_dir() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path.cwd()
class ConfigTab(ctk.CTkFrame):
def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, **kwargs):
self._p = palette or theme_mod.get_palette(theme_mod.DEFAULT_THEME)
super().__init__(master, fg_color=self._p["bg"], **kwargs)
self._state = state if state is not None else ConfigState()
self._sub = "reg"
self._sub_buttons: dict[str, ctk.CTkButton] = {}
self._panels: dict[str, ctk.CTkFrame] = {}
self._manual_mask_templates: dict[str, Path | None] = {MANUAL_MASK_NONE_LABEL: None}
self._manual_mask_var = ctk.StringVar(value=MANUAL_MASK_NONE_LABEL)
self._manual_mask_required_var = ctk.BooleanVar(value=self._state.manual_mask_required)
self._mask_style_var = ctk.StringVar(value=self._state.mask_marker_style)
self._mask_color = self._state.mask_color
self._mask_margin_x_var = ctk.IntVar(value=self._state.mask_margin_x)
self._mask_margin_y_var = ctk.IntVar(value=self._state.mask_margin_y)
self._mask_rounded_var = ctk.BooleanVar(value=self._state.mask_rounded_corners)
self._mask_status_text = ctk.StringVar(value="Éditeur de masques en fenêtre dédiée.")
# L'édition interactive des masques se fait dans une fenêtre séparée
# (gui_v6.mask_editor_window) ; on garde juste une référence à l'instance
# ouverte pour éviter d'en empiler plusieurs.
self._mask_editor_window = None
self._build()
@property
def state(self) -> ConfigState:
return self._state
# -- construction -----------------------------------------------------
def _build(self) -> None:
p = self._p
bar = ctk.CTkFrame(self, fg_color="transparent")
bar.pack(fill="x", padx=14, pady=(10, 2))
for key, label in _SUBTABS:
btn = ctk.CTkButton(
bar,
text=label,
command=lambda k=key: self._show_sub(k),
fg_color=p["btn_sec_bg"] if key == self._sub else "transparent",
hover_color=p["card_border"],
text_color=p["primary"] if key == self._sub else p["text_dim"],
border_color=p["card_border"],
border_width=1 if key == self._sub else 0,
font=ui_kit.font(13, "bold" if key == self._sub else "normal"),
corner_radius=6,
width=10,
height=30,
)
btn.pack(side="left", padx=(0, 6))
self._sub_buttons[key] = btn
self._body = ctk.CTkFrame(self, fg_color="transparent")
self._body.pack(fill="both", expand=True, padx=14, pady=(4, 0))
self._body.grid_columnconfigure(0, weight=1)
self._body.grid_rowconfigure(0, weight=1)
builders = {
"reg": self._build_reglages,
"msk": self._build_masquage,
"shr": self._build_partage,
"rul": self._build_regles,
}
for key, builder in builders.items():
panel = ctk.CTkFrame(self._body, fg_color="transparent")
panel.grid(row=0, column=0, sticky="nsew")
self._panels[key] = panel
builder(panel)
self._refresh_manual_mask_templates()
self._show_sub("reg")
def _show_sub(self, key: str) -> None:
self._sub = key
p = self._p
for k, btn in self._sub_buttons.items():
active = k == key
btn.configure(
fg_color=p["btn_sec_bg"] if active else "transparent",
border_width=1 if active else 0,
text_color=p["primary"] if active else p["text_dim"],
font=ui_kit.font(13, "bold" if active else "normal"),
)
self._panels[key].tkraise()
# -- Réglages ---------------------------------------------------------
def _build_reglages(self, parent) -> None:
p = self._p
top = ctk.CTkFrame(
parent,
fg_color=p["card"],
border_color=p["card_border"],
border_width=1,
corner_radius=8,
)
top.pack(fill="x", pady=(0, 8))
profiles = list_profile_keys()
current = self._state.profile or default_profile_key() or (profiles[0] if profiles else "")
self._state.profile = current or None
ctk.CTkLabel(top, text="Profil métier", text_color=p["text_dim"], font=ui_kit.font(11, "bold")).pack(
side="left", padx=(12, 8), pady=10
)
self._profile_menu = ctk.CTkOptionMenu(
top,
values=profiles or ["(aucun profil)"],
command=self._on_profile,
fg_color=p["btn_sec_bg"],
button_color=p["primary"],
button_hover_color=p["primary_dim"],
text_color=p["text"],
width=180,
height=30,
)
if current:
self._profile_menu.set(current)
self._profile_menu.pack(side="left", pady=10)
ui_kit.secondary_button(top, p, "📁 Sortie…", command=self._pick_output).pack(
side="left", padx=(12, 6), pady=10
)
self._out_label = ctk.CTkLabel(
top,
text=str(self._state.output_dir or "anonymise/"),
text_color=p["text_muted"],
font=ui_kit.font(12),
)
self._out_label.pack(side="left", fill="x", expand=True, padx=(0, 12), pady=10)
cols = self._columns(parent, 3, gap=8, height=455)
det = ui_kit.Card(cols[0], p, title="🔍 Données à détecter")
det.pack(fill="both", expand=True)
for label, hint in _DETECTION_OPTIONS:
self._mini_toggle(det, label, hint, value=True).pack(fill="x", padx=12, pady=1)
ner = ui_kit.Card(cols[1], p, title="🧠 Moteurs et masques")
ner.pack(fill="both", expand=True)
self._tog_ner = self._mini_toggle(
ner, "CamemBERT-bio", "rapide · F1 0.963", value=self._state.use_local_ner, command=self._on_ner
)
self._tog_ner.pack(fill="x", padx=12, pady=1)
self._tog_eds = self._mini_toggle(
ner, "EDS-Pseudo", "médical français", value=self._state.enable_eds, command=self._on_eds
)
self._tog_eds.pack(fill="x", padx=12, pady=1)
self._tog_gli = self._mini_toggle(
ner, "GLiNER", "vote croisé", value=self._state.enable_gliner, command=self._on_gliner
)
self._tog_gli.pack(fill="x", padx=12, pady=1)
self._mini_toggle(
ner,
"Masque manuel obligatoire",
"bloque le traitement si absent",
value=self._state.manual_mask_required,
variable=self._manual_mask_required_var,
command=self._on_manual_mask_required,
).pack(fill="x", padx=12, pady=(8, 1))
ctk.CTkLabel(
ner,
text="Template de masque manuel",
text_color=p["text_muted"],
font=ui_kit.font(11, "bold"),
anchor="w",
).pack(fill="x", padx=12, pady=(10, 2))
self._manual_mask_menu = ctk.CTkOptionMenu(
ner,
values=[MANUAL_MASK_NONE_LABEL],
variable=self._manual_mask_var,
command=self._on_manual_mask_template,
fg_color=p["btn_sec_bg"],
button_color=p["primary"],
button_hover_color=p["primary_dim"],
text_color=p["text"],
height=30,
)
self._manual_mask_menu.pack(fill="x", padx=12, pady=(0, 6))
mask_actions = ctk.CTkFrame(ner, fg_color="transparent")
mask_actions.pack(fill="x", padx=12, pady=(0, 12))
ui_kit.secondary_button(mask_actions, p, "🔄 Actualiser", command=self._refresh_manual_mask_templates).pack(
side="left", fill="x", expand=True, padx=(0, 4)
)
ui_kit.secondary_button(mask_actions, p, "📁 Dossier", command=self._open_templates_dir).pack(
side="left", fill="x", expand=True, padx=(4, 0)
)
terms = ui_kit.Card(cols[2], p, title="✅ Listes locales")
terms.pack(fill="both", expand=True)
self._compact_tag_editor(terms, "Termes à conserver", "Ex : FUROSEMIDE", _PRESERVE_TERMS, "keep")
self._compact_tag_editor(terms, "Termes à masquer", "Ex : CHUXX", _MASK_TERMS, "mask")
self._compact_tag_editor(terms, "Mots à ignorer", "Ex : prescription", _STOPWORDS, "stop")
# -- Masquage ---------------------------------------------------------
def _build_masquage(self, parent) -> None:
p = self._p
top_cols = self._columns(parent, 3, gap=8, height=300)
pdf_opts = ui_kit.Card(top_cols[0], p, title="⬛ PDF")
pdf_opts.pack(fill="both", expand=True)
ctk.CTkLabel(
pdf_opts, text="Couleur de masquage", text_color=p["text"], font=ui_kit.font(12, "bold")
).pack(anchor="w", padx=12, pady=(0, 4))
swatches = ctk.CTkFrame(pdf_opts, fg_color="transparent")
swatches.pack(fill="x", padx=12, pady=(0, 8))
self._swatch_buttons: dict[str, ctk.CTkButton] = {}
for label, color_value in _MASK_COLORS:
btn = ctk.CTkButton(
swatches,
text="",
width=30,
height=26,
fg_color=color_value,
hover_color=color_value,
border_color=p["primary"] if color_value == self._mask_color else p["card_border"],
border_width=3 if color_value == self._mask_color else 1,
corner_radius=6,
command=lambda c=color_value: self._set_mask_color(c),
)
btn.pack(side="left", padx=(0, 6))
self._swatch_buttons[color_value] = btn
self._slider_row(pdf_opts, "Marge H", self._mask_margin_x_var, self._on_mask_margin_x)
self._slider_row(pdf_opts, "Marge V", self._mask_margin_y_var, self._on_mask_margin_y)
self._mini_toggle(
pdf_opts,
"Coins arrondis",
"",
value=self._state.mask_rounded_corners,
variable=self._mask_rounded_var,
command=self._on_rounded_corners,
).pack(fill="x", padx=12, pady=(4, 10))
text_opts = ui_kit.Card(top_cols[1], p, title="🏷️ Texte")
text_opts.pack(fill="both", expand=True)
for label, value, preview in [
("Crochets", "brackets", "[NOM]"),
("Étoiles", "stars", "***"),
("Noirci", "blackout", "████"),
]:
ctk.CTkRadioButton(
text_opts,
text=f"{label} {preview}",
variable=self._mask_style_var,
value=value,
command=self._update_mask_preview,
text_color=p["text"],
fg_color=p["primary"],
hover_color=p["primary_dim"],
font=ui_kit.font(12),
).pack(anchor="w", padx=12, pady=2)
self._mask_preview = ctk.CTkLabel(
text_opts,
text="Patient [NOM], né le [DATE_NAISSANCE]",
text_color=p["text_dim"],
fg_color=p["divider"],
corner_radius=6,
font=ui_kit.font(12),
anchor="w",
)
self._mask_preview.pack(fill="x", padx=12, pady=(8, 10), ipady=7)
codes = ui_kit.Card(top_cols[2], p, title="🔒 Codes")
codes.pack(fill="both", expand=True)
grid = ctk.CTkFrame(codes, fg_color="transparent")
grid.pack(fill="both", expand=True, padx=12, pady=(0, 10))
for idx, (label, code) in enumerate(_REPLACEMENT_CODES):
ctk.CTkLabel(grid, text=label, text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").grid(
row=idx, column=0, sticky="w", pady=1
)
ctk.CTkLabel(grid, text=code, text_color=p["primary"], font=ui_kit.font(11, "bold"), anchor="w").grid(
row=idx, column=1, sticky="w", padx=(8, 0), pady=1
)
grid.grid_columnconfigure(0, weight=1)
editor = ui_kit.Card(parent, p, title="🏠 Masques de zones fixes")
editor.pack(fill="x", pady=(8, 0))
ctk.CTkLabel(
editor,
text=(
"Définissez les zones à masquer (en-têtes, blocs identité…) directement sur "
"votre PDF, dans une fenêtre dédiée où le document est affiché en grand et "
"défilable (scroll, zoom, ajuster largeur/page). Les templates enregistrés "
"apparaissent ensuite dans « Template de masque manuel » (onglet Réglages)."
),
text_color=p["text_muted"],
font=ui_kit.font(12),
justify="left",
wraplength=760,
anchor="w",
).pack(fill="x", padx=14, pady=(0, 10))
actions = ctk.CTkFrame(editor, fg_color="transparent")
actions.pack(fill="x", padx=14, pady=(0, 6))
ui_kit.primary_button(
actions, p, "🖊 Ouvrir l'éditeur de masques", command=self._open_full_mask_editor
).pack(side="left")
ui_kit.secondary_button(
actions, p, "📁 Dossier des templates", command=self._open_templates_dir
).pack(side="left", padx=(8, 0))
ctk.CTkLabel(
editor,
textvariable=self._mask_status_text,
text_color=p["text_muted"],
font=ui_kit.font(11),
anchor="w",
).pack(fill="x", padx=14, pady=(2, 12))
# -- Partage / Règles -------------------------------------------------
def _build_partage(self, parent) -> None:
p = self._p
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(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, parent) -> None:
p = self._p
card = ui_kit.Card(parent, p, title="🛡️ Règles actives")
card.pack(fill="x", pady=(0, 8))
self._note(card, "Ces règles adaptent le moteur à votre établissement. Chaque règle est validée avant activation.")
headers = ctk.CTkFrame(card, fg_color="transparent")
headers.pack(fill="x", padx=12, pady=(0, 4))
for text, width in [("Label", 190), ("Type", 80), ("Cible → Résultat", 210), ("Statut", 70), ("", 70)]:
ctk.CTkLabel(headers, text=text.upper(), width=width, anchor="w", text_color=p["text_muted"], font=ui_kit.font(10, "bold")).pack(side="left")
for row in [
("Masquer le sigle CHUXX", "exact", "CHUXX → [MASK]", "Actif"),
("Préserver “classification internationale”", "preserve", "conservé tel quel", "Actif"),
("Identifier N° 1234567", "norm-id", "N° 1234567 → [NDA]", "Candidat"),
]:
self._rule_row(card, row)
actions = ctk.CTkFrame(card, fg_color="transparent")
actions.pack(fill="x", padx=12, pady=(8, 12))
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(parent, p, title="🧪 Testeur de règle")
sim.pack(fill="x")
ctk.CTkLabel(sim, text="Texte de test", text_color=p["text_muted"], font=ui_kit.font(12)).pack(anchor="w", padx=12)
txt = ctk.CTkTextbox(sim, height=74, fg_color=p["divider"], text_color=p["text"], border_color=p["card_border"], border_width=1)
txt.pack(fill="x", padx=12, pady=(5, 8))
txt.insert("1.0", "Compte rendu CHUXX, patient N° 1234567.")
btns = ctk.CTkFrame(sim, fg_color="transparent")
btns.pack(fill="x", padx=12, pady=(0, 12))
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 réglages ----------------------------------------------
def _on_profile(self, value: str) -> None:
self._state.profile = value
def _on_ner(self) -> None:
self._state.use_local_ner = self._tog_ner.get()
def _on_eds(self) -> None:
self._state.enable_eds = self._tog_eds.get()
def _on_gliner(self) -> None:
self._state.enable_gliner = self._tog_gli.get()
def _on_manual_mask_required(self) -> None:
self._state.manual_mask_required = bool(self._manual_mask_required_var.get())
def _on_manual_mask_template(self, label: str) -> None:
self._state.manual_mask_template = self._manual_mask_templates.get(label)
def _pick_output(self) -> None:
path = filedialog.askdirectory(title="Dossier de sortie")
if path:
self._state.output_dir = Path(path)
self._out_label.configure(text=str(self._state.output_dir))
def _refresh_manual_mask_templates(self) -> None:
selected = self._state.manual_mask_template
options: dict[str, Path | None] = {MANUAL_MASK_NONE_LABEL: None}
try:
for path in list_mask_templates(_app_base_dir()):
options[mask_template_label(path, _app_base_dir())] = path
except Exception:
pass
self._manual_mask_templates = options
labels = list(options)
if hasattr(self, "_manual_mask_menu"):
self._manual_mask_menu.configure(values=labels)
selected_label = MANUAL_MASK_NONE_LABEL
if selected is not None:
for label, path in options.items():
if path == selected:
selected_label = label
break
self._manual_mask_var.set(selected_label)
self._state.manual_mask_template = options.get(selected_label)
def _open_templates_dir(self) -> None:
path = ensure_mask_templates_dir(_app_base_dir())
try:
webbrowser.open(path.as_uri())
except Exception:
messagebox.showinfo("Dossier modèles", str(path))
# -- callbacks masquage ----------------------------------------------
def _set_mask_color(self, color: str) -> None:
self._mask_color = color
self._state.mask_color = color
p = self._p
for value, btn in self._swatch_buttons.items():
btn.configure(
border_color=p["primary"] if value == color else p["card_border"],
border_width=3 if value == color else 1,
)
def _on_mask_margin_x(self, value: float) -> None:
self._state.mask_margin_x = int(round(value))
self._mask_margin_x_var.set(self._state.mask_margin_x)
def _on_mask_margin_y(self, value: float) -> None:
self._state.mask_margin_y = int(round(value))
self._mask_margin_y_var.set(self._state.mask_margin_y)
def _on_rounded_corners(self) -> None:
self._state.mask_rounded_corners = bool(self._mask_rounded_var.get())
def _update_mask_preview(self) -> None:
value = self._mask_style_var.get()
self._state.mask_marker_style = value
if value == "stars":
text = "Patient ***, né le ***"
elif value == "blackout":
text = "Patient ████, né le ████"
else:
text = "Patient [NOM], né le [DATE_NAISSANCE]"
self._mask_preview.configure(text=text)
# -- éditeur masques --------------------------------------------------
def _open_full_mask_editor(self) -> None:
existing = self._mask_editor_window
if existing is not None:
try:
if existing.winfo_exists():
existing.lift()
existing.focus_force()
return
except Exception:
pass
try:
from gui_v6.mask_editor_window import MaskEditorWindow
active = self._state.manual_mask_template
initial_template = active if (active and Path(active).exists()) else None
win = MaskEditorWindow(
self.winfo_toplevel(),
templates_dir=ensure_mask_templates_dir(_app_base_dir()),
initial_template=initial_template,
on_template_saved=self._on_mask_template_saved,
)
self._mask_editor_window = win
self._mask_status_text.set("Éditeur de masques ouvert.")
except Exception as exc:
messagebox.showerror("Masques PDF", f"Impossible d'ouvrir l'éditeur : {exc}")
def _on_mask_template_saved(self, path: Path) -> None:
"""Callback déclenché par la fenêtre dédiée après sauvegarde d'un template."""
self._refresh_manual_mask_templates()
try:
self._manual_mask_var.set(mask_template_label(path, _app_base_dir()))
self._state.manual_mask_template = path
except Exception:
pass
self._mask_status_text.set(f"Template enregistré : {path.name}")
# -- helpers UI -------------------------------------------------------
def _columns(self, parent, count: int, gap: int = 8, height: int | None = None) -> list[ctk.CTkFrame]:
row = ctk.CTkFrame(parent, fg_color="transparent")
row.pack(fill="x")
if height is not None:
row.configure(height=height)
row.pack_propagate(False)
row.grid_rowconfigure(0, weight=1)
frames: list[ctk.CTkFrame] = []
for idx in range(count):
row.grid_columnconfigure(idx, weight=1, uniform="config-cols")
frame = ctk.CTkFrame(row, fg_color="transparent")
frame.grid(row=0, column=idx, sticky="nsew", padx=(0 if idx == 0 else gap // 2, 0 if idx == count - 1 else gap // 2))
frames.append(frame)
return frames
def _note(self, parent, text: str) -> None:
p = self._p
ctk.CTkLabel(
parent,
text=text,
text_color=p["text_muted"],
fg_color=p["divider"],
corner_radius=4,
font=ui_kit.font(11),
anchor="w",
justify="left",
wraplength=330,
).pack(fill="x", padx=12, pady=(0, 10), ipady=5)
def _mini_toggle(self, parent, label: str, hint: str, value: bool = True, variable=None, command=None):
p = self._p
row = ctk.CTkFrame(parent, fg_color="transparent", height=34)
row.pack_propagate(False)
left = ctk.CTkFrame(row, fg_color="transparent")
left.pack(side="left", fill="x", expand=True)
ctk.CTkLabel(left, text=label, text_color=p["text"], font=ui_kit.font(12), anchor="w").pack(anchor="w")
if hint:
ctk.CTkLabel(left, text=hint, text_color=p["text_muted"], font=ui_kit.font(10), anchor="w").pack(anchor="w")
var = variable if variable is not None else ctk.BooleanVar(value=value)
switch = ctk.CTkSwitch(row, text="", variable=var, command=command, progress_color=p["primary"], width=38)
switch.pack(side="right", padx=(6, 0))
row.var = var # type: ignore[attr-defined]
row.get = lambda: bool(var.get()) # type: ignore[attr-defined]
return row
def _compact_tag_editor(self, parent, title: str, placeholder: str, terms: list[str], kind: str) -> None:
p = self._p
color = {"keep": p["success"], "mask": p["primary"], "stop": p["warning"]}.get(kind, p["primary"])
ctk.CTkLabel(parent, text=title, text_color=p["text"], font=ui_kit.font(12, "bold"), anchor="w").pack(
fill="x", padx=12, pady=(0, 2)
)
row = ctk.CTkFrame(parent, fg_color="transparent")
row.pack(fill="x", padx=12, pady=(0, 5))
ctk.CTkEntry(
row,
placeholder_text=placeholder,
fg_color=p["btn_sec_bg"],
border_color=p["btn_sec_border"],
text_color=p["text"],
height=28,
).pack(side="left", fill="x", expand=True, padx=(0, 6))
ui_kit.secondary_button(row, p, "+").pack(side="right")
cloud = ctk.CTkFrame(parent, fg_color="transparent")
cloud.pack(fill="x", padx=12, pady=(0, 8))
for term in terms[:2]:
display = f"{term[:18]}{'' if len(term) > 18 else ''} ×"
ctk.CTkLabel(
cloud,
text=display,
width=150,
anchor="w",
text_color=color,
fg_color=p["btn_sec_bg"],
corner_radius=99,
font=ui_kit.font(10),
).pack(anchor="w", fill="x", pady=2, ipadx=5, ipady=2)
def _slider_row(self, parent, label: str, variable: ctk.IntVar, command) -> None:
p = self._p
row = ctk.CTkFrame(parent, fg_color="transparent")
row.pack(fill="x", padx=12, pady=2)
ctk.CTkLabel(row, text=label, text_color=p["text"], font=ui_kit.font(12)).pack(side="left")
ctk.CTkLabel(row, textvariable=variable, text_color=p["primary"], font=ui_kit.font(11, "bold"), width=20).pack(
side="right"
)
slider = ctk.CTkSlider(row, from_=0, to=6, number_of_steps=6, progress_color=p["primary"], width=96, command=command)
slider.set(variable.get())
slider.pack(side="right", padx=(8, 4))
def _rule_row(self, parent, values: tuple[str, str, str, str]) -> None:
p = self._p
label, rule_type, target, status = values
row = ctk.CTkFrame(parent, fg_color="transparent")
row.pack(fill="x", padx=12, pady=1)
ctk.CTkLabel(row, text=label, width=190, anchor="w", text_color=p["text"], font=ui_kit.font(12)).pack(side="left")
ctk.CTkLabel(row, text=rule_type, width=80, anchor="w", text_color=p["text_dim"], font=ui_kit.font(11)).pack(side="left")
ctk.CTkLabel(row, text=target, width=210, anchor="w", text_color=p["primary"], font=ui_kit.font(12, "bold")).pack(side="left")
color = p["success"] if status == "Actif" else p["warning"]
ctk.CTkLabel(row, text=status, width=70, anchor="w", text_color=color, font=ui_kit.font(11, "bold")).pack(side="left")
ui_kit.secondary_button(row, p, "▶ Tester", command=lambda: None).pack(side="left")