Skip to content

Commit

Permalink
Handle ctrl-c (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
almarklein authored Nov 21, 2024
1 parent dcc06a1 commit ea67187
Show file tree
Hide file tree
Showing 7 changed files with 306 additions and 76 deletions.
144 changes: 119 additions & 25 deletions rendercanvas/_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@
"""

import time
import signal
import weakref

from ._coreutils import log_exception, BaseEnum
from ._coreutils import logger, log_exception, BaseEnum


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


HANDLED_SIGNALS = (
signal.SIGINT, # Unix signal 2. Sent by Ctrl+C.
signal.SIGTERM, # Unix signal 15. Sent by `kill <pid>`.
)


class BaseTimer:
"""The Base class for a timer object.
Expand Down Expand Up @@ -142,8 +150,9 @@ class BaseLoop:
_TimerClass = None # subclases must set this

def __init__(self):
self._schedulers = set()
self._stop_when_no_canvases = False
self._schedulers = []
self._is_inside_run = False
self._should_stop = 0

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

def _register_scheduler(self, scheduler):
# Gets called whenever a canvas in instantiated
self._schedulers.add(scheduler)
self._schedulers.append(scheduler)
self._gui_timer.start(0.1) # (re)start our internal timer

def get_canvases(self):
"""Get a list of currently active (not-closed) canvases."""
canvases = []
schedulers = []

for scheduler in self._schedulers:
canvas = scheduler.get_canvas()
if canvas is not None:
canvases.append(canvas)
schedulers.append(scheduler)

# Forget schedulers that no longer have a live canvas
self._schedulers = schedulers

return canvases

def _tick(self):
# Keep the GUI alive on every tick
self._rc_gui_poll()

# Check all schedulers
schedulers_to_close = []
for scheduler in self._schedulers:
if scheduler._get_canvas() is None:
schedulers_to_close.append(scheduler)
# Clean internal schedulers list
self.get_canvases()

# Forget schedulers that no longer have an live canvas
for scheduler in schedulers_to_close:
self._schedulers.discard(scheduler)
# Our loop can still tick, even if the loop is not started via our run() method.
# If this is the case, we don't run the close/stop logic
if not self._is_inside_run:
return

# Check whether we must stop the loop
if self._stop_when_no_canvases and not self._schedulers:
self.stop()
# Should we stop?
if not self._schedulers:
# Stop when there are no more canvases
self._rc_stop()
elif self._should_stop >= 2:
# force a stop without waiting for the canvases to close
self._rc_stop()
elif self._should_stop:
# Close all remaining canvases. Loop will stop in a next iteration.
for canvas in self.get_canvases():
if not getattr(canvas, "_rc_closed_by_loop", False):
canvas._rc_closed_by_loop = True
canvas._rc_close()
del canvas

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

def run(self, stop_when_no_canvases=True):
def run(self):
"""Enter the main loop.
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.
"""
self._stop_when_no_canvases = bool(stop_when_no_canvases)
self._rc_run()
# Note that when the loop is started via this method, we always stop
# when the last canvas is closed. Keeping the loop alive is a use-case
# for interactive sessions, where the loop is already running, or started
# "behind our back". So we don't need to accomodate for this.

# Cannot run if already running
if self._is_inside_run:
raise RuntimeError("loop.run() is not reentrant.")

# Make sure that the internal timer is running, even if no canvases.
self._gui_timer.start(0.1)

# Register interrupt handler
prev_sig_handlers = self.__setup_interrupt()

# Run. We could be in this loop for a long time. Or we can exit
# immediately if the backend already has an (interactive) event
# loop. In the latter case, note how we restore the sigint
# handler again, so we don't interfere with that loop.
self._is_inside_run = True
try:
self._rc_run()
finally:
self._is_inside_run = False
for sig, cb in prev_sig_handlers.items():
signal.signal(sig, cb)

def stop(self):
"""Stop the currently running event loop."""
self._rc_stop()
"""Close all windows and stop the currently running event loop.
This only has effect when the event loop is currently running via ``.run()``.
I.e. not when a Qt app is started with ``app.exec()``, or when Qt or asyncio
is running interactively in your IDE.
"""
# Only take action when we're inside the run() method
if self._is_inside_run:
self._should_stop += 1
if self._should_stop >= 4:
# 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() :)
self._rc_stop()

def __setup_interrupt(self):
def on_interrupt(sig, _frame):
logger.warning(f"Received signal {signal.strsignal(sig)}")
self.stop()

prev_handlers = {}

for sig in HANDLED_SIGNALS:
prev_handler = signal.getsignal(sig)
if prev_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
# Only register if the old handler for SIGINT was not None,
# which means that a non-python handler was installed, i.e. in
# Julia, and not SIG_IGN which means we should ignore the interrupts.
pass
else:
# Setting the signal can raise ValueError if this is not the main thread/interpreter
try:
prev_handlers[sig] = signal.signal(signal.SIGINT, on_interrupt)
except ValueError:
break
return prev_handlers

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 Expand Up @@ -355,7 +448,8 @@ def __init__(self, canvas, events, loop, *, mode="ondemand", min_fps=1, max_fps=
# Register this scheduler/canvas at the loop object
loop._register_scheduler(self)

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

# Get canvas or stop
if (canvas := self._get_canvas()) is None:
if (canvas := self.get_canvas()) is None:
return

# Process events, handlers may request a draw
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
10 changes: 2 additions & 8 deletions rendercanvas/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ def _final_canvas_init(self):
self.set_title(kwargs["title"])

def __del__(self):
# On delete, we call the custom close method.
# On delete, we call the custom destroy method.
try:
self.close()
self._rc_close()
except Exception:
pass
# Since this is sometimes used in a multiple inheritance, the
Expand Down Expand Up @@ -497,9 +497,6 @@ def _rc_set_logical_size(self, width, height):
def _rc_close(self):
"""Close the canvas.
For widgets that are wrapped by a ``WrapperRenderCanvas``, this should probably
close the wrapper instead.
Note that ``BaseRenderCanvas`` implements the ``close()`` method, which
is a rather common name; it may be necessary to re-implement that too.
"""
Expand All @@ -512,9 +509,6 @@ def _rc_is_closed(self):
def _rc_set_title(self, title):
"""Set the canvas title. May be ignored when it makes no sense.
For widgets that are wrapped by a ``WrapperRenderCanvas``, this should probably
set the title of the wrapper instead.
The default implementation does nothing.
"""
pass
Expand Down
55 changes: 26 additions & 29 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,13 +171,10 @@ 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))
glfw.set_window_close_callback(self._window, weakbind(self._check_close))
glfw.set_window_close_callback(self._window, weakbind(self._on_want_close))
glfw.set_window_refresh_callback(self._window, weakbind(self._on_window_dirty))
glfw.set_window_focus_callback(self._window, weakbind(self._on_window_dirty))
set_window_content_scale_callback(
Expand Down Expand Up @@ -234,6 +230,16 @@ def _determine_size(self):
}
self.submit_event(ev)

