"""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")