Files
anonymisation/gui_v6/tabs/tab_usage.py
Domi31tls 6a0a5811a5 fix(gui): retours Dom GUI V6 — thème, Administration, Règles, aide
Cinq retours utilisateur sur l'exécutable Windows GUI V6.

- Thème : `_render()` vidait les widgets mais conservait le cache
  `_tab_frames`/`_visible_tab` → l'onglet Utilisation se vidait (TclError
  sur widget détruit) au changement de thème. Reset du cache dans
  `_render()` → onglet actif recréé proprement.
- Onglet principal « Configuration » → « Administration » (clé interne
  inchangée).
- Sous-onglet « Règles  2 » → « Règles » (le « 2 » était un badge non
  câblé).
- Actions de maquette non câblées (Partage Export/Import, Règles Nouvelle
  règle/Recharger/Tester/Fermer) désactivées + suffixe « (à venir) » via
  `_mockup_button` : plus aucune action morte qui semble fonctionner.
- Aide « ? » restaurée (façon V5) : `ui_kit.HelpButton`/`help_button`
  réutilisable ouvrant une fenêtre d'aide en français simple, posée sur
  Utilisation, Administration (Réglages/Masquage/Partage/Règles) et
  À propos. Partage : phrase visible + aide expliquant qu'on partage les
  réglages, jamais les documents patients.

`tests/unit/test_gui_v6_app_shell.py` : régression thème, libellés,
présence d'aide, navigation. 228 tests unit OK (0 régression), self-test
GUI V6 OK. V5/moteur/app_aivanov non touchés, aucune dépendance ajoutée.
Verdict Qwen requis avant push/build/diffusion.

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

310 lines
14 KiB
Python

