feat(gui): GUI V6 G4 — alignement visuel sur la maquette v6 (option A)

Refonte de la couche présentation pour reprendre docs/ui_mockup_v6.html, sans
changer de techno UI ni la logique G1-G3.

- theme.py : 4 thèmes aux tokens EXACTS de la maquette (sombre #1a1a2e/#16213e/
  #e94560, clair, médical, neutre), palette complète + status_color.
- ui_kit.py (nouveau) : composants stylés (Card titrée, boutons primary/secondary/
  success/pilule, StatCard, ToggleRow) appliquant la palette.
- app.py : shell étroit, header identité + version + statut licence + liseré accent,
  barre d'onglets custom (plus de CTkTabview brut), navigation par recréation,
  changement de thème à chaud.
- tab_usage : carte Apparence (sélecteur de thème), dropzone stylée, grille formats,
  barre d'actions, progression à étapes + journal, résultats en cartes statistiques.
- tab_config : sous-navigation Réglages/Masquage/Partage/Règles ; Réglages câblé au
  ConfigState (profil, moteurs NER, dossier sortie).
- tab_about : grille d'informations + bloc licence (logique inchangée).

Logique inchangée : engine_bridge, config_state, license_client/store, runner.
Tests : +9 (theme). self-test exit 0, 55 tests gui_v6, 202 tests/unit (0 régression).
Smoke construction headless (Xvfb) : 3 onglets × 4 thèmes rendus sans erreur.
Pas de pywebview, aucun .exe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:06:05 +02:00
parent 9575714ae2
commit 1bced55b81
8 changed files with 730 additions and 294 deletions

View File

@@ -1,11 +1,11 @@
"""Shell minimal de la GUI V6 (lot G1).
"""Shell de la GUI V6 (G4 — alignement maquette).
Header + bandeau de statut licence + navigation 3 onglets
(Utilisation, Configuration, À propos). Seul « À propos » est étoffé en G1 ;
les deux autres sont des placeholders qui seront remplis en G2/G3.
Reproduit l'identité de ``docs/ui_mockup_v6.html`` : shell étroit, header avec
identité produit + version + statut licence + liseré accent, barre d'onglets
custom (pas CTkTabview brut), navigation par recréation du contenu, changement
de thème à chaud. La logique (runner moteur, config, licence) est inchangée.
Aucune logique de détection ici : ce module orchestre uniquement. La fenêtre
n'est créée qu'à l'instanciation de :class:`AnonymisationApp` (import sûr).
La fenêtre n'est créée qu'à l'instanciation de :class:`AnonymisationApp`.
"""
from __future__ import annotations
@@ -15,18 +15,21 @@ from typing import Optional
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
from gui_v6.license_client import LicenseClient, LicenseStatus
from gui_v6.tabs.tab_about import AboutTab
from gui_v6.tabs.tab_config import ConfigTab
from gui_v6.tabs.tab_usage import UsageTab
_TABS = ("Utilisation", "Configuration", "À propos")
_TABS = [
("use", "📄 Utilisation"),
("cfg", "⚙️ Configuration"),
("about", " À propos"),
]
class AnonymisationApp(ctk.CTk):
"""Fenêtre principale (socle G1)."""
def __init__(
self,
license_client: Optional[LicenseClient] = None,
@@ -34,71 +37,122 @@ class AnonymisationApp(ctk.CTk):
) -> None:
super().__init__()
self._theme_name = theme_name
theme_mod.apply_theme(theme_name)
# Client licence : par défaut, lecture du statut local uniquement
# (aucun appel réseau au démarrage). Injectable pour les tests.
self._license_client = license_client or LicenseClient("http://localhost")
status = self._safe_local_status()
# État de configuration partagé entre Configuration et Utilisation.
self._config = ConfigState()
self._active = "use"
self._tab_buttons: dict = {}
self.title("Pseudonymisation de vos documents")
self.geometry("960x640")
self.geometry("820x880")
self.minsize(720, 680)
self._render()
self._build_header(status)
self._build_tabs(status)
# -- thème / rendu ----------------------------------------------------
# -- statut licence ---------------------------------------------------
def set_theme(self, name: str) -> None:
self._theme_name = name
self._render()
def _render(self) -> None:
self._palette = theme_mod.apply_theme(self._theme_name)
p = self._palette
try:
self.configure(fg_color=p["bg"])
except Exception:
pass
for child in self.winfo_children():
child.destroy()
self._build_header(p)
self._build_tabsbar(p)
self._content = ctk.CTkScrollableFrame(self, fg_color=p["bg"])
self._content.pack(fill="both", expand=True)
self._show(self._active)
# -- header -----------------------------------------------------------
def _safe_local_status(self) -> LicenseStatus:
try:
return self._license_client.local_status()
except Exception:
# Licence indisponible → dégradation silencieuse (mode bêta).
return LicenseStatus.unavailable()
# -- construction UI --------------------------------------------------
def _build_header(self, status: LicenseStatus) -> None:
header = ctk.CTkFrame(self, height=56)
header.pack(fill="x", padx=12, pady=(12, 6))
def _build_header(self, p: dict) -> None:
header = ctk.CTkFrame(self, fg_color=p["card"], corner_radius=0)
header.pack(fill="x")
ctk.CTkLabel(
header, text="🛡️ aivanonym", text_color=p["text"], font=ui_kit.font(18, "bold")
).pack(side="left", padx=16, pady=10)
status = self._safe_local_status()
ctk.CTkLabel(
header,
text="Pseudonymisation",
font=ctk.CTkFont(size=16, weight="bold"),
).pack(side="left", padx=12, pady=10)
text=f"licence : {status.status}",
text_color=theme_mod.status_color(self._theme_name, status.status),
font=ui_kit.font(11),
).pack(side="right", padx=(8, 16))
ctk.CTkLabel(
header, text="v6.0", text_color=p["text_muted"], font=ui_kit.font(11)
).pack(side="right", padx=4)
color = theme_mod.status_color(self._theme_name, status.status)
self._status_banner = ctk.CTkLabel(
header, text=self._banner_text(status), text_color=color
)
self._status_banner.pack(side="right", padx=12, pady=10)
# Liseré accent sous le header (border-bottom 3px primary).
ctk.CTkFrame(self, fg_color=p["primary"], height=3, corner_radius=0).pack(fill="x")
def _build_tabs(self, status: LicenseStatus) -> None:
tabview = ctk.CTkTabview(self)
tabview.pack(fill="both", expand=True, padx=12, pady=(6, 12))
for name in _TABS:
tabview.add(name)
# -- barre d'onglets --------------------------------------------------
self._config_tab = ConfigTab(tabview.tab("Configuration"), state=self._config)
self._config_tab.pack(fill="both", expand=True)
def _build_tabsbar(self, p: dict) -> None:
bar = ctk.CTkFrame(self, fg_color=p["card"], corner_radius=0)
bar.pack(fill="x")
self._tab_buttons = {}
for key, label in _TABS:
active = key == self._active
btn = ctk.CTkButton(
bar,
text=label,
command=lambda k=key: self._show(k),
fg_color="transparent",
hover_color=p["card_border"],
text_color=p["primary"] if active else p["text_dim"],
font=ui_kit.font(13, "bold" if active else "normal"),
corner_radius=0,
width=10,
)
btn.pack(side="left", padx=4, pady=4)
self._tab_buttons[key] = btn
self._usage = UsageTab(
tabview.tab("Utilisation"), config_provider=lambda: self._config
)
self._usage.pack(fill="both", expand=True)
def _refresh_tabbar(self) -> None:
p = self._palette
for key, btn in self._tab_buttons.items():
active = key == self._active
btn.configure(
text_color=p["primary"] if active else p["text_dim"],
font=ui_kit.font(13, "bold" if active else "normal"),
)
self._about = AboutTab(
tabview.tab("À propos"),
status=status,
theme_name=self._theme_name,
license_client=self._license_client,
)
self._about.pack(fill="both", expand=True)
# -- contenu ----------------------------------------------------------
@staticmethod
def _banner_text(status: LicenseStatus) -> str:
return f"Licence : {status.status}"
def _show(self, key: str) -> None:
self._active = key
self._refresh_tabbar()
for child in self._content.winfo_children():
child.destroy()
p = self._palette
status = self._safe_local_status()
if key == "use":
tab = UsageTab(
self._content,
palette=p,
config_provider=lambda: self._config,
on_theme_change=self.set_theme,
current_theme=self._theme_name,
)
elif key == "cfg":
tab = ConfigTab(self._content, palette=p, state=self._config)
else:
tab = AboutTab(
self._content,
palette=p,
status=status,
theme_name=self._theme_name,
license_client=self._license_client,
)
tab.pack(fill="both", expand=True)