1105 lines
47 KiB
Python
1105 lines
47 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 json
|
||
import sys
|
||
import webbrowser
|
||
from pathlib import Path
|
||
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"),
|
||
("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()
|
||
|
||
|
||
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):
|
||
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_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=(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()
|
||
if key == "msk":
|
||
self.after_idle(self._refresh_mask_canvas)
|
||
|
||
# -- 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))
|
||
|
||
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("<ButtonPress-1>", self._on_mask_canvas_down)
|
||
self._mask_canvas.bind("<B1-Motion>", self._on_mask_canvas_drag)
|
||
self._mask_canvas.bind("<ButtonRelease-1>", self._on_mask_canvas_up)
|
||
self._mask_canvas.bind("<Configure>", 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(side="right")
|
||
|
||
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
|
||
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,
|
||
)
|
||
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 _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 _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=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")
|