def _on_want_close(self, *args):
# Called when the user attempts to close the window, for example by clicking the close widget in the title bar.
# We could prevent closing the window here. But we don't :)
pass # Prevent closing: glfw.set_window_should_close(self._window, 0)

def _maybe_close(self):
if self._window is not None:
if glfw.window_should_close(self._window):
self._rc_close()

# %% Methods to implement RenderCanvas

def _set_logical_size(self, new_logical_size):
Expand Down Expand Up @@ -306,9 +312,12 @@ 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()
glfw.destroy_window(self._window) # not just glfw.hide_window
self._window = None
self.submit_event({"event_type": "close"})

def _rc_is_closed(self):
return self._window is None
Expand All @@ -332,20 +341,6 @@ def _on_size_change(self, *args):
self._determine_size()
self.request_draw()

def _check_close(self, *args):
# Follow the close flow that glfw intended.
# This method can be overloaded and the close-flag can be set to False
# using set_window_should_close() if now is not a good time to close.
if self._window is not None and glfw.window_should_close(self._window):
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
self.submit_event({"event_type": "close"})

def _on_mouse_button(self, window, but, action, mods):
# Map button being changed, which we use to update self._pointer_buttons.
button_map = {
Expand Down Expand Up @@ -529,26 +524,28 @@ 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):
for canvas in self.get_canvases():
canvas._maybe_close()
del canvas
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
Loading

0 comments on commit ea67187

Please sign in to comment.