From 34c681b791492ddb4bf26c8fa53d358e85b2c8b2 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Fri, 12 Jun 2026 12:06:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(gui):=20GUI=20V6=20G4=20=E2=80=94=20aligne?= =?UTF-8?q?ment=20visuel=20sur=20la=20maquette=20v6=20(option=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Pseudonymisation_Gui_V6.py | 3 + gui_v6/app.py | 164 ++++++++++++++++--------- gui_v6/tabs/tab_about.py | 91 ++++++++------ gui_v6/tabs/tab_config.py | 204 +++++++++++++++++++------------- gui_v6/tabs/tab_usage.py | 196 +++++++++++++++++++----------- gui_v6/theme.py | 163 ++++++++++++++++--------- gui_v6/ui_kit.py | 148 +++++++++++++++++++++++ tests/unit/test_gui_v6_theme.py | 55 +++++++++ 8 files changed, 730 insertions(+), 294 deletions(-) create mode 100644 gui_v6/ui_kit.py create mode 100644 tests/unit/test_gui_v6_theme.py diff --git a/Pseudonymisation_Gui_V6.py b/Pseudonymisation_Gui_V6.py index 4d0dc5a..f4ab55a 100644 --- a/Pseudonymisation_Gui_V6.py +++ b/Pseudonymisation_Gui_V6.py @@ -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") diff --git a/gui_v6/app.py b/gui_v6/app.py index ad23946..443a5b2 100644 --- a/gui_v6/app.py +++ b/gui_v6/app.py @@ -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) diff --git a/gui_v6/tabs/tab_about.py b/gui_v6/tabs/tab_about.py index 04bc238..7903010 100644 --- a/gui_v6/tabs/tab_about.py +++ b/gui_v6/tabs/tab_about.py @@ -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: diff --git a/gui_v6/tabs/tab_config.py b/gui_v6/tabs/tab_config.py index 205f3a8..aa35a67 100644 --- a/gui_v6/tabs/tab_config.py +++ b/gui_v6/tabs/tab_config.py @@ -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)) diff --git a/gui_v6/tabs/tab_usage.py b/gui_v6/tabs/tab_usage.py index 53e68ee..3ace90b 100644 --- a/gui_v6/tabs/tab_usage.py +++ b/gui_v6/tabs/tab_usage.py @@ -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 -------------------------------------------------- diff --git a/gui_v6/theme.py b/gui_v6/theme.py index a6f71b9..1764fc0 100644 --- a/gui_v6/theme.py +++ b/gui_v6/theme.py @@ -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 diff --git a/gui_v6/ui_kit.py b/gui_v6/ui_kit.py new file mode 100644 index 0000000..65176e5 --- /dev/null +++ b/gui_v6/ui_kit.py @@ -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()) diff --git a/tests/unit/test_gui_v6_theme.py b/tests/unit/test_gui_v6_theme.py new file mode 100644 index 0000000..2aa5a93 --- /dev/null +++ b/tests/unit/test_gui_v6_theme.py @@ -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