fix(tray): architecture hybride pystray (thread) + Tk.mainloop() (thread principal) — les callbacks dispatch via root.after() pour que Tk ait sa boucle d'événements ; fenêtres peintes et interactives
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
+45
-40
@@ -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()
|
||||
if self._root is not None:
|
||||
try:
|
||||
self._root.quit() # débloque Tk.mainloop()
|
||||
except tk.TclError:
|
||||
pass
|
||||
Reference in New Issue
Block a user