Skip to content

Commit

Permalink
Handle ctrl-c
Browse files Browse the repository at this point in the history
  • Loading branch information
almarklein committed Nov 18, 2024
1 parent 748c6f3 commit 163b007
Show file tree
Hide file tree
Showing 5 changed files with 63 additions and 32 deletions.
47 changes: 41 additions & 6 deletions rendercanvas/_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import time
import signal
import weakref

from ._coreutils import log_exception, BaseEnum
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -219,27 +222,59 @@ 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()

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

Expand Down
11 changes: 3 additions & 8 deletions rendercanvas/asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down
22 changes: 9 additions & 13 deletions rendercanvas/glfw.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import sys
import time
import atexit
import weakref

import glfw

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions rendercanvas/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions rendercanvas/wx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit 163b007

Please sign in to comment.