From 2f5c428545e36fc597430acb3bbfcb47f7ba3567 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 18 Sep 2026 10:54:03 +0200 Subject: [PATCH] =?UTF-8?q?fix(tray):=20architecture=20hybride=20pystray?= =?UTF-8?q?=20(thread)=20+=20Tk.mainloop()=20(thread=20principal)=20?= =?UTF-8?q?=E2=80=94=20les=20callbacks=20dispatch=20via=20root.after()=20p?= =?UTF-8?q?our=20que=20Tk=20ait=20sa=20boucle=20d'=C3=A9v=C3=A9nements=20;?= =?UTF-8?q?=20fen=C3=AAtres=20peintes=20et=20interactives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai_typewriter/app.py | 8 ++-- src/ai_typewriter/tray.py | 85 +++++++++++++++++++++------------------ 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/src/ai_typewriter/app.py b/src/ai_typewriter/app.py index 43028dc..22c5a3c 100644 --- a/src/ai_typewriter/app.py +++ b/src/ai_typewriter/app.py @@ -71,12 +71,14 @@ def run_tray(store: ConfigStore) -> int: from .tray import TrayApp app = TrayApp(store) + root = app.get_root() # force la création de la racine Tk (cachée) + app.start() # lance pystray dans un thread d'arrière-plan + LOG.info("Entrée dans la boucle Tk principale.") try: - # start() bloque (Icon.run() sur le thread principal) et retourne - # quand l'utilisateur clique sur « Quitter ». - app.start() + root.mainloop() # bloque sur le thread principal ; traite les événements Tk except KeyboardInterrupt: app.stop() + LOG.info("Boucle Tk terminée.") return 0 diff --git a/src/ai_typewriter/tray.py b/src/ai_typewriter/tray.py index 315c44e..acbd17b 100644 --- a/src/ai_typewriter/tray.py +++ b/src/ai_typewriter/tray.py @@ -8,8 +8,11 @@ seule une icône dans la zone de notification. Le menu de l'icône permet : - Gérer l'authentification (clés d'API sécurisées) - Quitter -La fenêtre racine Tk est créée de manière invisible et sert uniquement de -référence pour les dialogues ; l'icône est pilotée par pystray. +Architecture hybride pystray + Tkinter : + * pystray tourne dans un thread d'arrière-plan (fonctionne sous Windows). + * Tk.mainloop() bloque le thread principal. + * Les callback de pystray s'exécutent sur le thread d'arrière-plan et + délèguent les opérations Tk au thread principal via root.after(). """ from __future__ import annotations @@ -48,7 +51,6 @@ class TrayApp: self._icon_thread: threading.Thread | None = None self._icon_factory = icon_factory self._menu_factory = menu_factory - self._exit_event = threading.Event() # -- fenêtre racine cachée (pour les dialogues) -------------------------- @@ -59,12 +61,19 @@ class TrayApp: self._root.withdraw() return self._root + # -- dispatch vers le thread principal Tk -------------------------------- + + def _defer_tk(self, fn: Callable) -> None: + """Exécute `fn` sur le thread principal via root.after(0, …).""" + root = self.get_root() + root.after(0, fn) + # -- actions du menu ------------------------------------------------------- - def show_logs(self) -> None: + def _show_logs(self) -> None: LogsWindow(self.get_root()) - def edit_profile(self, name: str | None = None) -> None: + def _edit_profile(self, name: str | None = None) -> None: """Ouvre l'éditeur du profil `name`, ou le profil actif si `None`.""" store = self.store target = store.get(name) if name else store.active() @@ -77,7 +86,7 @@ class TrayApp: except Exception as exc: LOG.exception("Impossible d'enregistrer le profil : %s", exc) - def add_profile(self) -> None: + def _add_profile(self) -> None: dlg = ProfileDialog(self.get_root(), existing=None, secure=self.secure) self.get_root().wait_window(dlg) if dlg.result: @@ -87,15 +96,16 @@ class TrayApp: except Exception as exc: LOG.exception("Impossible d'ajouter le profil : %s", exc) - def set_active(self, name: str) -> None: + def _set_active(self, name: str) -> None: try: self.store.set_active(name) LOG.info("Profil actif : %s", name) except Exception as exc: LOG.exception("Impossible de sélectionner le profil : %s", exc) - def manage_auth(self) -> None: - AuthDialog(self.get_root(), self.secure) + def _manage_auth(self) -> None: + dlg = AuthDialog(self.get_root(), self.secure) + self.get_root().wait_window(dlg) # -- construction de l'icône ---------------------------------------------- @@ -111,10 +121,10 @@ class TrayApp: menu_items = [] menu_items.append( - self._menu_item("Ouvrir les logs", self._guard("Ouvrir les logs", self.show_logs)) + self._menu_item("Ouvrir les logs", self._guard("Ouvrir les logs", self._show_logs)) ) menu_items.append( - self._menu_item("Ajouter un profil", self._guard("Ajouter un profil", self.add_profile)) + self._menu_item("Ajouter un profil", self._guard("Ajouter un profil", self._add_profile)) ) # Sous-menu des profils profiles_sub = pystray.Menu( @@ -135,7 +145,7 @@ class TrayApp: menu_items.append( self._menu_item( "Gérer l'authentification", - self._guard("Gérer l'authentification", self.manage_auth), + self._guard("Gérer l'authentification", self._manage_auth), ) ) menu_items.append(pystray.Menu.SEPARATOR) @@ -160,41 +170,41 @@ class TrayApp: return pystray.MenuItem(text, action) def _guard(self, label: str, action: Callable) -> Callable: - """Enveloppe une action de menu (sans argument requis par l'appelant). + """Enveloppe une action de menu. - pystray invoque les actions de menu avec ``(icon, item)`` et examine - le nombre de paramètres : un handler à exactement 2 params est appelé - tel quel (aucun reshufflage d'arguments). Ce wrapper journalise le - déclenchement et capture toute exception avec traceback pour rendre - chaque clic traçable. + pystray invoque les actions avec ``(icon, item)`` et utilise la + signature pour adapter les arguments. Un handler à 2 params est + appelé tel quel, sans reshufflage. + + Cette enveloppe journalise chaque clic, délègue la vraie action au + thread Tk principal via ``after()``, et capture toute exception avec + traceback. """ def handler(icon, item) -> None: LOG.debug("Clic menu → %s", label) try: - action() + self._defer_tk(action) except Exception: - LOG.exception("Erreur dans l'action de menu : %s", label) + LOG.exception("Impossible de déférer l'action Tk : %s", label) return handler def _select_profile_action(self, profile) -> Callable: def action() -> None: LOG.debug("Sélection du profil « %s » demandée", profile.name) - self.set_active(profile.name) + self._set_active(profile.name) return action # -- cycle de vie ----------------------------------------------------------- def start(self) -> None: - """Attache le raccourci global puis lance l'icône — BLoquant, sur le - thread principal. + """Attache le raccourci global puis lance l'icône dans un thread + d'arrière-plan. - pystray exige que ``Icon.run()`` soit appelé depuis le thread principal - ; les callbacks de menu s'exécutent alors sur ce thread, ce qui est - indispensable pour créer des fenêtres Tk. Retourne quand « Quitter » - est demandé. + La boucle pystray tourne dans un thread daemon (fonctionne sous + Windows). Le thread principal doit lancer Tk.mainloop() de son côté. """ hotkey = self.store.hotkey try: @@ -205,24 +215,19 @@ class TrayApp: icon = self._icon if self._icon is not None else self.build_icon() self._icon = icon - LOG.info("Application lancée en arrière-plan (icône zone de notification).") - icon.run() # bloque sur le thread principal - LOG.info("Sortie : la boucle de l'icône s'est terminée.") + self._icon_thread = threading.Thread(target=icon.run, daemon=True) + self._icon_thread.start() + LOG.info("Application lancée en arrière-plan (icône zone de notification) [pystray en thread].") def stop(self, icon=None, item=None) -> None: LOG.info("Arrêt de l'application.") - if self._root is not None: - try: - self._root.destroy() - except tk.TclError: - pass if self._icon is not None: try: self._icon.stop() except Exception: pass - self._exit_event.set() - - def wait(self) -> None: - """Blocage jusqu'à ce que l'application soit arrêtée (élément « Quitter »).""" - self._exit_event.wait() \ No newline at end of file + if self._root is not None: + try: + self._root.quit() # débloque Tk.mainloop() + except tk.TclError: + pass \ No newline at end of file