"""Onglet « Utilisation » de la GUI V6. Sélection fichier/dossier → choix sortie → lancement via le runner (dans un thread) → progression / journal / résumé. Aucun appel réseau au démarrage, aucune logique de détection : tout passe par :class:`ProcessingRunner`. Les widgets ne sont créés qu'à l'instanciation (import sûr pour ``--self-test``). La communication thread worker → UI passe par une file drainée via ``after``. """ from __future__ import annotations import queue import threading from pathlib import Path from tkinter import filedialog import customtkinter as ctk from gui_v6.processing_runner import ProcessingRunner, default_output_dir class UsageTab(ctk.CTkFrame): def __init__( self, master, runner: ProcessingRunner | None = None, config_provider=None, config_path: Path | None = None, **kwargs, ): super().__init__(master, **kwargs) self._runner = runner or ProcessingRunner() self._config_provider = config_provider self._config_path = config_path self._input_path: Path | None = None self._output_dir: Path | None = None self._stop_event: threading.Event | None = None self._worker: threading.Thread | None = None self._is_running = False self._events: "queue.Queue[tuple]" = queue.Queue() self._build() self.after(120, self._drain_events) def _build_run_runner(self) -> tuple[ProcessingRunner, Path | None]: """Runner d'exécution + dossier sortie selon la configuration courante. Si une configuration est fournie, câble le moteur réel via engine_bridge ; sinon retombe sur le runner par défaut (process_document direct). """ 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 # -- construction UI -------------------------------------------------- def _build(self) -> None: bar = ctk.CTkFrame(self) bar.pack(fill="x", padx=12, pady=(12, 6)) ctk.CTkButton(bar, text="Choisir un fichier…", command=self._pick_file).pack( side="left", padx=(0, 8) ) ctk.CTkButton(bar, text="Choisir un dossier…", command=self._pick_folder).pack( side="left" ) self._target_label = ctk.CTkLabel(self, text="Aucune source sélectionnée", anchor="w") self._target_label.pack(fill="x", padx=12, pady=(0, 4)) out_bar = ctk.CTkFrame(self) out_bar.pack(fill="x", padx=12, pady=(0, 6)) ctk.CTkButton(out_bar, text="Dossier de sortie…", command=self._pick_output).pack( side="left", padx=(0, 8) ) self._output_label = ctk.CTkLabel(out_bar, text="Sortie : (défaut anonymise/)", anchor="w") self._output_label.pack(side="left") action = ctk.CTkFrame(self) action.pack(fill="x", padx=12, pady=(0, 6)) self._run_btn = ctk.CTkButton(action, text="Lancer", command=self._start, state="disabled") self._run_btn.pack(side="left", padx=(0, 8)) self._stop_btn = ctk.CTkButton(action, text="Arrêter", command=self._request_stop, state="disabled") self._stop_btn.pack(side="left") self._progress = ctk.CTkProgressBar(self) self._progress.set(0.0) self._progress.pack(fill="x", padx=12, pady=(6, 4)) self._status_label = ctk.CTkLabel(self, text="Prêt.", anchor="w") self._status_label.pack(fill="x", padx=12) self._log = ctk.CTkTextbox(self, height=180) self._log.pack(fill="both", expand=True, padx=12, pady=(6, 12)) self._log.configure(state="disabled") # -- 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 _pick_output(self) -> None: path = filedialog.askdirectory(title="Dossier de sortie") if path: self._output_dir = Path(path) self._output_label.configure(text=f"Sortie : {self._output_dir}") 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"Dossier : {path} · {count} document(s) détecté(s)") else: self._target_label.configure(text=f"Fichier : {path.name}") self._output_label.configure(text=f"Sortie : (défaut {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._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}." ) # -- 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")