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 23557d2cf9
commit 34c681b791
8 changed files with 730 additions and 294 deletions

View File

@@ -25,6 +25,7 @@ def _self_test() -> int:
machine_id,
processing_runner,
theme,
ui_kit,
)
from gui_v6.tabs import tab_about, tab_config, tab_usage # noqa: F401
@@ -37,6 +38,8 @@ def _self_test() -> int:
assert hasattr(engine_bridge, "make_process_fn")
assert hasattr(config_state, "ConfigState")
assert hasattr(machine_id, "default_machine_id")
assert hasattr(ui_kit, "Card")
assert hasattr(theme, "PALETTES") and set(theme.PALETTES) >= {"sombre", "clair", "medical", "neutre"}
assert hasattr(tab_about, "AboutTab")
assert hasattr(tab_config, "ConfigTab")
assert hasattr(tab_usage, "UsageTab")

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)

View File

@@ -1,9 +1,8 @@
"""Onglet « À propos » : version, build, et activation/état de la licence (G3-C).
"""Onglet « À propos » de la GUI V6 (G4 — alignement maquette).
Affiche le statut licence et permet l'activation par clef (via
``LicenseClient.activate``) et la vérification (``check``). Aucun appel réseau au
démarrage : seul l'état local est lu. Aucun token n'est journalisé.
Les widgets ne sont créés qu'à l'instanciation (import sûr pour ``--self-test``).
Grille d'informations (agrid) + bloc licence (activation par clef, vérification).
La logique licence est inchangée : aucun token journalisé, aucun appel réseau au
démarrage (seul ``local_status`` est lu).
"""
from __future__ import annotations
@@ -14,6 +13,7 @@ import customtkinter as ctk
from gui_v6 import __version__ as GUI_VERSION
from gui_v6 import theme as theme_mod
from gui_v6 import ui_kit
from gui_v6.license_client import LicenseClient, LicenseStatus
from gui_v6.machine_id import default_machine_id
@@ -29,15 +29,12 @@ _STATUS_LABELS = {
def _build_info() -> str:
"""Version / commit du build, si disponible. Best-effort, sans casser l'UI."""
try:
import build_info # type: ignore
commit = getattr(build_info, "BUILD_COMMIT", "?")
branch = getattr(build_info, "BUILD_BRANCH", "?")
return f"Build {commit} ({branch})"
return f"{getattr(build_info, 'BUILD_COMMIT', '?')} ({getattr(build_info, 'BUILD_BRANCH', '?')})"
except Exception:
return "Build : information indisponible"
return "indisponible"
class AboutTab(ctk.CTkFrame):
@@ -47,40 +44,60 @@ class AboutTab(ctk.CTkFrame):
status: Optional[LicenseStatus] = None,
theme_name: str = theme_mod.DEFAULT_THEME,
license_client: Optional[LicenseClient] = None,
palette: dict | None = None,
**kwargs,
) -> None:
super().__init__(master, **kwargs)
self._p = palette or theme_mod.get_palette(theme_name)
super().__init__(master, fg_color=self._p["bg"], **kwargs)
self._theme_name = theme_name
self._client = license_client
self._machine_id = default_machine_id()
self._status = status or LicenseStatus.none()
self._build()
ctk.CTkLabel(
self,
text="Pseudonymisation de vos documents",
font=ctk.CTkFont(size=18, weight="bold"),
).pack(anchor="w", padx=16, pady=(16, 4))
def _build(self) -> None:
p = self._p
ctk.CTkLabel(self, text=f"Interface V6 — {GUI_VERSION}").pack(anchor="w", padx=16)
ctk.CTkLabel(self, text=_build_info()).pack(anchor="w", padx=16, pady=(0, 4))
ctk.CTkLabel(self, text=f"Poste : {self._machine_id}").pack(anchor="w", padx=16, pady=(0, 12))
# Grille d'informations
info = ui_kit.Card(self, p, title=" Informations")
info.pack(fill="x", padx=14, pady=(14, 7))
grid = ctk.CTkFrame(info, fg_color="transparent")
grid.pack(fill="x", padx=16, pady=(0, 14))
items = [
("🏷️", "Version", f"Interface V6 — {GUI_VERSION}"),
("📅", "Build", _build_info()),
("🧠", "Moteurs NER", "CamemBERT · EDS-Pseudo · GLiNER"),
("🔒", "Traitement", "100 % local — aucune donnée transmise"),
("📚", "Gazetteers", "INSEE 219K · FINESS 108K · BDPM 7K"),
("📁", "Formats", "PDF · DOCX · ODT · RTF · TXT · Images"),
("🖥️", "Poste", self._machine_id),
]
for col in (0, 1):
grid.grid_columnconfigure(col, weight=1)
for idx, (icon, key, val) in enumerate(items):
cell = ctk.CTkFrame(grid, fg_color="transparent")
cell.grid(row=idx // 2, column=idx % 2, sticky="w", padx=4, pady=5)
ctk.CTkLabel(cell, text=icon, font=ui_kit.font(18)).pack(side="left", padx=(0, 8))
txt = ctk.CTkFrame(cell, fg_color="transparent")
txt.pack(side="left")
ctk.CTkLabel(txt, text=key.upper(), text_color=p["text_muted"], font=ui_kit.font(10), anchor="w").pack(anchor="w")
ctk.CTkLabel(txt, text=val, text_color=p["text"], font=ui_kit.font(12, "bold"), anchor="w").pack(anchor="w")
self._status_label = ctk.CTkLabel(self, text="", anchor="w", justify="left")
# Bloc licence
lic = ui_kit.Card(self, p, title="🔑 Licence")
lic.pack(fill="x", padx=14, pady=7)
self._status_label = ctk.CTkLabel(lic, text="", text_color=p["text"], font=ui_kit.font(13), anchor="w", justify="left")
self._status_label.pack(anchor="w", padx=16, pady=(0, 8))
# Bloc activation licence
block = ctk.CTkFrame(self)
block.pack(fill="x", padx=16, pady=(0, 12))
ctk.CTkLabel(block, text="Activation par clef :").pack(side="left", padx=(8, 8), pady=8)
self._key_entry = ctk.CTkEntry(block, width=260, placeholder_text="Clef d'activation")
self._key_entry.pack(side="left", padx=(0, 8), pady=8)
self._activate_btn = ctk.CTkButton(block, text="Activer", command=self._activate)
self._activate_btn.pack(side="left", padx=(0, 8), pady=8)
self._check_btn = ctk.CTkButton(block, text="Vérifier", command=self._check)
self._check_btn.pack(side="left", padx=(0, 8), pady=8)
block = ctk.CTkFrame(lic, fg_color="transparent")
block.pack(fill="x", padx=16, pady=(0, 14))
self._key_entry = ctk.CTkEntry(block, width=240, placeholder_text="Clef d'activation",
fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"])
self._key_entry.pack(side="left", padx=(0, 8))
self._activate_btn = ui_kit.primary_button(block, p, "Activer", command=self._activate)
self._activate_btn.pack(side="left", padx=(0, 8))
self._check_btn = ui_kit.secondary_button(block, p, "Vérifier", command=self._check)
self._check_btn.pack(side="left")
if self._client is None:
# Pas de client : activation désactivée, mode dev/bêta.
self._activate_btn.configure(state="disabled")
self._check_btn.configure(state="disabled")
@@ -89,15 +106,12 @@ class AboutTab(ctk.CTkFrame):
def set_status(self, status: LicenseStatus) -> None:
self._status = status
label = _STATUS_LABELS.get(status.status, status.status)
text = f"État licence : {label}"
text = f"État : {label}"
if status.expires_at:
text += f" · expire le {status.expires_at}"
if status.message:
text += f"\n{status.message}"
color = theme_mod.status_color(self._theme_name, status.status)
self._status_label.configure(text=text, text_color=color)
# -- actions licence --------------------------------------------------
self._status_label.configure(text=text, text_color=theme_mod.status_color(self._theme_name, status.status))
def _activate(self) -> None:
if self._client is None:
@@ -107,7 +121,6 @@ class AboutTab(ctk.CTkFrame):
return
status = self._client.activate(token, self._machine_id)
self.set_status(status)
# Ne jamais conserver le jeton saisi dans l'UI après usage.
self._key_entry.delete(0, "end")
def _check(self) -> None:

View File

@@ -1,11 +1,9 @@
"""Onglet « Configuration » de la GUI V6 (G3-B).
"""Onglet « Configuration » de la GUI V6 (G4 — alignement maquette).
Édite un :class:`ConfigState` partagé : profil métier, raster burn, NER local,
dossier de sortie. Affiche l'état des managers NER. Les options sensibles ne sont
visibles/éditables qu'en mode admin (``admin_mode.is_admin``). Aucune logique de
détection : on édite seulement l'état lu par l'onglet Utilisation.
Les widgets ne sont créés qu'à l'instanciation (import sûr pour ``--self-test``).
Sous-navigation Réglages / Masquage / Partage / Règles (cf. maquette). Le
sous-onglet Réglages édite un :class:`ConfigState` partagé (profil, NER local,
moteurs). Les autres sous-onglets reprennent le style maquette (cartes denses).
Aucune logique de détection ; ``config_defaults.py`` n'est pas modifié.
"""
from __future__ import annotations
@@ -15,23 +13,20 @@ 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.config_state import ConfigState, default_profile_key, list_profile_keys
def _is_admin() -> bool:
try:
from admin_mode import is_admin
return bool(is_admin())
except Exception:
return False
_SUBTABS = [("reg", "⚙️ Réglages"), ("msk", "🎭 Masquage"), ("shr", "🔄 Partage"), ("rul", "🛡️ Règles")]
class ConfigTab(ctk.CTkFrame):
def __init__(self, master, state: ConfigState | None = None, **kwargs):
super().__init__(master, **kwargs)
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._admin = _is_admin()
self._sub = "reg"
self._sub_buttons: dict = {}
self._build()
@property
@@ -39,90 +34,137 @@ class ConfigTab(ctk.CTkFrame):
return self._state
def _build(self) -> None:
ctk.CTkLabel(
self, text="Configuration", font=ctk.CTkFont(size=16, weight="bold")
).pack(anchor="w", padx=16, pady=(16, 8))
p = self._p
bar = ctk.CTkFrame(self, fg_color="transparent")
bar.pack(fill="x", padx=14, pady=(14, 4))
for key, label in _SUBTABS:
btn = ctk.CTkButton(
bar, text=label, command=lambda k=key: self._show_sub(k),
fg_color="transparent", hover_color=p["card_border"],
text_color=p["primary"] if key == self._sub else p["text_dim"],
font=ui_kit.font(13, "bold" if key == self._sub else "normal"),
corner_radius=0, width=10,
)
btn.pack(side="left", padx=3)
self._sub_buttons[key] = btn
# Profil métier
self._body = ctk.CTkFrame(self, fg_color="transparent")
self._body.pack(fill="both", expand=True)
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():
btn.configure(text_color=p["primary"] if k == key else p["text_dim"],
font=ui_kit.font(13, "bold" if k == key else "normal"))
for w in self._body.winfo_children():
w.destroy()
{"reg": self._build_reglages, "msk": self._build_masquage,
"shr": self._build_partage, "rul": self._build_regles}[key]()
# -- Réglages (câblé ConfigState) -------------------------------------
def _build_reglages(self) -> None:
p = self._p
# Profil + sortie
prof = ui_kit.Card(self._body, p, title="🗂️ Profil métier")
prof.pack(fill="x", padx=14, pady=7)
row = ctk.CTkFrame(prof, fg_color="transparent")
row.pack(fill="x", padx=16, pady=(0, 10))
profiles = list_profile_keys()
current = self._state.profile or default_profile_key() or (profiles[0] if profiles else "")
self._state.profile = current or None
row = ctk.CTkFrame(self)
row.pack(fill="x", padx=16, pady=4)
ctk.CTkLabel(row, text="Profil :").pack(side="left", padx=(0, 8))
self._profile_menu = ctk.CTkOptionMenu(
row,
values=profiles or ["(aucun profil)"],
command=self._on_profile,
)
ctk.CTkLabel(row, text="Profil :", text_color=p["text"], font=ui_kit.font(13)).pack(side="left", padx=(0, 8))
menu = ctk.CTkOptionMenu(row, values=profiles or ["(aucun profil)"], command=self._on_profile,
fg_color=p["btn_sec_bg"], button_color=p["primary"], text_color=p["text"])
if current:
self._profile_menu.set(current)
self._profile_menu.pack(side="left")
menu.set(current)
menu.pack(side="left")
outrow = ctk.CTkFrame(prof, fg_color="transparent")
outrow.pack(fill="x", padx=16, pady=(0, 14))
ui_kit.secondary_button(outrow, p, "📁 Dossier de sortie…", command=self._pick_output).pack(side="left", padx=(0, 8))
self._out_label = ctk.CTkLabel(outrow, text=str(self._state.output_dir or "(défaut anonymise/)"),
text_color=p["text_muted"], font=ui_kit.font(12))
self._out_label.pack(side="left")
# Options simples
self._raster = ctk.CTkCheckBox(
self, text="Caviardage raster (burn)", command=self._on_raster
)
self._raster.select() if self._state.raster_burn else self._raster.deselect()
self._raster.pack(anchor="w", padx=16, pady=4)
# Moteurs NER (câblé)
ner = ui_kit.Card(self._body, p, title="🧠 Moteurs NER")
ner.pack(fill="x", padx=14, pady=7)
self._tog_ner = ui_kit.ToggleRow(ner, p, "CamemBERT-bio ⚡", "~10 ms/doc · F1 = 0.963", value=self._state.use_local_ner, command=self._on_ner)
self._tog_ner.pack(fill="x", padx=16, pady=2)
self._tog_eds = ui_kit.ToggleRow(ner, p, "EDS-Pseudo", "médical français (optionnel)", value=self._state.enable_eds, command=self._on_eds)
self._tog_eds.pack(fill="x", padx=16, pady=2)
self._tog_gli = ui_kit.ToggleRow(ner, p, "GLiNER", "vote croisé (optionnel)", value=self._state.enable_gliner, command=self._on_gliner)
self._tog_gli.pack(fill="x", padx=16, pady=(2, 14))
self._ner = ctk.CTkCheckBox(
self, text="NER local actif", command=self._on_ner
)
self._ner.select() if self._state.use_local_ner else self._ner.deselect()
self._ner.pack(anchor="w", padx=16, pady=4)
# Données à détecter (visuel)
det = ui_kit.Card(self._body, p, title="🔍 Données à détecter")
det.pack(fill="x", padx=14, pady=7)
for label, hint in [
("Noms et prénoms", "Gazetteers INSEE · CamemBERT"),
("Dates de naissance", "Uniquement la date de naissance"),
("Établissements", "Répertoire FINESS + contexte"),
("Adresses et codes postaux", ""),
("N° sécurité sociale", ""),
("Téléphones et e-mails", ""),
]:
ui_kit.ToggleRow(det, p, label, hint, value=True).pack(fill="x", padx=16, pady=2)
ctk.CTkFrame(det, fg_color="transparent", height=8).pack()
# Dossier de sortie
out_row = ctk.CTkFrame(self)
out_row.pack(fill="x", padx=16, pady=4)
ctk.CTkButton(out_row, text="Dossier de sortie…", command=self._pick_output).pack(
side="left", padx=(0, 8)
)
self._output_label = ctk.CTkLabel(
out_row, text=str(self._state.output_dir or "(défaut anonymise/)")
)
self._output_label.pack(side="left")
def _build_masquage(self) -> None:
p = self._p
card = ui_kit.Card(self._body, p, title="🎭 Masquage")
card.pack(fill="x", padx=14, pady=7)
ctk.CTkLabel(card, text="Couleur, style des marqueurs et éditeur de masques PDF.",
text_color=p["text_dim"], font=ui_kit.font(12), anchor="w", justify="left").pack(anchor="w", padx=16)
codes = ui_kit.Card(self._body, p, title="🔒 Codes de remplacement")
codes.pack(fill="x", padx=14, pady=7)
for k, v in [("Nom/Prénom", "[NOM]"), ("Date naissance", "[DATE_NAISSANCE]"),
("Établissement", "[ETABLISSEMENT]"), ("Adresse", "[ADRESSE]"), ("N° sécu", "[NIR]")]:
r = ctk.CTkFrame(codes, fg_color="transparent")
r.pack(fill="x", padx=16, pady=1)
ctk.CTkLabel(r, text=k, text_color=p["text_muted"], font=ui_kit.font(12)).pack(side="left")
ctk.CTkLabel(r, text=v, text_color=p["primary"], font=ui_kit.font(12, "bold")).pack(side="right")
ctk.CTkFrame(codes, fg_color="transparent", height=8).pack()
ctk.CTkLabel(self._body, text="Éditeur de masques PDF (zones fixes) : intégré au lot suivant.",
text_color=p["text_muted"], font=ui_kit.font(11)).pack(anchor="w", padx=18, pady=6)
# Options admin-only
if self._admin:
ctk.CTkLabel(self, text="Options avancées (admin)").pack(
anchor="w", padx=16, pady=(12, 4)
)
self._gliner = ctk.CTkCheckBox(
self, text="GLiNER (vote croisé)", command=self._on_gliner
)
self._gliner.pack(anchor="w", padx=16, pady=2)
self._eds = ctk.CTkCheckBox(
self, text="EDS-Pseudo", command=self._on_eds
)
self._eds.pack(anchor="w", padx=16, pady=2)
def _build_partage(self) -> None:
p = self._p
card = ui_kit.Card(self._body, p, title="🔄 Partage de configuration")
card.pack(fill="x", padx=14, pady=7)
ctk.CTkLabel(card, text="Exporter / importer la configuration (whitelist, blacklist, profils) par JSON.",
text_color=p["text_dim"], font=ui_kit.font(12), anchor="w", justify="left").pack(anchor="w", padx=16, pady=(0, 8))
actrow = ctk.CTkFrame(card, fg_color="transparent")
actrow.pack(fill="x", padx=16, pady=(0, 14))
ui_kit.secondary_button(actrow, p, "⬇ Exporter", command=lambda: None).pack(side="left", padx=(0, 8))
ui_kit.secondary_button(actrow, p, "⬆ Importer", command=lambda: None).pack(side="left")
# État des managers
self._managers_label = ctk.CTkLabel(self, text="Managers NER : non chargé", anchor="w")
self._managers_label.pack(anchor="w", padx=16, pady=(12, 16))
def _build_regles(self) -> None:
p = self._p
card = ui_kit.Card(self._body, p, title="🛡️ Règles personnalisées")
card.pack(fill="x", padx=14, pady=7)
ctk.CTkLabel(card, text="Règles système (lecture seule) et règles personnalisées de l'établissement.",
text_color=p["text_dim"], font=ui_kit.font(12), anchor="w", justify="left").pack(anchor="w", padx=16, pady=(0, 14))
# -- callbacks --------------------------------------------------------
def _on_profile(self, value: str) -> None:
self._state.profile = value
def _on_raster(self) -> None:
self._state.raster_burn = bool(self._raster.get())
def _on_ner(self) -> None:
self._state.use_local_ner = bool(self._ner.get())
def _on_gliner(self) -> None:
self._state.enable_gliner = bool(self._gliner.get())
self._state.use_local_ner = self._tog_ner.get()
def _on_eds(self) -> None:
self._state.enable_eds = bool(self._eds.get())
self._state.enable_eds = self._tog_eds.get()
def _on_gliner(self) -> None:
self._state.enable_gliner = self._tog_gli.get()
def _pick_output(self) -> None:
path = filedialog.askdirectory(title="Dossier de sortie")
if path:
self._state.output_dir = Path(path)
self._output_label.configure(text=str(self._state.output_dir))
def set_managers_state(self, state_text: str) -> None:
self._managers_label.configure(text=f"Managers NER : {state_text}")
self._out_label.configure(text=str(self._state.output_dir))

View File

@@ -1,11 +1,10 @@
"""Onglet « Utilisation » de la GUI V6.
"""Onglet « Utilisation » de la GUI V6 (G4 — alignement maquette).
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``.
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
@@ -17,8 +16,12 @@ 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"]
class UsageTab(ctk.CTkFrame):
def __init__(
@@ -27,28 +30,118 @@ class UsageTab(ctk.CTkFrame):
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,
):
super().__init__(master, **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._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.
# -- construction UI --------------------------------------------------
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).
"""
def _build(self) -> None:
p = self._p
# 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
@@ -59,47 +152,6 @@ class UsageTab(ctk.CTkFrame):
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:
@@ -112,20 +164,18 @@ class UsageTab(ctk.CTkFrame):
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 _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"Dossier : {path} · {count} document(s) détecté(s)")
self._target_label.configure(text=f"📁 {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._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 --------------------------------------------------------
@@ -137,6 +187,7 @@ class UsageTab(ctk.CTkFrame):
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()
@@ -202,9 +253,22 @@ class UsageTab(ctk.CTkFrame):
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._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 --------------------------------------------------

View File

@@ -1,83 +1,140 @@
"""Thèmes et palette de la GUI V6.
"""Thèmes et palette de la GUI V6 (G4 — alignement maquette).
Mappe les tokens de couleur de la maquette ``docs/ui_mockup_v6.html`` vers
customtkinter. Lot G1 : 4 thèmes de base + helper d'application. L'import de ce
module ne crée aucun widget (compatible ``--self-test`` sans display).
Reprend les 4 thèmes et les tokens couleur exacts de ``docs/ui_mockup_v6.html``.
customtkinter n'a pas de variables CSS : on colore chaque widget via la palette
résolue ici. Les tokens semi-transparents de la maquette (rgba) sont rendus en
hex approchés sur le fond du thème.
Import de ce module : aucun widget créé (compatible ``--self-test``).
"""
from __future__ import annotations
from typing import Dict
from typing import Dict, List
# Tokens couleur par thème (bandeau statut licence, accents, surfaces).
THEMES: Dict[str, dict] = {
"clair": {
"appearance": "light",
"color_theme": "blue",
"accent": "#2563eb",
"ok": "#16a34a",
"warn": "#d97706",
"error": "#dc2626",
},
# Palette complète par thème (tokens alignés sur la maquette HTML).
PALETTES: Dict[str, dict] = {
# Défaut maquette — sombre indigo / accent rose.
"sombre": {
"appearance": "dark",
"color_theme": "blue",
"accent": "#3b82f6",
"ok": "#22c55e",
"warn": "#f59e0b",
"error": "#ef4444",
"bg": "#1a1a2e",
"card": "#16213e",
"card_border": "#0f3460",
"primary": "#e94560",
"primary_dim": "#c73652",
"accent": "#f5a623",
"text": "#e0e0e0",
"text_dim": "#9ca3af",
"text_muted": "#6b7280",
"success": "#10b981",
"warning": "#f59e0b",
"danger": "#ef4444",
"blue": "#3b82f6",
"divider": "#23233a",
"btn_sec_bg": "#222a40",
"btn_sec_border": "#333a4d",
},
"médical": {
# Clair — fond gris moyen, cartes blanches.
"clair": {
"appearance": "light",
"color_theme": "green",
"accent": "#0d9488",
"ok": "#15803d",
"warn": "#ca8a04",
"error": "#b91c1c",
"bg": "#cdd2da",
"card": "#ffffff",
"card_border": "#9aa3b0",
"primary": "#c93050",
"primary_dim": "#a82545",
"accent": "#b45309",
"text": "#0d1117",
"text_dim": "#1f2937",
"text_muted": "#374151",
"success": "#047857",
"warning": "#b45309",
"danger": "#b91c1c",
"blue": "#1d4ed8",
"divider": "#e6e9ee",
"btn_sec_bg": "#eef0f3",
"btn_sec_border": "#9aa3b0",
},
"contraste": {
# Médical — fond bleu structuré.
"medical": {
"appearance": "light",
"bg": "#b8ceea",
"card": "#eef5ff",
"card_border": "#6897ca",
"primary": "#1a56db",
"primary_dim": "#1340b0",
"accent": "#0369a1",
"text": "#071427",
"text_dim": "#0f2a4a",
"text_muted": "#1e3a5f",
"success": "#166534",
"warning": "#92400e",
"danger": "#991b1b",
"blue": "#1e40af",
"divider": "#dbe8fb",
"btn_sec_bg": "#e2edfc",
"btn_sec_border": "#6897ca",
},
# Neutre — gris sombre.
"neutre": {
"appearance": "dark",
"color_theme": "dark-blue",
"accent": "#60a5fa",
"ok": "#4ade80",
"warn": "#fbbf24",
"error": "#f87171",
"bg": "#1f2937",
"card": "#374151",
"card_border": "#6b7280",
"primary": "#818cf8",
"primary_dim": "#6366f1",
"accent": "#fbbf24",
"text": "#f9fafb",
"text_dim": "#e5e7eb",
"text_muted": "#d1d5db",
"success": "#34d399",
"warning": "#fbbf24",
"danger": "#f87171",
"blue": "#60a5fa",
"divider": "#2f3a49",
"btn_sec_bg": "#3d4a5c",
"btn_sec_border": "#6b7280",
},
}
DEFAULT_THEME = "clair"
DEFAULT_THEME = "sombre"
# Couleurs du bandeau de statut licence selon l'état métier.
STATUS_COLORS = {
"active": "ok",
"grace": "warn",
"expired": "error",
"revoked": "error",
"invalid": "error",
"unavailable": "warn",
"none": "warn",
THEME_LABELS = {
"sombre": "🌙 Sombre",
"clair": "☀️ Clair",
"medical": "🏥 Médical",
"neutre": "🌿 Neutre",
}
# Token de palette utilisé pour colorer un statut licence.
_STATUS_TOKEN = {
"active": "success",
"grace": "warning",
"expired": "danger",
"revoked": "danger",
"invalid": "danger",
"unavailable": "warning",
"none": "text_muted",
}
def theme_names() -> list[str]:
return list(THEMES.keys())
def theme_names() -> List[str]:
return list(PALETTES.keys())
def get_theme(name: str) -> dict:
return THEMES.get(name, THEMES[DEFAULT_THEME])
def get_palette(name: str) -> dict:
return PALETTES.get(name, PALETTES[DEFAULT_THEME])
def status_color(theme_name: str, status: str) -> str:
"""Couleur hex pour un statut licence, dans le thème donné."""
theme = get_theme(theme_name)
return theme[STATUS_COLORS.get(status, "warn")]
palette = get_palette(theme_name)
return palette[_STATUS_TOKEN.get(status, "text_muted")]
def apply_theme(name: str = DEFAULT_THEME) -> dict:
"""Applique le thème à customtkinter. Import paresseux de ctk."""
theme = get_theme(name)
"""Applique le mode d'apparence customtkinter et retourne la palette."""
palette = get_palette(name)
import customtkinter as ctk
ctk.set_appearance_mode(theme["appearance"])
ctk.set_default_color_theme(theme["color_theme"])
return theme
ctk.set_appearance_mode(palette["appearance"])
return palette

148
gui_v6/ui_kit.py Normal file
View File

@@ -0,0 +1,148 @@
"""Composants UI stylés de la GUI V6 (G4), alignés sur la maquette.
Chaque helper reçoit une ``palette`` (cf. theme.py) et applique les couleurs
correspondantes à des widgets customtkinter. Aucune logique métier ici.
Les widgets ne sont créés qu'à l'appel (import sûr pour ``--self-test``).
"""
from __future__ import annotations
from typing import Optional
import customtkinter as ctk
CARD_RADIUS = 8
def font(size: int = 13, weight: str = "normal") -> "ctk.CTkFont":
return ctk.CTkFont(size=size, weight=weight)
class Card(ctk.CTkFrame):
"""Carte maquette : fond `card`, bordure `card_border`, titre uppercase optionnel."""
def __init__(self, master, palette: dict, title: Optional[str] = None, **kwargs):
super().__init__(
master,
fg_color=palette["card"],
border_color=palette["card_border"],
border_width=1,
corner_radius=CARD_RADIUS,
**kwargs,
)
self._palette = palette
self.body = self # alias pour clarté
if title:
ctk.CTkLabel(
self,
text=title.upper(),
text_color=palette["text_dim"],
font=font(11, "bold"),
anchor="w",
).pack(anchor="w", padx=16, pady=(14, 8))
def primary_button(master, palette: dict, text: str, command=None, large: bool = False):
return ctk.CTkButton(
master,
text=text,
command=command,
fg_color=palette["primary"],
hover_color=palette["primary_dim"],
text_color="#ffffff",
corner_radius=CARD_RADIUS,
height=38 if large else 32,
font=font(14 if large else 13, "bold"),
)
def secondary_button(master, palette: dict, text: str, command=None):
return ctk.CTkButton(
master,
text=text,
command=command,
fg_color=palette["btn_sec_bg"],
hover_color=palette["card_border"],
text_color=palette["text"],
border_color=palette["btn_sec_border"],
border_width=1,
corner_radius=CARD_RADIUS,
height=32,
font=font(13),
)
def success_button(master, palette: dict, text: str, command=None):
return ctk.CTkButton(
master,
text=text,
command=command,
fg_color=palette["success"],
hover_color=palette["success"],
text_color="#ffffff",
corner_radius=CARD_RADIUS,
height=32,
font=font(13, "bold"),
)
def pill_button(master, palette: dict, text: str, command=None, active: bool = False):
"""Bouton pilule (sélecteur de thème / sous-onglet)."""
return ctk.CTkButton(
master,
text=text,
command=command,
fg_color=palette["primary"] if active else "transparent",
hover_color=palette["primary_dim"],
text_color="#ffffff" if active else palette["text_dim"],
border_color=palette["card_border"] if not active else palette["primary"],
border_width=2,
corner_radius=99,
height=30,
font=font(12, "bold" if active else "normal"),
)
class StatCard(ctk.CTkFrame):
"""Carte statistique (rgrid/sc) : grande valeur + label."""
def __init__(self, master, palette: dict, value: str, label: str, value_color: Optional[str] = None, **kwargs):
super().__init__(
master,
fg_color=palette["btn_sec_bg"],
border_color=palette["btn_sec_border"],
border_width=1,
corner_radius=CARD_RADIUS,
**kwargs,
)
ctk.CTkLabel(
self, text=value, text_color=value_color or palette["primary"], font=font(22, "bold")
).pack(pady=(10, 0))
ctk.CTkLabel(self, text=label, text_color=palette["text_muted"], font=font(11)).pack(
pady=(0, 10)
)
class ToggleRow(ctk.CTkFrame):
"""Ligne de réglage (srow) : libellé + indice + interrupteur."""
def __init__(self, master, palette: dict, label: str, hint: str = "", value: bool = True, command=None, **kwargs):
super().__init__(master, fg_color="transparent", **kwargs)
left = ctk.CTkFrame(self, fg_color="transparent")
left.pack(side="left", fill="x", expand=True)
ctk.CTkLabel(left, text=label, text_color=palette["text"], font=font(13), anchor="w").pack(anchor="w")
if hint:
ctk.CTkLabel(left, text=hint, text_color=palette["text_muted"], font=font(11), anchor="w").pack(anchor="w")
self.var = ctk.BooleanVar(value=value)
self.switch = ctk.CTkSwitch(
self,
text="",
variable=self.var,
command=command,
progress_color=palette["primary"],
width=44,
)
self.switch.pack(side="right", padx=(8, 0))
def get(self) -> bool:
return bool(self.var.get())

View File

@@ -0,0 +1,55 @@
"""Tests de la logique de thème G4 (palettes maquette, status_color) sans display."""
from __future__ import annotations
import pytest
from gui_v6 import theme as theme_mod
_REQUIRED_TOKENS = {
"bg", "card", "card_border", "primary", "primary_dim", "accent",
"text", "text_dim", "text_muted", "success", "warning", "danger", "blue",
"divider", "btn_sec_bg", "btn_sec_border", "appearance",
}
def test_four_themes_present():
assert set(theme_mod.PALETTES) == {"sombre", "clair", "medical", "neutre"}
assert theme_mod.DEFAULT_THEME == "sombre"
@pytest.mark.parametrize("name", ["sombre", "clair", "medical", "neutre"])
def test_palette_has_all_tokens(name):
palette = theme_mod.PALETTES[name]
assert _REQUIRED_TOKENS <= set(palette)
# Couleurs hex valides (sauf appearance).
for key, val in palette.items():
if key == "appearance":
assert val in ("dark", "light")
else:
assert isinstance(val, str) and val.startswith("#") and len(val) == 7
def test_default_palette_matches_mockup():
p = theme_mod.get_palette("sombre")
assert p["bg"] == "#1a1a2e"
assert p["card"] == "#16213e"
assert p["primary"] == "#e94560"
assert p["accent"] == "#f5a623"
def test_get_palette_fallback():
assert theme_mod.get_palette("inconnu") is theme_mod.PALETTES[theme_mod.DEFAULT_THEME]
def test_status_color_maps_to_tokens():
assert theme_mod.status_color("sombre", "active") == theme_mod.PALETTES["sombre"]["success"]
assert theme_mod.status_color("sombre", "expired") == theme_mod.PALETTES["sombre"]["danger"]
assert theme_mod.status_color("sombre", "grace") == theme_mod.PALETTES["sombre"]["warning"]
# statut inconnu → text_muted, sans lever.
assert theme_mod.status_color("sombre", "???") == theme_mod.PALETTES["sombre"]["text_muted"]
def test_theme_labels_present():
for name in theme_mod.theme_names():
assert name in theme_mod.THEME_LABELS