1717
1818import sys
1919import re
20- from typing import Optional , Any
20+ from typing import Optional , Any , Callable
2121
2222try :
2323 from rich .console import Console # type: ignore
5858 re .UNICODE ,
5959)
6060
61+ _LOG_COLORS = {
62+ "info" : "#00aaff" ,
63+ "warning" : "#ffaa00" ,
64+ "error" : "#ff4444" ,
65+ "success" : "#00cc66" ,
66+ }
67+
68+ # Global widget cache
69+ _log_widget = None
70+
6171
6272def strip_emojis (text : str ) -> str :
6373 """Remove emojis from a string for cleaner terminal output."""
@@ -90,10 +100,6 @@ def plain(message: str, err: bool = False) -> None:
90100 _emit (message , err = err )
91101
92102
93- # Global widget cache
94- _log_widget = None
95-
96-
97103def get_log_widget ():
98104 global _log_widget
99105 if _log_widget is not None :
@@ -113,13 +119,108 @@ def get_log_widget():
113119 return None
114120
115121
122+ def _is_on_gui_thread () -> bool :
123+ """Return True when there is no Qt app, or we are on the app's thread."""
124+ try :
125+ from PySide6 .QtCore import QThread
126+ from PySide6 .QtWidgets import QApplication
127+
128+ app = QApplication .instance ()
129+ if app is None :
130+ return True
131+ return QThread .currentThread () == app .thread ()
132+ except Exception :
133+ return True
134+
135+
136+ def _append_html_to_widget (widget : Any , html : str , plain_text : str ) -> None :
137+ """Mutate the log widget. Must only run on the Qt GUI thread."""
138+ try :
139+ from PySide6 .QtGui import QTextCursor
140+
141+ widget .moveCursor (QTextCursor .MoveOperation .End )
142+ widget .insertHtml (html )
143+ widget .ensureCursorVisible ()
144+ except Exception :
145+ try :
146+ widget .append (plain_text )
147+ except Exception :
148+ pass
149+
150+
151+ def _post_to_gui_thread (fn : Callable [[], None ]) -> bool :
152+ """Queue *fn* on the Qt GUI thread. Returns False if posting is impossible."""
153+ try :
154+ from PySide6 .QtCore import QTimer
155+ from PySide6 .QtWidgets import QApplication
156+
157+ app = QApplication .instance ()
158+ if app is None :
159+ return False
160+ # Context=app → functor runs on the application (GUI) thread.
161+ QTimer .singleShot (0 , app , fn )
162+ return True
163+ except Exception :
164+ return False
165+
166+
167+ def _safe_append_to_widget (widget : Any , html : str , plain_text : str ) -> None :
168+ """Append to a Qt log widget from any thread without segfaulting."""
169+ if widget is None :
170+ return
171+
172+ # Bridge / non-QTextEdit objects expose a thread-safe append via signals.
173+ try :
174+ from PySide6 .QtWidgets import QTextEdit
175+
176+ if not isinstance (widget , QTextEdit ):
177+ if callable (getattr (widget , "append" , None )):
178+ widget .append (plain_text )
179+ return
180+ except Exception :
181+ # Qt unavailable: best-effort append
182+ try :
183+ if callable (getattr (widget , "append" , None )):
184+ widget .append (plain_text )
185+ except Exception :
186+ pass
187+ return
188+
189+ if _is_on_gui_thread ():
190+ _append_html_to_widget (widget , html , plain_text )
191+ return
192+
193+ # Off GUI thread: never touch QTextEdit directly (segfault).
194+ posted = _post_to_gui_thread (
195+ lambda w = widget , h = html , p = plain_text : _append_html_to_widget (w , h , p )
196+ )
197+ if not posted :
198+ # No event loop / posting failed — skip widget; console _emit still runs.
199+ return
200+
201+
202+ def _try_bridge_log (gui : object | None , level : str , message : str ) -> bool :
203+ """Route through SafeGuiBridge-style API when available (already thread-safe)."""
204+ if gui is None :
205+ return False
206+ log_message_level = getattr (gui , "log_message_level" , None )
207+ if not callable (log_message_level ):
208+ return False
209+ try :
210+ # Message is already translated; pass twice for (fr, en).
211+ log_message_level (level , message , message )
212+ return True
213+ except Exception :
214+ return False
215+
216+
116217def log (
117218 level : str ,
118219 message : str | tuple | list | Any ,
119220 err : bool | None = None ,
120221 gui : object | None = None ,
121222) -> None :
122- """Log principal avec append automatique"""
223+ """Log principal avec append automatique (thread-safe pour le widget GUI). """
123224
124225 lvl = level .upper ().strip ()
125226
@@ -148,43 +249,29 @@ def log(
148249 message = str (message )
149250
150251 out_err = err if err is not None else lvl in ("ERROR" , "WARN" , "WARNING" )
252+ plain_line = f"{ prefix } { message } "
151253
152- # === Append AUTOMATIQUE dans le widget 'log' ===
153- widget = get_log_widget ()
154- if widget is None and gui is not None and hasattr (gui , "log" ):
155- widget = gui .log
254+ # Prefer SafeGuiBridge (signals) when the caller passes one — avoids
255+ # touching QTextEdit from a worker thread and prevents double-writes.
256+ bridged = _try_bridge_log (gui , style , str (message ))
156257
157- if widget is not None :
158- try :
159- from PySide6 .QtGui import QTextCursor
258+ if not bridged :
259+ widget = get_log_widget ()
260+ if widget is None and gui is not None and hasattr (gui , "log" ):
261+ widget = gui .log
160262
161- colors = {
162- "info" : "#00aaff" ,
163- "warning" : "#ffaa00" ,
164- "error" : "#ff4444" ,
165- "success" : "#00cc66" ,
166- }
167- color = colors .get (style , "#ffffff" )
168-
169- html = f'<span style="color:{ color } ;">{ prefix } { message } </span><br>'
170-
171- widget .moveCursor (QTextCursor .MoveOperation .End )
172- widget .insertHtml (html )
173- widget .ensureCursorVisible ()
174-
175- except Exception :
176- try :
177- widget .append (f"{ prefix } { message } " )
178- except Exception :
179- pass
263+ if widget is not None :
264+ color = _LOG_COLORS .get (style , "#ffffff" )
265+ html = f'<span style="color:{ color } ;">{ plain_line } </span><br>'
266+ _safe_append_to_widget (widget , html , plain_line )
180267
181268 # Appel sûr à _emit (sans exc_info)
182269 try :
183- _emit (f" { prefix } { message } " , err = out_err , style = style )
270+ _emit (plain_line , err = out_err , style = style )
184271 except TypeError :
185272 # Si _emit a une autre signature
186273 try :
187- _emit (f" { prefix } { message } " , err = out_err )
274+ _emit (plain_line , err = out_err )
188275 except Exception :
189276 pass # dernier recours
190277
0 commit comments