Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle ctrl-c #25

Merged
merged 7 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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