diff --git a/src/ai_typewriter/app.py b/src/ai_typewriter/app.py index 22c5a3c..aaec0f3 100644 --- a/src/ai_typewriter/app.py +++ b/src/ai_typewriter/app.py @@ -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:])) \ No newline at end of file + raise SystemExit(main(sys.argv[1:])) diff --git a/src/ai_typewriter/tray.py b/src/ai_typewriter/tray.py index 7e4f026..a57044e 100644 --- a/src/ai_typewriter/tray.py +++ b/src/ai_typewriter/tray.py @@ -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 \ No newline at end of file diff --git a/src/ai_typewriter/ui/_tk_spawn.py b/src/ai_typewriter/ui/_tk_spawn.py new file mode 100644 index 0000000..c24cd36 --- /dev/null +++ b/src/ai_typewriter/ui/_tk_spawn.py @@ -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("", _on_destroy) + # WM_DELETE_WINDOW (the X button) just delegates to destroy() so that + # both paths funnel through the same 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