refactor: un thread par fenêtre Tk, main thread dédié au hotkey

- Nouveau helper ui/_tk_spawn.py : chaque fenêtre Tk dans son propre thread
  avec sa propre racine tk.Tk (plus de racine partagée)
- app.py : thread principal pour le hotkey + attente stop_event,
  thread séparé pour l'icône pystray
- tray.py : suppression de get_root/_defer_tk/_present_window,
  spawn_tk_window pour chaque dialogue, callbacks on_result au lieu
  de wait_window bloquant
This commit is contained in:
Hermes Agent
2026-09-18 13:25:24 +02:00
parent 15eb856bf2
commit e07f401b5c
3 changed files with 226 additions and 125 deletions
+57 -19
View File
@@ -1,22 +1,33 @@
"""Point d'entrée de l'application.
Par défaut, l'application s'exécute en arrière-plan, sans fenêtre visible,
avec une icône dans la zone de notification (Windows). Deux modes console
restent disponibles pour le développement / le test :
- `python main.py --ask "texte"` : envoie le texte au profil actif et
avec une icône dans la zone de notification. Deux modes console restent
disponibles pour le développement / le test :
- ``python main.py --ask "texte"`` : envoie le texte au profil actif et
imprime la réponse sans intercepter le clavier.
- `python main.py` : lance l'icône de la zone de notification.
- ``python main.py`` : lance l'icône de la zone de notification.
Architecture (threads)
----------------------
* **Thread principal** : capture du raccourci global (Ctrl+Alt+A) et
traitement des requêtes IA. Aucune boucle Tk ne tourne ici.
* **Thread UI (tray)** : icône pystray dans la zone de notification.
* **Fenêtres** : chaque dialogue (logs, profil, authentification, …) est
lancé dans son **propre thread** avec sa **propre instance Tk** racine.
Aucune racine Tk partagée — une fenêtre = un thread = un ``tk.Tk``.
"""
from __future__ import annotations
import argparse
import logging
import signal
import sys
import threading
from .config import ConfigStore, load_config
from .credentials import SecureStore
from .engine import AITypewriterEngine
from .engine import AITypewriterEngine, bind_hotkey
from .logging_utils import setup_logging
LOG = logging.getLogger("ai_typewriter")
@@ -40,7 +51,6 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
# Journalisation très verbeuse par défaut pour faciliter le diagnostic.
setup_logging(level=logging.DEBUG)
LOG.debug("Démarrage : args=%s", args)
@@ -61,26 +71,54 @@ def main(argv: list[str] | None = None) -> int:
LOG.error("%s", exc)
return 1
return run_tray(store)
return run_background(store, engine)
def run_tray(store: ConfigStore) -> int:
"""Lance l'application en arrière-plan avec l'icône de notification."""
# Import différé pour garantir que --ask / les tests fonctionnent même si
# pystray ou PIL ne sont pas installés / sans affichage graphique.
def run_background(store: ConfigStore, engine: AITypewriterEngine) -> int:
"""Lance l'application en arrière-plan.
- Le thread principal enregistre le raccourci global et attend l'arrêt.
- L'icône de notification (pystray) tourne dans un thread dédié.
- Chaque fenêtre Tk est créée dans son propre thread (voir
``ui._tk_spawn.spawn_tk_window``).
"""
# -- raccourci global (sur le thread principal, via le hook keyboard) -----
hotkey = store.hotkey
try:
bind_hotkey(engine, hotkey)
LOG.info("Raccourci global actif : %s", hotkey)
except Exception as exc:
LOG.exception("Impossible d'enregistrer le raccourci : %s", exc)
# -- icône de notification (thread dédié) --------------------------------
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.")
stop_event = threading.Event()
tray = TrayApp(store, stop_event=stop_event)
ui_thread = threading.Thread(target=tray.run, daemon=True, name="ui-tray")
ui_thread.start()
# -- le thread principal reste en vie jusqu'au signal d'arrêt ------------
# SIGINT / SIGTERM → arrêt propre
def _handle_signal(signum, frame):
LOG.info("Signal %s reçu, arrêt…", signum)
stop_event.set()
signal.signal(signal.SIGINT, _handle_signal)
signal.signal(signal.SIGTERM, _handle_signal)
try:
root.mainloop() # bloque sur le thread principal ; traite les événements Tk
stop_event.wait()
except KeyboardInterrupt:
app.stop()
LOG.info("Boucle Tk terminée.")
pass
LOG.info("Arrêt demandé.")
tray.stop()
ui_thread.join(timeout=3)
LOG.info("Application terminée.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
raise SystemExit(main(sys.argv[1:]))
+76 -106
View File
@@ -8,23 +8,25 @@ 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
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().
Architecture
------------
* **Thread UI** : exécute ``pystray.Icon.run()`` (boucle GTK / appindicator).
* **Fenêtres** : chaque action du menu crée une fenêtre Tk dans un thread
dédié avec sa propre racine ``tk.Tk`` (via ``ui._tk_spawn.spawn_tk_window``).
Il n'y a **aucune** racine Tk partagée.
* **Résultats** : les dialogues qui retournent une valeur (ProfileDialog,
ModelPicker) passent leur résultat via un callback exécuté à la fermeture.
"""
from __future__ import annotations
import logging
import threading
import tkinter as tk
from typing import Callable
from .config import ConfigStore
from .credentials import SecureStore
from .engine import AITypewriterEngine, bind_hotkey
from .ui._tk_spawn import spawn_tk_window
from .ui.auth_dialog import AuthDialog
from .ui.logs_window import LogsWindow
from .ui.profile_dialog import ProfileDialog
@@ -33,7 +35,7 @@ LOG = logging.getLogger("ai_typewriter.tray")
class TrayApp:
"""Encapsule l'icône de zone de notification + l'UI Tk."""
"""Encapsule l'icône de zone de notification (pystray)."""
def __init__(
self,
@@ -41,83 +43,78 @@ class TrayApp:
secure: SecureStore | None = None,
icon_factory: Callable | None = None,
menu_factory: Callable | None = None,
stop_event: threading.Event | None = None,
) -> None:
self.store = store
self.secure = secure or SecureStore()
self.engine = AITypewriterEngine(store, self.secure)
self._root: tk.Tk | None = None
self._root_lock = threading.Lock()
self._icon = None
self._icon_thread: threading.Thread | None = None
self._icon_factory = icon_factory
self._menu_factory = menu_factory
self._stop_event = stop_event
# -- fenêtre racine cachée (pour les dialogues) --------------------------
# -- cycle de vie -----------------------------------------------------------
def get_root(self) -> tk.Tk:
with self._root_lock:
if self._root is None:
self._root = tk.Tk()
self._root.withdraw()
return self._root
def run(self) -> None:
"""Lance la boucle pystray (bloquant, appelé depuis le thread UI)."""
icon = self._icon if self._icon is not None else self.build_icon()
self._icon = icon
LOG.info("Icône de notification lancée (pystray).")
icon.run()
# -- 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)
def _present_window(self, win) -> None:
"""Force une fenêtre Tk à apparaître au premier plan.
La racine étant retirée (``withdraw``), les ``Toplevel`` enfants
peuvent apparaître cachés ou derrière les autres fenêtres sous
Windows. On les rend visibles, on leur donne le focus, et on les
place brièvement en topmost pour garantir qu'ils soient au-dessus.
"""
try:
win.deiconify()
win.lift()
win.attributes("-topmost", True)
win.after(200, lambda: win.attributes("-topmost", False))
win.focus_force()
except tk.TclError:
pass
def stop(self, icon=None, item=None) -> None:
"""Arrête l'icône et signale l'arrêt au thread principal."""
LOG.info("Arrêt de l'icône de notification.")
if self._icon is not None:
try:
self._icon.stop()
except Exception:
pass
if self._stop_event is not None:
self._stop_event.set()
# -- actions du menu -------------------------------------------------------
def _show_logs(self) -> None:
win = LogsWindow(self.get_root())
self._present_window(win)
spawn_tk_window(lambda root: LogsWindow(root))
def _edit_profile(self, name: str | None = None) -> None:
"""Ouvre l'éditeur du profil `name`, ou le profil actif si `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()
dlg = ProfileDialog(self.get_root(), existing=target, secure=self.secure)
self._present_window(dlg)
self.get_root().wait_window(dlg)
if dlg.result:
def on_done(result: object) -> None:
if result is None:
return
try:
store.upsert(dlg.result)
store.set_active(dlg.result.name)
store.upsert(result)
store.set_active(result.name)
self._refresh_menu()
except Exception as exc:
LOG.exception("Impossible d'enregistrer le profil : %s", exc)
spawn_tk_window(
lambda root: ProfileDialog(root, existing=target, secure=self.secure),
on_result=on_done,
)
def _add_profile(self) -> None:
dlg = ProfileDialog(self.get_root(), existing=None, secure=self.secure)
self._present_window(dlg)
self.get_root().wait_window(dlg)
if dlg.result:
store = self.store
def on_done(result: object) -> None:
if result is None:
return
try:
self.store.upsert(dlg.result)
LOG.info("Profil « %s » ajouté.", dlg.result.name)
store.upsert(result)
LOG.info("Profil « %s » ajouté.", result.name)
self._refresh_menu()
except Exception as exc:
LOG.exception("Impossible d'ajouter le profil : %s", exc)
spawn_tk_window(
lambda root: ProfileDialog(root, existing=None, secure=self.secure),
on_result=on_done,
)
def _set_active(self, name: str) -> None:
try:
self.store.set_active(name)
@@ -135,9 +132,7 @@ class TrayApp:
LOG.debug("update_menu() a échoué", exc_info=True)
def _manage_auth(self) -> None:
dlg = AuthDialog(self.get_root(), self.secure)
self._present_window(dlg)
self.get_root().wait_window(dlg)
spawn_tk_window(lambda root: AuthDialog(root, self.secure))
# -- construction de l'icône ----------------------------------------------
@@ -153,16 +148,24 @@ 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 — construit dynamiquement à chaque affichage
# (grâce au callable), de sorte que la coche « ✓ » et la liste des
# profils reflètent toujours l'état courant après update_menu().
profiles_sub = pystray.Menu(self._profile_menu_items)
menu_items.append(self._menu_item("Modifier le profil", None, submenu=profiles_sub))
menu_items.append(
self._menu_item("Modifier le profil", None, submenu=profiles_sub)
)
menu_items.append(pystray.Menu.SEPARATOR)
menu_items.append(
@@ -172,7 +175,9 @@ class TrayApp:
)
)
menu_items.append(pystray.Menu.SEPARATOR)
menu_items.append(self._menu_item("Quitter", self._guard("Quitter", self.stop)))
menu_items.append(
self._menu_item("Quitter", self._guard("Quitter", self.stop))
)
if self._menu_factory:
return self._menu_factory(_image, menu_items)
@@ -216,23 +221,23 @@ class TrayApp:
return pystray.MenuItem(text, action)
def _guard(self, label: str, action: Callable) -> Callable:
"""Enveloppe une action de menu.
"""Enveloppe une action de menu avec journalisation et capture d'erreurs.
pystray invoque les actions avec ``(icon, item)`` et utilise la
signature pour adapter les arguments. Un handler à 2 params est
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.
Contrairement à l'ancienne architecture, on n'a **plus** besoin de
déférer l'exécution vers un thread Tk principal via ``after()`` :
chaque fenêtre crée son propre thread Tk.
"""
def handler(icon, item) -> None:
LOG.debug("Clic menu → %s", label)
try:
self._defer_tk(action)
action()
except Exception:
LOG.exception("Impossible de déférer l'action Tk : %s", label)
LOG.exception("Erreur lors de l'action : %s", label)
return handler
@@ -242,38 +247,3 @@ class TrayApp:
self._set_active(profile.name)
return action
# -- cycle de vie -----------------------------------------------------------
def start(self) -> None:
"""Attache le raccourci global puis lance l'icône dans un thread
d'arrière-plan.
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:
bind_hotkey(self.engine, hotkey)
LOG.info("Raccourci global actif : %s", hotkey)
except Exception as exc:
LOG.exception("Impossible d'enregistrer le raccourci : %s", exc)
icon = self._icon if self._icon is not None else self.build_icon()
self._icon = icon
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._icon is not None:
try:
self._icon.stop()
except Exception:
pass
if self._root is not None:
try:
self._root.quit() # débloque Tk.mainloop()
except tk.TclError:
pass
+93
View File
@@ -0,0 +1,93 @@
"""Helper to spawn Tk windows in dedicated threads with their own Tk root.
Each call to ``spawn_tk_window()`` creates a fresh ``tk.Tk`` (withdrawn),
instantiates the window as a ``tk.Toplevel`` of that root, and runs the
Tk event loop in a brand-new daemon thread. When the toplevel is closed
(programmatically or via the window manager), the root quits and the
thread exits cleanly.
"""
from __future__ import annotations
import threading
import tkinter as tk
from typing import Callable
def spawn_tk_window(
window_factory: Callable[[tk.Tk], tk.Toplevel],
on_result: Callable[[object], None] | None = None,
) -> threading.Thread:
"""Spawn *window_factory* in a dedicated thread with its own Tk root.
Parameters
----------
window_factory:
Called with a fresh withdrawn ``tk.Tk`` as the single argument.
Must return a ``tk.Toplevel`` that will be presented to the user.
on_result:
If provided, called **after** the Tk mainloop exits with the
value of ``window.result`` (or ``None`` when the attribute is
absent). The callback runs on the spawned thread, not on the
caller's thread.
Returns
-------
The ``threading.Thread`` that was started (daemon=True).
"""
result_container: list[object] = []
def _run() -> None:
root = tk.Tk()
root.withdraw() # never show the bare root window
try:
win = window_factory(root)
except Exception:
root.quit()
raise
_closed = False
def _on_destroy(event: tk.Event) -> None:
"""Fires for every destroyed widget — only act for the toplevel."""
nonlocal _closed
if event.widget is not win:
return
if _closed:
return
_closed = True
# Snapshot the result attribute before the Python wrapper is gone.
try:
if hasattr(win, "result"):
result_container.append(win.result)
except Exception:
pass
try:
root.quit()
except tk.TclError:
pass
win.bind("<Destroy>", _on_destroy)
# WM_DELETE_WINDOW (the X button) just delegates to destroy() so that
# both paths funnel through the same <Destroy> handler above.
win.protocol("WM_DELETE_WINDOW", lambda: win.destroy())
# -- present the window ------------------------------------------------
try:
win.deiconify()
win.lift()
win.attributes("-topmost", True)
win.after(200, lambda: win.attributes("-topmost", False))
win.focus_force()
except tk.TclError:
pass
root.mainloop()
if on_result is not None:
result = result_container[0] if result_container else None
on_result(result)
t = threading.Thread(target=_run, daemon=True, name="tk-window")
t.start()
return t