Skip to content

Commit ea67187

Browse files
authored
Handle ctrl-c (#25)
1 parent dcc06a1 commit ea67187

File tree

7 files changed

+306
-76
lines changed

7 files changed

+306
-76
lines changed

rendercanvas/_loop.py

Lines changed: 119 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,22 @@
33
"""
44

55
import time
6+
import signal
67
import weakref
78

8-
from ._coreutils import log_exception, BaseEnum
9+
from ._coreutils import logger, log_exception, BaseEnum
10+
911

1012
# Note: technically, we could have a global loop proxy object that defers to any of the other loops.
1113
# That would e.g. allow using glfw with qt together. Probably a too weird use-case for the added complexity.
1214

1315

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+
1422
class BaseTimer:
1523
"""The Base class for a timer object.
1624
@@ -142,8 +150,9 @@ class BaseLoop:
142150
_TimerClass = None # subclases must set this
143151

144152
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
147156

148157
# The loop object runs a lightweight timer for a few reasons:
149158
# * Support running the loop without windows (e.g. to keep animations going).
@@ -156,26 +165,51 @@ def __init__(self):
156165

157166
def _register_scheduler(self, scheduler):
158167
# Gets called whenever a canvas in instantiated
159-
self._schedulers.add(scheduler)
168+
self._schedulers.append(scheduler)
160169
self._gui_timer.start(0.1) # (re)start our internal timer
161170

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+
162187
def _tick(self):
163188
# Keep the GUI alive on every tick
164189
self._rc_gui_poll()
165190

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()
171193

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
175198

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
179213

180214
def call_soon(self, callback, *args):
181215
"""Arrange for a callback to be called as soon as possible.
@@ -213,33 +247,92 @@ def call_repeated(self, interval, callback, *args):
213247
timer.start()
214248
return timer
215249

216-
def run(self, stop_when_no_canvases=True):
250+
def run(self):
217251
"""Enter the main loop.
218252
219253
This provides a generic API to start the loop. When building an application (e.g. with Qt)
220254
its fine to start the loop in the normal way.
221255
"""
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)
224282

225283
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
228318

229319
def _rc_run(self):
230320
"""Start running the event-loop.
231321
232322
* 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.
235327
"""
236328
raise NotImplementedError()
237329

238330
def _rc_stop(self):
239331
"""Stop the event loop.
240332
241333
* 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.
243336
"""
244337
raise NotImplementedError()
245338

@@ -355,7 +448,8 @@ def __init__(self, canvas, events, loop, *, mode="ondemand", min_fps=1, max_fps=
355448
# Register this scheduler/canvas at the loop object
356449
loop._register_scheduler(self)
357450

358-
def _get_canvas(self):
451+
def get_canvas(self):
452+
"""Get the canvas, or None if it is closed or gone."""
359453
canvas = self._canvas_ref()
360454
if canvas is None or canvas.is_closed():
361455
# Pretty nice, we can send a close event, even if the canvas no longer exists
@@ -396,7 +490,7 @@ def _tick(self):
396490
self._last_tick_time = time.perf_counter()
397491

398492
# Get canvas or stop
399-
if (canvas := self._get_canvas()) is None:
493+
if (canvas := self.get_canvas()) is None:
400494
return
401495

402496
# Process events, handlers may request a draw

rendercanvas/asyncio.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ def _rc_stop(self):
4040
class AsyncioLoop(BaseLoop):
4141
_TimerClass = AsyncioTimer
4242
_the_loop = None
43-
_is_interactive = True # When run() is not called, assume interactive
4443

4544
@property
4645
def _loop(self):
@@ -58,16 +57,12 @@ def _get_loop(self):
5857
return loop
5958

6059
def _rc_run(self):
61-
if self._loop.is_running():
62-
self._is_interactive = True
63-
else:
64-
self._is_interactive = False
60+
if not self._loop.is_running():
6561
self._loop.run_forever()
6662

6763
def _rc_stop(self):
68-
if not self._is_interactive:
69-
self._loop.stop()
70-
self._is_interactive = True
64+
# Note: is only called when we're inside _rc_run
65+
self._loop.stop()
7166

7267
def _rc_call_soon(self, callback, *args):
7368
self._loop.call_soon(callback, *args)

rendercanvas/base.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,9 @@ def _final_canvas_init(self):
116116
self.set_title(kwargs["title"])
117117

118118
def __del__(self):
119-
# On delete, we call the custom close method.
119+
# On delete, we call the custom destroy method.
120120
try:
121-
self.close()
121+
self._rc_close()
122122
except Exception:
123123
pass
124124
# Since this is sometimes used in a multiple inheritance, the
@@ -497,9 +497,6 @@ def _rc_set_logical_size(self, width, height):
497497
def _rc_close(self):
498498
"""Close the canvas.
499499
500-
For widgets that are wrapped by a ``WrapperRenderCanvas``, this should probably
501-
close the wrapper instead.
502-
503500
Note that ``BaseRenderCanvas`` implements the ``close()`` method, which
504501
is a rather common name; it may be necessary to re-implement that too.
505502
"""
@@ -512,9 +509,6 @@ def _rc_is_closed(self):
512509
def _rc_set_title(self, title):
513510
"""Set the canvas title. May be ignored when it makes no sense.
514511
515-
For widgets that are wrapped by a ``WrapperRenderCanvas``, this should probably
516-
set the title of the wrapper instead.
517-
518512
The default implementation does nothing.
519513
"""
520514
pass

rendercanvas/glfw.py

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import sys
1313
import time
1414
import atexit
15-
import weakref
1615

1716
import glfw
1817

@@ -172,13 +171,10 @@ def __init__(self, *args, present_method=None, **kwargs):
172171
self._changing_pixel_ratio = False
173172
self._is_minimized = False
174173

175-
# Register ourselves
176-
loop.all_glfw_canvases.add(self)
177-
178174
# Register callbacks. We may get notified too often, but that's
179175
# ok, they'll result in a single draw.
180176
glfw.set_framebuffer_size_callback(self._window, weakbind(self._on_size_change))
181-
glfw.set_window_close_callback(self._window, weakbind(self._check_close))
177+
glfw.set_window_close_callback(self._window, weakbind(self._on_want_close))
182178
glfw.set_window_refresh_callback(self._window, weakbind(self._on_window_dirty))
183179
glfw.set_window_focus_callback(self._window, weakbind(self._on_window_dirty))
184180
set_window_content_scale_callback(
@@ -234,6 +230,16 @@ def _determine_size(self):
234230
}
235231
self.submit_event(ev)
236232

233+
def _on_want_close(self, *args):
234+
# Called when the user attempts to close the window, for example by clicking the close widget in the title bar.
235+
# We could prevent closing the window here. But we don't :)
236+
pass # Prevent closing: glfw.set_window_should_close(self._window, 0)
237+
238+
def _maybe_close(self):
239+
if self._window is not None:
240+
if glfw.window_should_close(self._window):
241+
self._rc_close()
242+
237243
# %% Methods to implement RenderCanvas
238244

239245
def _set_logical_size(self, new_logical_size):
@@ -306,9 +312,12 @@ def _rc_set_logical_size(self, width, height):
306312
self._set_logical_size((float(width), float(height)))
307313

308314
def _rc_close(self):
315+
if not loop._glfw_initialized:
316+
return # glfw is probably already terminated
309317
if self._window is not None:
310-
glfw.set_window_should_close(self._window, True)
311-
self._check_close()
318+
glfw.destroy_window(self._window) # not just glfw.hide_window
319+
self._window = None
320+
self.submit_event({"event_type": "close"})
312321

313322
def _rc_is_closed(self):
314323
return self._window is None
@@ -332,20 +341,6 @@ def _on_size_change(self, *args):
332341
self._determine_size()
333342
self.request_draw()
334343

335-
def _check_close(self, *args):
336-
# Follow the close flow that glfw intended.
337-
# This method can be overloaded and the close-flag can be set to False
338-
# using set_window_should_close() if now is not a good time to close.
339-
if self._window is not None and glfw.window_should_close(self._window):
340-
self._on_close()
341-
342-
def _on_close(self, *args):
343-
loop.all_glfw_canvases.discard(self)
344-
if self._window is not None:
345-
glfw.destroy_window(self._window) # not just glfw.hide_window
346-
self._window = None
347-
self.submit_event({"event_type": "close"})
348-
349344
def _on_mouse_button(self, window, but, action, mods):
350345
# Map button being changed, which we use to update self._pointer_buttons.
351346
button_map = {
@@ -529,26 +524,28 @@ def _on_char(self, window, char):
529524
class GlfwAsyncioLoop(AsyncioLoop):
530525
def __init__(self):
531526
super().__init__()
532-
self.all_glfw_canvases = weakref.WeakSet()
533-
self.stop_if_no_more_canvases = True
534527
self._glfw_initialized = False
528+
atexit.register(self._terminate_glfw)
535529

536530
def init_glfw(self):
537-
glfw.init() # Safe to call multiple times
538531
if not self._glfw_initialized:
532+
glfw.init() # Note: safe to call multiple times
539533
self._glfw_initialized = True
540-
atexit.register(glfw.terminate)
534+
535+
def _terminate_glfw(self):
536+
self._glfw_initialized = False
537+
glfw.terminate()
541538

542539
def _rc_gui_poll(self):
540+
for canvas in self.get_canvases():
541+
canvas._maybe_close()
542+
del canvas
543543
glfw.post_empty_event() # Awake the event loop, if it's in wait-mode
544544
glfw.poll_events()
545-
if self.stop_if_no_more_canvases and not tuple(self.all_glfw_canvases):
546-
self.stop()
547545

548546
def _rc_run(self):
549547
super()._rc_run()
550-
if not self._is_interactive:
551-
poll_glfw_briefly()
548+
poll_glfw_briefly()
552549

553550

554551
loop = GlfwAsyncioLoop()

0 commit comments

Comments
 (0)