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>
310 lines
14 KiB
Python
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")
|