3
3
"""
4
4
5
5
import time
6
+ import signal
6
7
import weakref
7
8
8
- from ._coreutils import log_exception , BaseEnum
9
+ from ._coreutils import logger , log_exception , BaseEnum
10
+
9
11
10
12
# Note: technically, we could have a global loop proxy object that defers to any of the other loops.
11
13
# That would e.g. allow using glfw with qt together. Probably a too weird use-case for the added complexity.
12
14
13
15
16
+ HANDLED_SIGNALS = (
17
+ signal .SIGINT , # Unix signal 2. Sent by Ctrl+C.
18
+ signal .SIGTERM , # Unix signal 15. Sent by `kill <pid>`.
19
+ )
20
+
21
+
14
22
class BaseTimer :
15
23
"""The Base class for a timer object.
16
24
@@ -142,8 +150,9 @@ class BaseLoop:
142
150
_TimerClass = None # subclases must set this
143
151
144
152
def __init__ (self ):
145
- self ._schedulers = set ()
146
- self ._stop_when_no_canvases = False
153
+ self ._schedulers = []
154
+ self ._is_inside_run = False
155
+ self ._should_stop = 0
147
156
148
157
# The loop object runs a lightweight timer for a few reasons:
149
158
# * Support running the loop without windows (e.g. to keep animations going).
@@ -156,26 +165,51 @@ def __init__(self):
156
165
157
166
def _register_scheduler (self , scheduler ):
158
167
# Gets called whenever a canvas in instantiated
159
- self ._schedulers .add (scheduler )
168
+ self ._schedulers .append (scheduler )
160
169
self ._gui_timer .start (0.1 ) # (re)start our internal timer
161
170
171
+ def get_canvases (self ):
172
+ """Get a list of currently active (not-closed) canvases."""
173
+ canvases = []
174
+ schedulers = []
175
+
176
+ for scheduler in self ._schedulers :
177
+ canvas = scheduler .get_canvas ()
178
+ if canvas is not None :
179
+ canvases .append (canvas )
180
+ schedulers .append (scheduler )
181
+
182
+ # Forget schedulers that no longer have a live canvas
183
+ self ._schedulers = schedulers
184
+
185
+ return canvases
186
+
162
187
def _tick (self ):
163
188
# Keep the GUI alive on every tick
164
189
self ._rc_gui_poll ()
165
190
166
- # Check all schedulers
167
- schedulers_to_close = []
168
- for scheduler in self ._schedulers :
169
- if scheduler ._get_canvas () is None :
170
- schedulers_to_close .append (scheduler )
191
+ # Clean internal schedulers list
192
+ self .get_canvases ()
171
193
172
- # Forget schedulers that no longer have an live canvas
173
- for scheduler in schedulers_to_close :
174
- self ._schedulers .discard (scheduler )
194
+ # Our loop can still tick, even if the loop is not started via our run() method.
195
+ # If this is the case, we don't run the close/stop logic
196
+ if not self ._is_inside_run :
197
+ return
175
198
176
- # Check whether we must stop the loop
177
- if self ._stop_when_no_canvases and not self ._schedulers :
178
- self .stop ()
199
+ # Should we stop?
200
+ if not self ._schedulers :
201
+ # Stop when there are no more canvases
202
+ self ._rc_stop ()
203
+ elif self ._should_stop >= 2 :
204
+ # force a stop without waiting for the canvases to close
205
+ self ._rc_stop ()
206
+ elif self ._should_stop :
207
+ # Close all remaining canvases. Loop will stop in a next iteration.
208
+ for canvas in self .get_canvases ():
209
+ if not getattr (canvas , "_rc_closed_by_loop" , False ):
210
+ canvas ._rc_closed_by_loop = True
211
+ canvas ._rc_close ()
212
+ del canvas
179
213
180
214
def call_soon (self , callback , * args ):
181
215
"""Arrange for a callback to be called as soon as possible.
@@ -213,33 +247,92 @@ def call_repeated(self, interval, callback, *args):
213
247
timer .start ()
214
248
return timer
215
249
216
- def run (self , stop_when_no_canvases = True ):
250
+ def run (self ):
217
251
"""Enter the main loop.
218
252
219
253
This provides a generic API to start the loop. When building an application (e.g. with Qt)
220
254
its fine to start the loop in the normal way.
221
255
"""
222
- self ._stop_when_no_canvases = bool (stop_when_no_canvases )
223
- self ._rc_run ()
256
+ # Note that when the loop is started via this method, we always stop
257
+ # when the last canvas is closed. Keeping the loop alive is a use-case
258
+ # for interactive sessions, where the loop is already running, or started
259
+ # "behind our back". So we don't need to accomodate for this.
260
+
261
+ # Cannot run if already running
262
+ if self ._is_inside_run :
263
+ raise RuntimeError ("loop.run() is not reentrant." )
264
+
265
+ # Make sure that the internal timer is running, even if no canvases.
266
+ self ._gui_timer .start (0.1 )
267
+
268
+ # Register interrupt handler
269
+ prev_sig_handlers = self .__setup_interrupt ()
270
+
271
+ # Run. We could be in this loop for a long time. Or we can exit
272
+ # immediately if the backend already has an (interactive) event
273
+ # loop. In the latter case, note how we restore the sigint
274
+ # handler again, so we don't interfere with that loop.
275
+ self ._is_inside_run = True
276
+ try :
277
+ self ._rc_run ()
278
+ finally :
279
+ self ._is_inside_run = False
280
+ for sig , cb in prev_sig_handlers .items ():
281
+ signal .signal (sig , cb )
224
282
225
283
def stop (self ):
226
- """Stop the currently running event loop."""
227
- self ._rc_stop ()
284
+ """Close all windows and stop the currently running event loop.
285
+
286
+ This only has effect when the event loop is currently running via ``.run()``.
287
+ I.e. not when a Qt app is started with ``app.exec()``, or when Qt or asyncio
288
+ is running interactively in your IDE.
289
+ """
290
+ # Only take action when we're inside the run() method
291
+ if self ._is_inside_run :
292
+ self ._should_stop += 1
293
+ if self ._should_stop >= 4 :
294
+ # If for some reason the tick method is no longer being called, but the loop is still running, we can still stop it by spamming stop() :)
295
+ self ._rc_stop ()
296
+
297
+ def __setup_interrupt (self ):
298
+ def on_interrupt (sig , _frame ):
299
+ logger .warning (f"Received signal { signal .strsignal (sig )} " )
300
+ self .stop ()
301
+
302
+ prev_handlers = {}
303
+
304
+ for sig in HANDLED_SIGNALS :
305
+ prev_handler = signal .getsignal (sig )
306
+ if prev_handler in (None , signal .SIG_IGN , signal .SIG_DFL ):
307
+ # Only register if the old handler for SIGINT was not None,
308
+ # which means that a non-python handler was installed, i.e. in
309
+ # Julia, and not SIG_IGN which means we should ignore the interrupts.
310
+ pass
311
+ else :
312
+ # Setting the signal can raise ValueError if this is not the main thread/interpreter
313
+ try :
314
+ prev_handlers [sig ] = signal .signal (signal .SIGINT , on_interrupt )
315
+ except ValueError :
316
+ break
317
+ return prev_handlers
228
318
229
319
def _rc_run (self ):
230
320
"""Start running the event-loop.
231
321
232
322
* Start the event loop.
233
- * The rest of the loop object must work just fine, also when the loop is
234
- started in the "normal way" (i.e. this method may not be called).
323
+ * The loop object must also work when the native loop is started
324
+ in the GUI-native way (i.e. this method may not be called).
325
+ * If the backend is in interactive mode (i.e. there already is
326
+ an active native loop) this may return directly.
235
327
"""
236
328
raise NotImplementedError ()
237
329
238
330
def _rc_stop (self ):
239
331
"""Stop the event loop.
240
332
241
333
* Stop the running event loop.
242
- * When running in an interactive session, this call should probably be ignored.
334
+ * This will only be called when the process is inside _rc_run().
335
+ I.e. not for interactive mode.
243
336
"""
244
337
raise NotImplementedError ()
245
338
@@ -355,7 +448,8 @@ def __init__(self, canvas, events, loop, *, mode="ondemand", min_fps=1, max_fps=
355
448
# Register this scheduler/canvas at the loop object
356
449
loop ._register_scheduler (self )
357
450
358
- def _get_canvas (self ):
451
+ def get_canvas (self ):
452
+ """Get the canvas, or None if it is closed or gone."""
359
453
canvas = self ._canvas_ref ()
360
454
if canvas is None or canvas .is_closed ():
361
455
# Pretty nice, we can send a close event, even if the canvas no longer exists
@@ -396,7 +490,7 @@ def _tick(self):
396
490
self ._last_tick_time = time .perf_counter ()
397
491
398
492
# Get canvas or stop
399
- if (canvas := self ._get_canvas ()) is None :
493
+ if (canvas := self .get_canvas ()) is None :
400
494
return
401
495
402
496
# Process events, handlers may request a draw
0 commit comments