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:
+57
-19
@@ -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
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user