"""Onglet « Utilisation » de la GUI V6 (G4 — alignement maquette).
Reprend la structure de ``docs/ui_mockup_v6.html`` : carte Apparence (sélecteur
de thème), carte Documents (dropzone + liste), carte Format, barre d'actions,
carte progression (étapes), carte résultats (cartes statistiques). La logique
(runner moteur câblé, threading, file d'événements, arrêt coopératif, anti
double-lancement) est conservée. Aucune logique de détection ici.
"""
from __future__ import annotations
import queue
import threading
from pathlib import Path
from tkinter import filedialog
import customtkinter as ctk
from gui_v6 import theme as theme_mod
from gui_v6 import ui_kit
from gui_v6.processing_runner import ProcessingRunner, default_output_dir
_STEPS = ["📖 Extraction", "🧠 Détection", "🔒 Masquage", "📄 PDF final"]
_HELP_USAGE = (
"Anonymiser vos documents.\n\n"
"1) Choisissez un fichier ou un dossier de documents.\n"
"2) Vérifiez le format de sortie.\n"
"3) Cliquez sur « Lancer » : l'application détecte et masque les données "
"personnelles, puis écrit les documents anonymisés dans un dossier de sortie.\n\n"
"Tout le traitement se fait 100 % en local sur ce poste. Aucun document "
"n'est envoyé sur Internet."
)
class UsageTab(ctk.CTkFrame):
def __init__(
self,
master,
runner: ProcessingRunner | None = None,
config_provider=None,
config_path: Path | None = None,
palette: dict | None = None,
on_theme_change=None,
current_theme: str = theme_mod.DEFAULT_THEME,
**kwargs,
):
self._p = palette or theme_mod.get_palette(current_theme)
super().__init__(master, fg_color=self._p["bg"], **kwargs)
self._runner = runner or ProcessingRunner()
self._config_provider = config_provider
self._config_path = config_path
self._on_theme_change = on_theme_change
self._current_theme = current_theme
self._input_path: Path | None = None
self._output_dir: Path | None = None
self._stop_event: threading.Event | None = None
self._is_running = False
self._events: "queue.Queue[tuple]" = queue.Queue()
self._build()
self.after(120, self._drain_events)
# -- construction UI --------------------------------------------------
def _build(self) -> None:
p = self._p
# Bandeau d'introduction + aide « ? »
intro = ctk.CTkFrame(self, fg_color="transparent")
intro.pack(fill="x", padx=14, pady=(12, 0))
ctk.CTkLabel(
intro,
text="Sélectionnez vos documents puis lancez l'anonymisation (100 % local).",
text_color=p["text_dim"],
font=ui_kit.font(12),
anchor="w",
).pack(side="left", padx=(2, 6))
ui_kit.help_button(intro, p, _HELP_USAGE, title="Comment ça marche ?").pack(side="right", padx=2)
# Carte Apparence (sélecteur de thème)
appearance = ui_kit.Card(self, p, title="🎨 Apparence")
appearance.pack(fill="x", padx=14, pady=(14, 7))
row = ctk.CTkFrame(appearance, fg_color="transparent")
row.pack(fill="x", padx=16, pady=(0, 14))
for name in theme_mod.theme_names():
ui_kit.pill_button(
row,
p,
theme_mod.THEME_LABELS.get(name, name),
command=lambda n=name: self._change_theme(n),
active=(name == self._current_theme),
).pack(side="left", padx=(0, 8))
# Carte Documents + dropzone
docs = ui_kit.Card(self, p, title="📁 Documents à anonymiser")
docs.pack(fill="x", padx=14, pady=7)
dz = ctk.CTkFrame(
docs, fg_color=p["divider"], border_color=p["card_border"], border_width=2, corner_radius=8
)
dz.pack(fill="x", padx=16, pady=(0, 8))
ctk.CTkLabel(dz, text="⬆️", font=ui_kit.font(30)).pack(pady=(20, 4))
ctk.CTkLabel(dz, text="Choisissez vos fichiers", text_color=p["text"], font=ui_kit.font(14)).pack()
ctk.CTkLabel(dz, text="PDF · Word · Images · Texte", text_color=p["text_muted"], font=ui_kit.font(12)).pack(pady=(2, 10))
acts = ctk.CTkFrame(dz, fg_color="transparent")
acts.pack(pady=(0, 20))
ui_kit.secondary_button(acts, p, "📄 Fichier", command=self._pick_file).pack(side="left", padx=4)
ui_kit.secondary_button(acts, p, "📁 Dossier entier", command=self._pick_folder).pack(side="left", padx=4)
self._target_label = ctk.CTkLabel(docs, text="Aucune source sélectionnée", text_color=p["text_muted"], font=ui_kit.font(12), anchor="w")
self._target_label.pack(fill="x", padx=16, pady=(0, 14))
# Carte Format de sortie
fmt = ui_kit.Card(self, p, title="💾 Format de sortie")
fmt.pack(fill="x", padx=14, pady=7)
grid = ctk.CTkFrame(fmt, fg_color="transparent")
grid.pack(fill="x", padx=16, pady=(0, 14))
for icon, name, sub in [("📄", "PDF anonymisé", "Zones noircies"), ("📝", "Texte .txt", "Mots → [NOM]…")]:
fcard = ctk.CTkFrame(grid, fg_color=p["card"], border_color=p["primary"], border_width=2, corner_radius=8)
fcard.pack(side="left", expand=True, fill="x", padx=4)
ctk.CTkLabel(fcard, text=icon, font=ui_kit.font(20)).pack(pady=(10, 0))
ctk.CTkLabel(fcard, text=name, text_color=p["text"], font=ui_kit.font(13, "bold")).pack()
ctk.CTkLabel(fcard, text=sub, text_color=p["text_muted"], font=ui_kit.font(11)).pack(pady=(0, 10))
# Barre d'actions
brow = ctk.CTkFrame(self, fg_color="transparent")
brow.pack(fill="x", padx=14, pady=7)
ui_kit.secondary_button(brow, p, "✖ Effacer", command=self._clear_input).pack(side="right", padx=(8, 0))
self._run_btn = ui_kit.primary_button(brow, p, "▶ Lancer l'anonymisation", command=self._start, large=True)
self._run_btn.configure(state="disabled")
self._run_btn.pack(side="right")
# Carte progression
self._psec = ui_kit.Card(self, p, title="⏳ Traitement en cours")
self._progress = ctk.CTkProgressBar(self._psec, progress_color=p["primary"])
self._progress.set(0.0)
self._progress.pack(fill="x", padx=16, pady=(0, 6))
self._status_label = ctk.CTkLabel(self._psec, text="Prêt.", text_color=p["text_muted"], font=ui_kit.font(12), anchor="w")
self._status_label.pack(fill="x", padx=16)
steps = ctk.CTkFrame(self._psec, fg_color="transparent")
steps.pack(fill="x", padx=16, pady=6)
for s in _STEPS:
ctk.CTkLabel(steps, text=s, fg_color=p["divider"], text_color=p["text_muted"], corner_radius=99, font=ui_kit.font(11)).pack(side="left", padx=3, ipadx=6, ipady=2)
self._log = ctk.CTkTextbox(self._psec, height=110, fg_color=p["divider"], text_color=p["text_dim"], font=ui_kit.font(11), border_color=p["card_border"], border_width=1)
self._log.pack(fill="x", padx=16, pady=8)
self._log.configure(state="disabled")
self._stop_btn = ui_kit.secondary_button(self._psec, p, "⏹ Arrêter", command=self._request_stop)
self._stop_btn.configure(state="disabled")
self._stop_btn.pack(anchor="e", padx=16, pady=(0, 14))
# masquée tant qu'aucun run
# Carte résultats (cartes statistiques)
self._rsec = ui_kit.Card(self, p, title="✅ Résultats")
self._stats_row = ctk.CTkFrame(self._rsec, fg_color="transparent")
self._stats_row.pack(fill="x", padx=16, pady=(0, 14))
self._result_built = False
# -- thème ------------------------------------------------------------
def _change_theme(self, name: str) -> None:
if self._on_theme_change is not None:
self._on_theme_change(name)
# -- runner câblé -----------------------------------------------------
def _build_run_runner(self) -> tuple[ProcessingRunner, Path | None]:
if self._config_provider is None:
return self._runner, self._output_dir
from gui_v6.engine_bridge import make_process_fn
cfg = self._config_provider()
settings = cfg.to_engine_settings(self._config_path)
runner = ProcessingRunner(process_fn=make_process_fn(settings))
output_dir = self._output_dir or getattr(cfg, "output_dir", None)
return runner, output_dir
# -- sélection --------------------------------------------------------
def _pick_file(self) -> None:
path = filedialog.askopenfilename(title="Choisir un document")
if path:
self._set_input(Path(path))
def _pick_folder(self) -> None:
path = filedialog.askdirectory(title="Choisir un dossier")
if path:
self._set_input(Path(path))
def _clear_input(self) -> None:
self._input_path = None
self._target_label.configure(text="Aucune source sélectionnée")
self._run_btn.configure(state="disabled")
def _set_input(self, path: Path) -> None:
self._input_path = path
count = len(self._runner.discover(path))
if path.is_dir():
self._target_label.configure(text=f"📁 {path} · {count} document(s) détecté(s)")
else:
self._target_label.configure(text=f"📄 {path.name} · sortie {default_output_dir(path)}")
self._run_btn.configure(state="normal" if count > 0 else "disabled")
# -- exécution --------------------------------------------------------
def _start(self) -> None:
if self._input_path is None or self._is_running:
return
self._is_running = True
run_runner, run_output_dir = self._build_run_runner()
self._stop_event = threading.Event()
self._run_btn.configure(state="disabled")
self._psec.pack(fill="x", padx=14, pady=7)
self._stop_btn.configure(state="normal")
self._progress.set(0.0)
self._clear_log()
self._set_status("Traitement en cours…")
input_path, output_dir, stop = self._input_path, run_output_dir, self._stop_event
def work() -> None:
try:
summary = run_runner.run(
input_path,
output_dir,
on_progress=lambda done, total, name: self._events.put(("progress", done, total, name)),
on_log=lambda msg: self._events.put(("log", msg)),
stop_event=stop,
)
self._events.put(("done", summary))
except Exception as exc: # garde-fou : ne jamais laisser le thread tuer l'UI
self._events.put(("error", str(exc)))
self._worker = threading.Thread(target=work, daemon=True)
self._worker.start()
def _request_stop(self) -> None:
if self._stop_event is not None:
self._stop_event.set()
self._set_status("Arrêt demandé…")
self._stop_btn.configure(state="disabled")
# -- file d'événements worker → UI ------------------------------------
def _drain_events(self) -> None:
try:
while True:
event = self._events.get_nowait()
self._handle_event(event)
except queue.Empty:
pass
self.after(120, self._drain_events)
def _handle_event(self, event: tuple) -> None:
kind = event[0]
if kind == "progress":
_, done, total, name = event
self._progress.set(done / total if total else 0.0)
self._set_status(f"{done}/{total}{name}")
elif kind == "log":
self._append_log(event[1])
elif kind == "done":
self._finish(event[1])
elif kind == "error":
self._append_log(f"Erreur : {event[1]}")
self._finish(None)
def _finish(self, summary) -> None:
self._is_running = False
self._stop_btn.configure(state="disabled")
self._run_btn.configure(state="normal")
if summary is None:
self._set_status("Terminé avec erreur.")
return
if summary.stopped:
self._set_status(f"Arrêté : {summary.succeeded}/{summary.total} traités.")
else:
self._progress.set(1.0)
self._set_status(f"Terminé : {summary.succeeded} OK, {summary.failed} échec(s) sur {summary.total}.")
self._show_results(summary)
def _show_results(self, summary) -> None:
p = self._p
for w in self._stats_row.winfo_children():
w.destroy()
cards = [
(str(summary.total), "Documents", p["primary"]),
(str(summary.succeeded), "Réussis", p["success"]),
(str(summary.failed), "Échecs", p["danger"] if summary.failed else p["text_muted"]),
("OK" if summary.ok else "KO", "Statut", p["success"] if summary.ok else p["warning"]),
]
for value, label, color in cards:
ui_kit.StatCard(self._stats_row, p, value, label, value_color=color).pack(side="left", expand=True, fill="x", padx=4)
self._rsec.pack(fill="x", padx=14, pady=(7, 14))
# -- helpers widgets --------------------------------------------------
def _set_status(self, text: str) -> None:
self._status_label.configure(text=text)
def _clear_log(self) -> None:
self._log.configure(state="normal")
self._log.delete("1.0", "end")
self._log.configure(state="disabled")
def _append_log(self, message: str) -> None:
self._log.configure(state="normal")
self._log.insert("end", message + "\n")
self._log.see("end")
self._log.configure(state="disabled")