From 163b00770b324cdbeb7dcd38f22067db96213a1d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 18 Nov 2024 23:18:32 +0100 Subject: [PATCH] Handle ctrl-c --- rendercanvas/_loop.py | 47 +++++++++++++++++++++++++++++++++++------ rendercanvas/asyncio.py | 11 +++------- rendercanvas/glfw.py | 22 ++++++++----------- rendercanvas/qt.py | 4 ++-- rendercanvas/wx.py | 11 +++++++--- 5 files changed, 63 insertions(+), 32 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index b820e81..7dd57b2 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -3,6 +3,7 @@ """ import time +import signal import weakref from ._coreutils import log_exception, BaseEnum @@ -143,6 +144,7 @@ class BaseLoop: def __init__(self): self._schedulers = set() + self._is_inside_run = False self._stop_when_no_canvases = False # The loop object runs a lightweight timer for a few reasons: @@ -173,7 +175,8 @@ def _tick(self): for scheduler in schedulers_to_close: self._schedulers.discard(scheduler) - # Check whether we must stop the loop + # Check whether we must stop the loop. Note that if the loop is not + # actually running, but is in an interactive mode, this has no effect. if self._stop_when_no_canvases and not self._schedulers: self.stop() @@ -219,19 +222,50 @@ def run(self, stop_when_no_canvases=True): This provides a generic API to start the loop. When building an application (e.g. with Qt) its fine to start the loop in the normal way. """ + + # Cannot run if already running + if self._is_inside_run: + raise RuntimeError("loop.run() is not reentrant.") + self._stop_when_no_canvases = bool(stop_when_no_canvases) - self._rc_run() + + # Handle interrupts + try: + prev_int_handler = signal.signal(signal.SIGINT, self.__on_interrupt) + except ValueError: + prev_int_handler = None + + # Run. We could be in this loop for a loong time. Or we can + # exit emmidiately if the backend already has an (interactive) + # event loop. In the latter case, see how we disable the signal + # again, so we don't interfere with that loop. + self._is_inside_run = True + try: + self._rc_run() + except KeyboardInterrupt: + pass + finally: + self._is_inside_run = False + if prev_int_handler is not None: + signal.signal(signal.SIGINT, prev_int_handler) def stop(self): """Stop the currently running event loop.""" - self._rc_stop() + # Only take action when we're inside the run() method + if self._is_inside_run: + self._rc_stop() + + def __on_interrupt(self, *args): + self.stop() def _rc_run(self): """Start running the event-loop. * Start the event loop. - * The rest of the loop object must work just fine, also when the loop is - started in the "normal way" (i.e. this method may not be called). + * The loop object must also work when the native loop is started + in the GUI-native way (i.e. this method may not be called). + * If the backend is in interactive mode (i.e. there already is + an active native loop) this may return directly. """ raise NotImplementedError() @@ -239,7 +273,8 @@ def _rc_stop(self): """Stop the event loop. * Stop the running event loop. - * When running in an interactive session, this call should probably be ignored. + * This will only be called when the process is inside _rc_run(). + I.e. not for interactive mode. """ raise NotImplementedError() diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py index a3c283a..2649f97 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -40,7 +40,6 @@ def _rc_stop(self): class AsyncioLoop(BaseLoop): _TimerClass = AsyncioTimer _the_loop = None - _is_interactive = True # When run() is not called, assume interactive @property def _loop(self): @@ -58,16 +57,12 @@ def _get_loop(self): return loop def _rc_run(self): - if self._loop.is_running(): - self._is_interactive = True - else: - self._is_interactive = False + if not self._loop.is_running(): self._loop.run_forever() def _rc_stop(self): - if not self._is_interactive: - self._loop.stop() - self._is_interactive = True + # Note: is only called when we're inside _rc_run + self._loop.stop() def _rc_call_soon(self, callback, *args): self._loop.call_soon(callback, *args) diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 209a46a..12e2cc4 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -12,7 +12,6 @@ import sys import time import atexit -import weakref import glfw @@ -172,9 +171,6 @@ def __init__(self, *args, present_method=None, **kwargs): self._changing_pixel_ratio = False self._is_minimized = False - # Register ourselves - loop.all_glfw_canvases.add(self) - # Register callbacks. We may get notified too often, but that's # ok, they'll result in a single draw. glfw.set_framebuffer_size_callback(self._window, weakbind(self._on_size_change)) @@ -306,6 +302,8 @@ def _rc_set_logical_size(self, width, height): self._set_logical_size((float(width), float(height))) def _rc_close(self): + if not loop._glfw_initialized: + return # glfw is probably already terminated if self._window is not None: glfw.set_window_should_close(self._window, True) self._check_close() @@ -340,7 +338,6 @@ def _check_close(self, *args): self._on_close() def _on_close(self, *args): - loop.all_glfw_canvases.discard(self) if self._window is not None: glfw.destroy_window(self._window) # not just glfw.hide_window self._window = None @@ -529,26 +526,25 @@ def _on_char(self, window, char): class GlfwAsyncioLoop(AsyncioLoop): def __init__(self): super().__init__() - self.all_glfw_canvases = weakref.WeakSet() - self.stop_if_no_more_canvases = True self._glfw_initialized = False + atexit.register(self._terminate_glfw) def init_glfw(self): - glfw.init() # Safe to call multiple times if not self._glfw_initialized: + glfw.init() # Note: safe to call multiple times self._glfw_initialized = True - atexit.register(glfw.terminate) + + def _terminate_glfw(self): + self._glfw_initialized = False + glfw.terminate() def _rc_gui_poll(self): glfw.post_empty_event() # Awake the event loop, if it's in wait-mode glfw.poll_events() - if self.stop_if_no_more_canvases and not tuple(self.all_glfw_canvases): - self.stop() def _rc_run(self): super()._rc_run() - if not self._is_interactive: - poll_glfw_briefly() + poll_glfw_briefly() loop = GlfwAsyncioLoop() diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 5cee615..6323f0f 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -577,8 +577,8 @@ def _rc_run(self): app.exec() if hasattr(app, "exec") else app.exec_() def _rc_stop(self): - if not already_had_app_on_import: - self._app.quit() + # Note: is only called when we're inside _rc_run + self._app.quit() def _rc_gui_poll(self): pass # we assume the Qt event loop is running. Calling processEvents() will cause recursive repaints. diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index e6725bf..1366e32 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -500,11 +500,16 @@ def _rc_call_soon(self, delay, callback, *args): def _rc_run(self): self._frame_to_keep_loop_alive = wx.Frame(None) - self._app.MainLoop() + try: + self._app.MainLoop() + finally: + self._rc_stop() def _rc_stop(self): - self._frame_to_keep_loop_alive.Destroy() - _frame_to_keep_loop_alive = None + # Stop the loop by closing the last frame + if self._frame_to_keep_loop_alive: + self._frame_to_keep_loop_alive.Destroy() + self._frame_to_keep_loop_alive = None def _rc_gui_poll(self): pass # We can assume the wx loop is running.