From f9b1d0e5f4019b312dc2b5c846ee5c949cf76cfa Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 28 Nov 2024 11:17:16 +0100 Subject: [PATCH 01/22] Refactor to go async (wip) --- rendercanvas/__init__.py | 5 +- rendercanvas/_async_adapter.py | 124 ++++++++++ rendercanvas/_async_sniffs.py | 15 ++ rendercanvas/_events.py | 14 +- rendercanvas/_loop.py | 413 ++++++++++++++------------------- rendercanvas/asyncio.py | 71 ------ rendercanvas/base.py | 11 +- rendercanvas/glfw.py | 6 +- rendercanvas/loops.py | 91 ++++++++ rendercanvas/offscreen.py | 23 +- rendercanvas/qt.py | 44 ++-- rendercanvas/stub.py | 20 +- rendercanvas/wx.py | 13 -- 13 files changed, 449 insertions(+), 401 deletions(-) create mode 100644 rendercanvas/_async_adapter.py create mode 100644 rendercanvas/_async_sniffs.py delete mode 100644 rendercanvas/asyncio.py create mode 100644 rendercanvas/loops.py diff --git a/rendercanvas/__init__.py b/rendercanvas/__init__.py index 491276c..15a3f20 100644 --- a/rendercanvas/__init__.py +++ b/rendercanvas/__init__.py @@ -7,11 +7,10 @@ from ._version import __version__, version_info from . import _coreutils from ._events import EventType -from .base import BaseRenderCanvas, BaseLoop, BaseTimer +from .base import BaseRenderCanvas, BaseLoop __all__ = [ + "BaseLoop", "BaseRenderCanvas", "EventType", - "BaseLoop", - "BaseTimer", ] diff --git a/rendercanvas/_async_adapter.py b/rendercanvas/_async_adapter.py new file mode 100644 index 0000000..0890dbc --- /dev/null +++ b/rendercanvas/_async_adapter.py @@ -0,0 +1,124 @@ +import time +import logging + +from sniffio import thread_local as sniffio_thread_local + + +logger = logging.getLogger("rendercanvas") + + +class Sleeper: + def __init__(self, when): + self.when = when + + def __await__(self): + yield {"wait_method": "sleep", "when": self.when} + + +async def sleep(delay): + await Sleeper(time.perf_counter() + delay) + + +class Event: + def __init__(self): + self._is_set = False + self._tasks = [] + + async def wait(self): + if self._is_set: + return + else: + return self # triggers __await__ + + def __await__(self): + return {"wait_method": "event", "event": self} + + def _add_task(self, task): + self._tasks.append(task) + + def set(self): + self._is_set = True + for task in self._tasks: + task.call_step_soon() + self._tasks = [] + + +class CancelledError(BaseException): + """Exception raised when a task is cancelled.""" + + pass + + +class Task: + def __init__(self, loop, coro, name): + self.loop = loop + self.coro = coro + self.name = name + self.cancelled = False + self._done_callbacks = [] + self.call_step_soon() + + def add_done_callback(self, callback): + self._done_callbacks.append(callback) + + def _close(self): + self.loop = None + self.coro = None + for callback in self._done_callbacks: + try: + callback(self) + except Exception: + pass + + def call_step_soon(self): + self.loop._rc_call_soon(self.step) + + def call_step_at(self, when): + self.loop._rc_call_at(when, self.step) + + def cancel(self): + self.cancelled = True + + def step(self): + if self.coro is None: + return + + result = None + stop = False + + old_name, sniffio_thread_local.name = sniffio_thread_local.name, __name__ + try: + if self.cancelled: + stop = True + self.coro.throw(CancelledError()) # falls through if not caught + self.coro.close() # raises GeneratorExit + else: + result = self.coro.send(None) + except CancelledError: + stop = True + except StopIteration: + stop = True + except Exception as err: + # This should not happen, because the loop catches and logs all errors. But just in case. + logger.error(f"Error in task: {err}") + stop = True + finally: + sniffio_thread_local.name = old_name + + # Clean up to help gc + if stop: + return self._close() + + if not (isinstance(result, dict) and result.get("wait_method", None)): + raise RuntimeError( + f"Incompatible awaitable result {result!r}. Maybe you used asyncio or trio (this does not run on either)?" + ) + + wait_method = result["wait_method"] + + if wait_method == "sleep": + self.call_step_at(result["when"]) + elif wait_method == "event": + result["event"]._add_task(self) + else: + raise RuntimeError(f"Unknown wait_method {wait_method!r}.") diff --git a/rendercanvas/_async_sniffs.py b/rendercanvas/_async_sniffs.py new file mode 100644 index 0000000..93b8998 --- /dev/null +++ b/rendercanvas/_async_sniffs.py @@ -0,0 +1,15 @@ +import sys +import sniffio + + +async def sleep(delay): + libname = sniffio.current_async_library() + sleep = sys.modules[libname].sleep + await sleep(delay) + + +class Event: + def __new__(cls): + libname = sniffio.current_async_library() + Event = sys.modules[libname].Event # noqa + return Event() diff --git a/rendercanvas/_events.py b/rendercanvas/_events.py index a60a304..f6166e8 100644 --- a/rendercanvas/_events.py +++ b/rendercanvas/_events.py @@ -3,6 +3,7 @@ """ import time +from asyncio import iscoroutinefunction # note: is not asyncio-specific from collections import defaultdict, deque from ._coreutils import log_exception, BaseEnum @@ -182,7 +183,7 @@ def submit(self, event): self._pending_events.append(event) - def flush(self): + async def flush(self): """Dispatch all pending events. This should generally be left to the scheduler. @@ -192,9 +193,9 @@ def flush(self): event = self._pending_events.popleft() except IndexError: break - self.emit(event) + await self.emit(event) - def emit(self, event): + async def emit(self, event): """Directly emit the given event. In most cases events should be submitted, so that they are flushed @@ -208,11 +209,14 @@ def emit(self, event): if event.get("stop_propagation", False): break with log_exception(f"Error during handling {event_type} event"): - callback(event) + if iscoroutinefunction(callback): + await callback(event) + else: + callback(event) def _rc_close(self): """Wrap up when the scheduler detects the canvas is closed/dead.""" # This is a little feature because detecting a widget from closing can be tricky. if not self._closed: self.submit({"event_type": "close"}) - self.flush() + # todo: !! ??self.flush() diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 04e6acd..6ce57b5 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -8,6 +8,9 @@ from ._coreutils import logger, log_exception, BaseEnum +from ._async_sniffs import sleep, Event # rename this module +from asyncio import iscoroutinefunction +from ._async_adapter import Task as AsyncAdapterTask # 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. @@ -19,154 +22,26 @@ ) -class BaseTimer: - """The Base class for a timer object. - - Each backends provides its own timer subclass. The timer is used by the internal scheduling mechanics, - and is also returned by user-facing API such as ``loop.call_later()``. - """ - - _running_timers = set() - - def __init__(self, loop, callback, *args, one_shot=False): - # The loop arg is passed as an argument, so that the Loop object itself can create a timer. - self._loop = loop - # Check callable - if not callable(callback): - raise TypeError("Given timer callback is not a callable.") - self._callback = callback - self._args = args - # Internal variables - self._one_shot = bool(one_shot) - self._interval = None - self._expect_tick_at = None - - def start(self, interval): - """Start the timer with the given interval. - - When the interval has passed, the callback function will be called, - unless the timer is stopped earlier. - - When the timer is currently running, it is first stopped and then - restarted. - """ - if self._interval is None: - self._rc_init() - if self.is_running: - self._rc_stop() - BaseTimer._running_timers.add(self) - self._interval = max(0.0, float(interval)) - self._expect_tick_at = time.perf_counter() + self._interval - self._rc_start() - - def stop(self): - """Stop the timer. - - If the timer is currently running, it is stopped, and the - callback is *not* called. If the timer is currently not running, - this method does nothing. - """ - BaseTimer._running_timers.discard(self) - self._expect_tick_at = None - self._rc_stop() - - def _tick(self): - """The implementations must call this method.""" - # Stop or restart - if self._one_shot: - BaseTimer._running_timers.discard(self) - self._expect_tick_at = None - else: - self._expect_tick_at = time.perf_counter() + self._interval - self._rc_start() - # Callback - with log_exception("Timer callback error"): - self._callback(*self._args) - - @property - def time_left(self): - """The expected time left before the callback is called. - - None means that the timer is not running. The value can be negative - (which means that the timer is late). - """ - if self._expect_tick_at is None: - return None - else: - return self._expect_tick_at - time.perf_counter() - - @property - def is_running(self): - """Whether the timer is running. - - A running timer means that a new call to the callback is scheduled and - will happen in ``time_left`` seconds (assuming the event loop keeps - running). - """ - return self._expect_tick_at is not None - - @property - def is_one_shot(self): - """Whether the timer is one-shot or continuous. - - A one-shot timer stops running after the currently scheduled call to the callback. - It can then be started again. A continuous timer (i.e. not one-shot) automatically - schedules new calls. - """ - return self._one_shot - - def _rc_init(self): - """Initialize the (native) timer object. - - Opportunity to initialize the timer object. This is called right - before the timer is first started. - """ - pass - - def _rc_start(self): - """Start the timer. - - * Must schedule for ``self._tick`` to be called in ``self._interval`` seconds. - * Must call it exactly once (the base class takes care of repeating the timer). - * When ``self._rc_stop()`` is called before the timer finished, the call to ``self._tick()`` must be cancelled. - """ - raise NotImplementedError() - - def _rc_stop(self): - """Stop the timer. - - * If the timer is running, cancel the pending call to ``self._tick()``. - * Otherwise, this should do nothing. - """ - raise NotImplementedError() - - class BaseLoop: """The base class for an event-loop object. - Each backends provides its own loop subclass, so that rendercanvas can run cleanly in the backend's event loop. + Each backend provides its own loop subclass, so that rendercanvas can run cleanly in the backend's event loop. """ - _TimerClass = None # subclases must set this - def __init__(self): + self.__tasks = [] 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). - # * Detect closed windows. Relying on the backend alone is tricky, since the - # loop usually stops when the last window is closed, so the close event may - # not be fired. - # * Keep the GUI going even when the canvas loop is on pause e.g. because its - # minimized (applies to backends that implement _rc_gui_poll). - self._gui_timer = self._TimerClass(self, self._tick, one_shot=False) + self.__created_loop_task = False def _register_scheduler(self, scheduler): # Gets called whenever a canvas in instantiated self._schedulers.append(scheduler) - self._gui_timer.start(0.1) # (re)start our internal timer + # self._gui_timer.start(0.1) # (re)start our internal timer + if not self.__created_loop_task: + self.__created_loop_task = True + self.add_task(self._loop_task, name="loop") def get_canvases(self): """Get a list of currently active (not-closed) canvases.""" @@ -184,32 +59,53 @@ def get_canvases(self): return canvases - def _tick(self): - # Keep the GUI alive on every tick - self._rc_gui_poll() - - # Clean internal schedulers list - self.get_canvases() - - # 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 - - # 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 + async def _loop_task(self): + # This task has multiple purposes: + # + # * Detect closed windows. Relying on the backend alone is tricky, since the + # loop usually stops when the last window is closed, so the close event may + # not be fired. + # * Keep the GUI going even when the canvas loop is on pause e.g. because its + # minimized (applies to backends that implement _rc_gui_poll). + + while True: + await sleep(0.1) + + # Keep the GUI alive on every tick. This is extra, since the canvas task also calls it. + self._rc_gui_poll() + + # Clean internal schedulers list + self.get_canvases() + + # 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: + continue + + # 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 add_task(self, async_func, *args, name="unnamed"): + if not (callable(async_func) and iscoroutinefunction(async_func)): + raise TypeError("call_soon() expects an async function.") + + async def wrapper(): + with log_exception(f"Error in {name} task:"): + await async_func(*args) + + self._rc_add_task(wrapper, name) def call_soon(self, callback, *args): """Arrange for a callback to be called as soon as possible. @@ -217,7 +113,17 @@ def call_soon(self, callback, *args): The callback will be called in the next iteration of the event-loop, but other pending events/callbacks may be handled first. Returns None. """ - self._rc_call_soon(callback, *args) + if not callable(callback): + raise TypeError("call_soon() expects a callable.") + elif iscoroutinefunction(callback): + raise TypeError("call_soon() expects a normal callable, not an async one.") + + async def wrapper(): + with log_exception("Callback error:"): + callback(*args) + + self._rc_add_task(wrapper, "call_soon") + # self._rc_call_soon(callback, *args) def call_later(self, delay, callback, *args): """Arrange for a callback to be called after the given delay (in seconds). @@ -229,9 +135,7 @@ def call_later(self, delay, callback, *args): It's not necessary to hold a reference to the timer object; a ref is held automatically, and discarded when the timer ends or stops. """ - timer = self._TimerClass(self, callback, *args, one_shot=True) - timer.start(delay) - return timer + raise NotImplementedError() def call_repeated(self, interval, callback, *args): """Arrange for a callback to be called repeatedly. @@ -243,9 +147,7 @@ def call_repeated(self, interval, callback, *args): ref is held automatically, and discarded when the timer is stopped. """ - timer = self._TimerClass(self, callback, *args, one_shot=False) - timer.start() - return timer + raise NotImplementedError() def run(self): """Enter the main loop. @@ -263,7 +165,9 @@ def run(self): 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) + if not self.__created_loop_task: + self.__created_loop_task = True + self.add_task(self._loop_task, name="loop") # Register interrupt handler prev_sig_handlers = self.__setup_interrupt() @@ -280,6 +184,10 @@ def run(self): for sig, cb in prev_sig_handlers.items(): signal.signal(sig, cb) + async def run_async(self): + """ "Alternative to ``run()``, to enter the mainloop from a running async framework.""" + await self._rc_run_async() + def stop(self): """Close all windows and stop the currently running event loop. @@ -327,22 +235,52 @@ def _rc_run(self): """ raise NotImplementedError() + async def _rc_run_async(self): + """Enter the mainloop by awaiting this co-routine. + + Should only be implemented by loop-backends that are async (asyncio, trio). + Other backends can ignore this. + """ + raise NotImplementedError() + def _rc_stop(self): """Stop the event loop. * Stop the running event loop. + * Cancel any remaining tasks. + * todo: is the below still (supposed to be) true? * This will only be called when the process is inside _rc_run(). I.e. not for interactive mode. """ - raise NotImplementedError() + for task in self.__tasks: + task.cancel() + self.__tasks = [] - def _rc_call_soon(self, callback, *args): + def _rc_add_task(self, async_func, name): + """Add an async task to the running loop. + + This method is optional. A backend must either implement ``_rc_add_task`` + or implement both ``_rc_call_soon()`` and ``_rc_call_at``. + + * Schedule running the task defined by the given co-routine function. + * The name is for debugging purposes only. + * The backend is responsible for cancelling remaining tasks in _rc_stop. + """ + task = AsyncAdapterTask(self, async_func(), name) + self.__tasks.append(task) + task.add_done_callback(self.__tasks.remove) + + def _rc_call_soon(self, callback): """Method to call a callback in the next iteraction of the event-loop. - * A quick path to have callback called in a next invocation of the event loop. - * This method is optional: the default implementation just calls ``call_later()`` with a zero delay. + This method must only be implemented if ``_rc_add_task()`` is not. + """ + + def _rc_call_at(self, when, callback): + """Method to call a callback at a specific time. + + This method must only be implemented if ``_rc_add_task()`` is not. """ - self.call_later(0, callback, *args) def _rc_gui_poll(self): """Process GUI events. @@ -436,6 +374,7 @@ def __init__(self, canvas, events, loop, *, mode="ondemand", min_fps=1, max_fps= self._max_fps = float(max_fps) self._draw_requested = True # Start with a draw in ondemand mode self._last_draw_time = 0 + self._async_draw_event = None # Keep track of fps self._draw_stats = 0, time.perf_counter() @@ -443,10 +382,13 @@ def __init__(self, canvas, events, loop, *, mode="ondemand", min_fps=1, max_fps= # Initialise the timer that runs our scheduling loop. # Note that the backend may do a first draw earlier, starting the loop, and that's fine. self._last_tick_time = -0.1 - self._timer = loop.call_later(0.1, self._tick) + # self._timer = loop.call_later(0.1, self._tick) # Register this scheduler/canvas at the loop object loop._register_scheduler(self) + loop.add_task( + self.__scheduler_task, name=f"{canvas.__class__.__name__} scheduler" + ) def get_canvas(self): """Get the canvas, or None if it is closed or gone.""" @@ -463,78 +405,79 @@ def request_draw(self): # Just set the flag self._draw_requested = True - def _schedule_next_tick(self): + async def __scheduler_task(self): """Schedule _tick() to be called via our timer.""" - if self._timer.is_running: - return - - # Determine delay - if self._mode == "fastest" or self._max_fps <= 0: - delay = 0 - else: - delay = 1 / self._max_fps - delay = 0 if delay < 0 else delay # 0 means cannot keep up - - # Offset delay for time spent on processing events, etc. - time_since_tick_start = time.perf_counter() - self._last_tick_time - delay -= time_since_tick_start - delay = max(0, delay) - - # Go! - self._timer.start(delay) - - def _tick(self): - """Process event and schedule a new draw or tick.""" - - self._last_tick_time = time.perf_counter() - - # Get canvas or stop - if (canvas := self.get_canvas()) is None: - return - - # Process events, handlers may request a draw - canvas._process_events() - - # Determine what to do next ... - - if self._mode == "fastest": - # fastest: draw continuously as fast as possible, ignoring fps settings. - canvas._rc_request_draw() - - elif self._mode == "continuous": - # continuous: draw continuously, aiming for a steady max framerate. - canvas._rc_request_draw() - - elif self._mode == "ondemand": - # ondemand: draw when needed (detected by calls to request_draw). - # Aim for max_fps when drawing is needed, otherwise min_fps. - if self._draw_requested: - canvas._rc_request_draw() - elif ( - self._min_fps > 0 - and time.perf_counter() - self._last_draw_time > 1 / self._min_fps - ): - canvas._rc_request_draw() + while True: + # Determine delay + if self._mode == "fastest" or self._max_fps <= 0: + delay = 0 else: - self._schedule_next_tick() + delay = 1 / self._max_fps + delay = 0 if delay < 0 else delay # 0 means cannot keep up + + # Offset delay for time spent on processing events, etc. + time_since_tick_start = time.perf_counter() - self._last_tick_time + delay -= time_since_tick_start + delay = max(0, delay) + + # Wait + await sleep(delay) + + # Get canvas or stop + if (canvas := self.get_canvas()) is None: + return + + # Below is the "tick" + + self._last_tick_time = time.perf_counter() + + # Process events, handlers may request a draw + await canvas._process_events() + + # Determine what to do next ... + + do_draw = False + + if self._mode == "fastest": + # fastest: draw continuously as fast as possible, ignoring fps settings. + do_draw = True + elif self._mode == "continuous": + # continuous: draw continuously, aiming for a steady max framerate. + do_draw = True + elif self._mode == "ondemand": + # ondemand: draw when needed (detected by calls to request_draw). + # Aim for max_fps when drawing is needed, otherwise min_fps. + if self._draw_requested: + do_draw = True + elif ( + self._min_fps > 0 + and time.perf_counter() - self._last_draw_time > 1 / self._min_fps + ): + do_draw = True + + elif self._mode == "manual": + pass + else: + raise RuntimeError(f"Unexpected scheduling mode: '{self._mode}'") - elif self._mode == "manual": - # manual: never draw, except when ... ? - self._schedule_next_tick() + # If we don't want to draw, we move to the next iter + if not do_draw: + continue - else: - raise RuntimeError(f"Unexpected scheduling mode: '{self._mode}'") + canvas._rc_request_draw() + self._async_draw_event = Event() + await self._async_draw_event.wait() def on_draw(self): - """Called from canvas._draw_frame_and_present().""" - # Bookkeeping self._last_draw_time = time.perf_counter() self._draw_requested = False # Keep ticking - self._schedule_next_tick() + if self._async_draw_event: + self._async_draw_event.set() + self._async_draw_event = None # Update stats count, last_time = self._draw_stats diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py deleted file mode 100644 index 2649f97..0000000 --- a/rendercanvas/asyncio.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Implements an asyncio event loop, used in some backends. -""" - -# This is used for backends that don't have an event loop by themselves, like glfw. -# Would be nice to also allow a loop based on e.g. Trio. But we can likely fit that in -# when the time comes. - -__all__ = ["AsyncioLoop", "AsyncioTimer"] - -import asyncio - -from .base import BaseLoop, BaseTimer - - -class AsyncioTimer(BaseTimer): - """Timer based on asyncio.""" - - _handle = None - - def _rc_init(self): - pass - - def _rc_start(self): - def tick(): - self._handle = None - self._tick() - - if self._handle is not None: - self._handle.cancel() - asyncio_loop = self._loop._loop - self._handle = asyncio_loop.call_later(self._interval, tick) - - def _rc_stop(self): - if self._handle: - self._handle.cancel() - self._handle = None - - -class AsyncioLoop(BaseLoop): - _TimerClass = AsyncioTimer - _the_loop = None - - @property - def _loop(self): - if self._the_loop is None: - self._the_loop = self._get_loop() - return self._the_loop - - def _get_loop(self): - try: - return asyncio.get_running_loop() - except Exception: - pass - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - return loop - - def _rc_run(self): - if not self._loop.is_running(): - self._loop.run_forever() - - def _rc_stop(self): - # 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) - - def _rc_gui_poll(self): - pass diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 64e66cf..31ed77d 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -2,12 +2,12 @@ The base classes. """ -__all__ = ["WrapperRenderCanvas", "BaseRenderCanvas", "BaseLoop", "BaseTimer"] +__all__ = ["BaseLoop", "BaseRenderCanvas", "WrapperRenderCanvas"] import importlib from ._events import EventEmitter, EventType # noqa: F401 -from ._loop import Scheduler, BaseLoop, BaseTimer +from ._loop import Scheduler, BaseLoop from ._coreutils import logger, log_exception @@ -229,7 +229,8 @@ def submit_event(self, event): # %% Scheduling and drawing - def _process_events(self): + # todo: now that it's async, we cannot call it from anywhere ... + async def _process_events(self): """Process events and animations. Called from the scheduler. @@ -247,7 +248,7 @@ def _process_events(self): # Flush our events, so downstream code can update stuff. # Maybe that downstream code request a new draw. - self._events.flush() + await self._events.flush() # TODO: implement later (this is a start but is not tested) # Schedule animation events until the lag is gone @@ -333,7 +334,7 @@ def _draw_frame_and_present(self): # Process special events # Note that we must not process normal events here, since these can do stuff # with the canvas (resize/close/etc) and most GUI systems don't like that. - self._events.emit({"event_type": "before_draw"}) + # todo: self._events.emit({"event_type": "before_draw"}) # Notify the scheduler if self.__scheduler is not None: diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index c3aa38d..8cab230 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -16,7 +16,7 @@ import glfw from .base import BaseRenderCanvas -from .asyncio import AsyncioLoop +from .loops import AsyncioLoop from ._coreutils import SYSTEM_IS_WAYLAND, weakbind, logger @@ -521,7 +521,7 @@ def _on_char(self, window, char): RenderCanvas = GlfwRenderCanvas -class GlfwAsyncioLoop(AsyncioLoop): +class GlfwLoop(AsyncioLoop): def __init__(self): super().__init__() self._glfw_initialized = False @@ -548,7 +548,7 @@ def _rc_run(self): poll_glfw_briefly() -loop = GlfwAsyncioLoop() +loop = GlfwLoop() run = loop.run # backwards compat diff --git a/rendercanvas/loops.py b/rendercanvas/loops.py new file mode 100644 index 0000000..bd602a6 --- /dev/null +++ b/rendercanvas/loops.py @@ -0,0 +1,91 @@ +""" +Implements an asyncio event loop, used in some backends. +""" + +# This is used for backends that don't have an event loop by themselves, like glfw. +# Would be nice to also allow a loop based on e.g. Trio. But we can likely fit that in +# when the time comes. + +__all__ = ["AsyncioLoop", "TrioLoop"] + + +from .base import BaseLoop + + +class AsyncioLoop(BaseLoop): + _the_loop = None + + def __init__(self): + super().__init__() + self._tasks = [] + + @property + def _loop(self): + if self._the_loop is None: + import asyncio + + try: + self._the_loop = asyncio.get_running_loop() + except Exception: + pass + if self._the_loop is None: + self._the_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._the_loop) + return self._the_loop + + def _rc_add_task(self, func, name): + task = self._loop.create_task(func(), name=name) + self._tasks.append(task) + task.add_done_callback(self._tasks.remove) + return task + + def _rc_run(self): + if not self._loop.is_running(): + self._loop.run_forever() + + def _rc_stop(self): + # Note: is only called when we're inside _rc_run + self._loop.stop() + while self._tasks: + t = self._tasks.pop(-1) + t.cancel() # is a no-op if the task is no longer running + + # def _rc_call_soon(self, callback, *args): + # self._loop.call_soon(callback, *args) + + def _rc_gui_poll(self): + pass + + +class TrioLoop(BaseLoop): + def __init__(self): + super().__init__() + import trio + + self._pending_tasks = [] + self._cancel_scope = None + self._send_channel, self._receive_channel = trio.open_memory_channel(99) + + def _rc_add_task(self, async_func, name): + self._send_channel.send_nowait((async_func, name)) + return None + + async def _rc_run_async(self): + import trio + + with trio.CancelScope() as self._cancel_scope: + async with trio.open_nursery() as nursery: + while True: + async_func, name = await self._receive_channel.receive() + nursery.start_soon(async_func, name=name) + self._cancel_scope = None + + def _rc_run(self): + import trio + + trio.run(self.run_async, restrict_keyboard_interrupt_to_checkpoints=False) + + def _rc_stop(self): + # Cancel the main task and all its child tasks. + if self._cancel_scope is not None: + self._cancel_scope.cancel() diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index fe5f67c..cc21db3 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -4,7 +4,7 @@ __all__ = ["RenderCanvas", "loop"] -from .base import BaseRenderCanvas, BaseLoop, BaseTimer +from .base import BaseRenderCanvas, BaseLoop class ManualOffscreenRenderCanvas(BaseRenderCanvas): @@ -85,17 +85,6 @@ def draw(self): RenderCanvas = ManualOffscreenRenderCanvas -class StubTimer(BaseTimer): - def _rc_init(self): - pass - - def _rc_start(self): - pass - - def _rc_stop(self): - pass - - class StubLoop(BaseLoop): # If we consider the use-cases for using this offscreen canvas: # @@ -110,13 +99,13 @@ class StubLoop(BaseLoop): # In summary, we provide a call_later() and run() that behave pretty # well for the first case. - _TimerClass = StubTimer # subclases must set this - def _process_timers(self): + pass # Running this loop processes any timers - for timer in list(BaseTimer._running_timers): - if timer.time_left <= 0: - timer._tick() + # todo: xxxx + # for timer in list(BaseTimer._running_timers): + # if timer.time_left <= 0: + # timer._tick() def _rc_run(self): self._process_timers() diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index da8ba47..f4f4fa0 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -3,18 +3,15 @@ can be used as a standalone window or in a larger GUI. """ -__all__ = ["RenderCanvas", "RenderWidget", "QRenderWidget", "loop"] +__all__ = ["QRenderWidget", "RenderCanvas", "RenderWidget", "loop"] import sys +import time import ctypes import importlib -from .base import ( - WrapperRenderCanvas, - BaseRenderCanvas, - BaseLoop, - BaseTimer, -) + +from .base import WrapperRenderCanvas, BaseRenderCanvas, BaseLoop from ._coreutils import ( logger, SYSTEM_IS_WAYLAND, @@ -521,25 +518,7 @@ def closeEvent(self, event): # noqa: N802 RenderCanvas = QRenderCanvas -class QtTimer(BaseTimer): - """Timer basef on Qt.""" - - def _rc_init(self): - self._qt_timer = QtCore.QTimer() - self._qt_timer.timeout.connect(self._tick) - self._qt_timer.setSingleShot(True) - self._qt_timer.setTimerType(PreciseTimer) - - def _rc_start(self): - self._qt_timer.start(int(self._interval * 1000)) - - def _rc_stop(self): - self._qt_timer.stop() - - class QtLoop(BaseLoop): - _TimerClass = QtTimer - def init_qt(self): _ = self._app self._latest_timeout = 0 @@ -553,11 +532,15 @@ def _app(self): self._the_app = app = QtWidgets.QApplication([]) return app - def _rc_call_soon(self, callback, *args): - func = callback - if args: - func = lambda: callback(*args) - QtCore.QTimer.singleshot(0, func) + def _rc_call_soon(self, callback): + QtCore.QTimer.singleShot(0, callback) + + def _rc_call_at(self, when, callback): + delay_ms = int(max(0, (when - time.perf_counter()) * 1000)) + QtCore.QTimer.singleShot(delay_ms, callback) + + def _rc_add_task(self, *args): + return super()._rc_add_task(*args) def _rc_run(self): # Note: we could detect if asyncio is running (interactive session) and wheter @@ -578,6 +561,7 @@ def _rc_run(self): def _rc_stop(self): # Note: is only called when we're inside _rc_run + super()._rc_stop() self._app.quit() def _rc_gui_poll(self): diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index c933b97..e4d02f7 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -4,7 +4,7 @@ __all__ = ["RenderCanvas", "loop"] -from .base import WrapperRenderCanvas, BaseRenderCanvas, BaseLoop, BaseTimer +from .base import WrapperRenderCanvas, BaseRenderCanvas, BaseLoop class StubRenderCanvas(BaseRenderCanvas): @@ -78,29 +78,11 @@ def __init__(self, parent=None, **kwargs): self._subwidget = StubRenderCanvas(self, **kwargs) -class StubTimer(BaseTimer): - """ - Backends must subclass ``BaseTimer`` and implement a set of methods prefixed with ``_rc__``. - """ - - def _rc_init(self): - pass - - def _rc_start(self): - raise NotImplementedError() - - def _rc_stop(self): - raise NotImplementedError() - - class StubLoop(BaseLoop): """ Backends must subclass ``BaseLoop`` and implement a set of methods prefixed with ``_rc__``. - In addition to that, the class attribute ``_TimerClass`` must be set to the corresponding timer subclass. """ - _TimerClass = StubTimer - def _rc_run(self): raise NotImplementedError() diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index aba9ed8..0762727 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -22,7 +22,6 @@ WrapperRenderCanvas, BaseRenderCanvas, BaseLoop, - BaseTimer, ) @@ -468,19 +467,7 @@ def Notify(self, *args): # noqa: N802 pass # wrapped C/C++ object of type WxRenderWidget has been deleted -class WxTimer(BaseTimer): - def _rc_init(self): - self._wx_timer = TimerWithCallback(self._tick) - - def _rc_start(self): - self._wx_timer.StartOnce(int(self._interval * 1000)) - - def _rc_stop(self): - self._wx_timer.Stop() - - class WxLoop(BaseLoop): - _TimerClass = WxTimer _the_app = None def init_wx(self): From e98cd44e4685ff2da8169be99b9b6be6ea30f1a4 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 28 Nov 2024 16:05:51 +0100 Subject: [PATCH 02/22] detach loop from backend --- examples/cube_glfw.py | 4 +- examples/demo.py | 5 +- rendercanvas/_async_adapter.py | 3 + rendercanvas/_events.py | 11 +- rendercanvas/_loop.py | 316 +++++++++------------------------ rendercanvas/_scheduler.py | 205 +++++++++++++++++++++ rendercanvas/asyncio.py | 50 ++++++ rendercanvas/base.py | 17 +- rendercanvas/glfw.py | 77 ++++---- rendercanvas/jupyter.py | 8 +- rendercanvas/loops.py | 91 ---------- rendercanvas/offscreen.py | 8 +- rendercanvas/qt.py | 18 +- rendercanvas/stub.py | 6 +- rendercanvas/trio.py | 45 +++++ rendercanvas/wx.py | 18 +- 16 files changed, 467 insertions(+), 415 deletions(-) create mode 100644 rendercanvas/_scheduler.py create mode 100644 rendercanvas/asyncio.py delete mode 100644 rendercanvas/loops.py create mode 100644 rendercanvas/trio.py diff --git a/examples/cube_glfw.py b/examples/cube_glfw.py index 878602e..6bd5e9f 100644 --- a/examples/cube_glfw.py +++ b/examples/cube_glfw.py @@ -5,7 +5,7 @@ Run a wgpu example on the glfw backend. """ -from rendercanvas.glfw import RenderCanvas, run +from rendercanvas.glfw import RenderCanvas, loop from rendercanvas.utils.cube import setup_drawing_sync @@ -18,4 +18,4 @@ if __name__ == "__main__": - run() + loop.run() diff --git a/examples/demo.py b/examples/demo.py index 436eee5..0d47396 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -15,13 +15,12 @@ import time from rendercanvas.auto import RenderCanvas, loop - from rendercanvas.utils.cube import setup_drawing_sync canvas = RenderCanvas( size=(640, 480), - title="Canvas events on $backend - $fps fps", + title="Canvas events with $backend - $fps fps", max_fps=10, update_mode="continuous", present_method="", @@ -33,7 +32,7 @@ @canvas.add_event_handler("*") -def process_event(event): +async def process_event(event): if event["event_type"] not in ["pointer_move", "before_draw", "animate"]: print(event) diff --git a/rendercanvas/_async_adapter.py b/rendercanvas/_async_adapter.py index 0890dbc..5382d0e 100644 --- a/rendercanvas/_async_adapter.py +++ b/rendercanvas/_async_adapter.py @@ -12,6 +12,9 @@ def __init__(self, when): self.when = when def __await__(self): + # This most be a generator, but it is unspecified what must be yielded; this + # is framework-specific. So we use our own little protocol. + # todo: make that sleep 0 resolves to call_soon yield {"wait_method": "sleep", "when": self.when} diff --git a/rendercanvas/_events.py b/rendercanvas/_events.py index f6166e8..19c7944 100644 --- a/rendercanvas/_events.py +++ b/rendercanvas/_events.py @@ -65,6 +65,7 @@ def __init__(self): self._pending_events = deque() self._event_handlers = defaultdict(list) self._closed = False + # todo: remove all handlers when closing def add_handler(self, *args, order: float = 0): """Register an event handler to receive events. @@ -159,8 +160,6 @@ def submit(self, event): event_type = event["event_type"] if event_type not in EventType: raise ValueError(f"Submitting with invalid event_type: '{event_type}'") - if event_type == "close": - self._closed = True event.setdefault("time_stamp", time.perf_counter()) event_merge_info = self._EVENTS_THAT_MERGE.get(event_type, None) @@ -203,6 +202,8 @@ async def emit(self, event): """ # Collect callbacks event_type = event.get("event_type") + if event_type == "close": + self._closed = True callbacks = self._event_handlers[event_type] + self._event_handlers["*"] # Dispatch for _order, callback in callbacks: @@ -214,9 +215,9 @@ async def emit(self, event): else: callback(event) - def _rc_close(self): + async def _rc_canvas_close(self): """Wrap up when the scheduler detects the canvas is closed/dead.""" # This is a little feature because detecting a widget from closing can be tricky. if not self._closed: - self.submit({"event_type": "close"}) - # todo: !! ??self.flush() + self._pending_events.clear() + await self.emit({"event_type": "close"}) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 6ce57b5..6b64e2c 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -1,14 +1,12 @@ """ -The loop mechanics: the base timer, base loop, and scheduler. +The base loop implementation. """ -import time import signal -import weakref -from ._coreutils import logger, log_exception, BaseEnum - -from ._async_sniffs import sleep, Event # rename this module +from ._coreutils import logger, log_exception +from ._scheduler import Scheduler +from ._async_sniffs import sleep from asyncio import iscoroutinefunction from ._async_adapter import Task as AsyncAdapterTask @@ -22,12 +20,64 @@ ) +class LoopProxy: + """Proxy loop object that canvases can use to register themselves before a loop is selected.""" + + def __init__(self): + self._current_loop = None + self._pending_calls = [] # (method_name, args) elements + + def set_current_loop(self, loop): + if loop is self._current_loop: + return + if self._current_loop: + raise RuntimeError( + "Cannot set the current loop while another loop is active." + ) + self._current_loop = loop + while self._pending_calls: + method_name, args = self._pending_calls.pop(-1) + func = getattr(self._current_loop, method_name) + func(*args) + + def unset_current_loop(self, loop): + if loop is self._current_loop: + self._current_loop = None + else: + raise RuntimeError("Cannot unset loop that is not active.") + + # proxy methods + + def add_scheduler(self, *args): + if self._current_loop: + self._current_loop.add_scheduler(*args) + else: + self._pending_calls.append(("add_scheduler", args)) + + def add_task(self, *args): + if self._current_loop: + self._current_loop.add_task(*args) + else: + self._pending_calls.append(("add_task", args)) + + def call_soon(self, *args): + if self._current_loop: + self._current_loop.call_soon(*args) + else: + self._pending_calls.append(("call_soon", args)) + + +global_loop_proxy = LoopProxy() + + class BaseLoop: """The base class for an event-loop object. Each backend provides its own loop subclass, so that rendercanvas can run cleanly in the backend's event loop. """ + _loop_proxy = global_loop_proxy + def __init__(self): self.__tasks = [] self._schedulers = [] @@ -35,13 +85,9 @@ def __init__(self): self._should_stop = 0 self.__created_loop_task = False - def _register_scheduler(self, scheduler): - # Gets called whenever a canvas in instantiated + def add_scheduler(self, scheduler): + assert isinstance(scheduler, Scheduler) self._schedulers.append(scheduler) - # self._gui_timer.start(0.1) # (re)start our internal timer - if not self.__created_loop_task: - self.__created_loop_task = True - self.add_task(self._loop_task, name="loop") def get_canvases(self): """Get a list of currently active (not-closed) canvases.""" @@ -71,11 +117,10 @@ async def _loop_task(self): while True: await sleep(0.1) - # Keep the GUI alive on every tick. This is extra, since the canvas task also calls it. - self._rc_gui_poll() - - # Clean internal schedulers list - self.get_canvases() + # Clean internal schedulers list, and keep the loop alive + for canvas in self.get_canvases(): + canvas._rc_gui_poll() + del canvas # 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 @@ -85,10 +130,10 @@ async def _loop_task(self): # Should we stop? if not self._schedulers: # Stop when there are no more canvases - self._rc_stop() + break elif self._should_stop >= 2: # force a stop without waiting for the canvases to close - self._rc_stop() + break elif self._should_stop: # Close all remaining canvases. Loop will stop in a next iteration. for canvas in self.get_canvases(): @@ -97,6 +142,8 @@ async def _loop_task(self): canvas._rc_close() del canvas + self._stop() + def add_task(self, async_func, *args, name="unnamed"): if not (callable(async_func) and iscoroutinefunction(async_func)): raise TypeError("call_soon() expects an async function.") @@ -160,6 +207,9 @@ def run(self): # for interactive sessions, where the loop is already running, or started # "behind our back". So we don't need to accomodate for this. + if self._loop_proxy is not None: + self._loop_proxy.set_current_loop(self) + # Cannot run if already running if self._is_inside_run: raise RuntimeError("loop.run() is not reentrant.") @@ -186,6 +236,8 @@ def run(self): async def run_async(self): """ "Alternative to ``run()``, to enter the mainloop from a running async framework.""" + if self._loop_proxy is not None: + self._loop_proxy.set_current_loop(self) await self._rc_run_async() def stop(self): @@ -200,7 +252,17 @@ def stop(self): 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() + self._stop() + + def _stop(self): + if self._loop_proxy is not None: + with log_exception("unset loop:"): + self._loop_proxy.unset_current_loop(self) + for task in self.__tasks: + with log_exception("task cancel:"): + task.cancel() + self.__tasks = [] + self._rc_stop() def __setup_interrupt(self): def on_interrupt(sig, _frame): @@ -252,9 +314,7 @@ def _rc_stop(self): * This will only be called when the process is inside _rc_run(). I.e. not for interactive mode. """ - for task in self.__tasks: - task.cancel() - self.__tasks = [] + raise NotImplementedError() def _rc_add_task(self, async_func, name): """Add an async task to the running loop. @@ -281,213 +341,3 @@ def _rc_call_at(self, when, callback): This method must only be implemented if ``_rc_add_task()`` is not. """ - - def _rc_gui_poll(self): - """Process GUI events. - - Some event loops (e.g. asyncio) are just that and dont have a GUI to update. - Other loops (like Qt) already process events. So this is only intended for - backends like glfw. - """ - pass - - -class UpdateMode(BaseEnum): - """The different modes to schedule draws for the canvas.""" - - manual = None #: Draw events are never scheduled. Draws only happen when you ``canvas.force_draw()``, and maybe when the GUI system issues them (e.g. when resizing). - ondemand = None #: Draws are only scheduled when ``canvas.request_draw()`` is called when an update is needed. Safes your laptop battery. Honours ``min_fps`` and ``max_fps``. - continuous = None #: Continuously schedules draw events, honouring ``max_fps``. Calls to ``canvas.request_draw()`` have no effect. - fastest = None #: Continuously schedules draw events as fast as possible. Gives high FPS (and drains your battery). - - -class Scheduler: - """Helper class to schedule event processing and drawing.""" - - # This class makes the canvas tick. Since we do not own the event-loop, but - # ride on e.g. Qt, asyncio, wx, JS, or something else, our little "loop" is - # implemented with a timer. - # - # The loop looks a little like this: - # - # ________________ __ ________________ __ rd = request_draw - # / wait \ / rd \ / wait \ / rd \ - # | || || || | - # --------------------------------------------------------------------> time - # | | | | | - # schedule tick draw tick draw - # - # With update modes 'ondemand' and 'manual', the loop ticks at the same rate - # as on 'continuous' mode, but won't draw every tick: - # - # ________________ ________________ __ - # / wait \ / wait \ / rd \ - # | || || | - # --------------------------------------------------------------------> time - # | | | | - # schedule tick tick draw - # - # A tick is scheduled by calling _schedule_next_tick(). If this method is - # called when the timer is already running, it has no effect. In the _tick() - # method, events are processed (including animations). Then, depending on - # the mode and whether a draw was requested, a new tick is scheduled, or a - # draw is requested. In the latter case, the timer is not started, but we - # wait for the canvas to perform a draw. In _draw_drame_and_present() the - # draw is done, and a new tick is scheduled. - # - # The next tick is scheduled when a draw is done, and not earlier, otherwise - # the drawing may not keep up with the ticking. - # - # On desktop canvases the draw usually occurs very soon after it is - # requested, but on remote frame buffers, it may take a bit longer. To make - # sure the rendered image reflects the latest state, these backends may - # issue an extra call to _process_events() right before doing the draw. - # - # When the window is minimized, the draw will not occur until the window is - # shown again. For the canvas to detect minimized-state, it will need to - # receive GUI events. This is one of the reasons why the loop object also - # runs a timer-loop. - # - # The drawing itself may take longer than the intended wait time. In that - # case, it will simply take longer than we hoped and get a lower fps. - # - # Note that any extra draws, e.g. via force_draw() or due to window resizes, - # don't affect the scheduling loop; they are just extra draws. - - def __init__(self, canvas, events, loop, *, mode="ondemand", min_fps=1, max_fps=30): - assert loop is not None - - # We don't keep a ref to the canvas to help gc. This scheduler object can be - # referenced via a callback in an event loop, but it won't prevent the canvas - # from being deleted! - self._canvas_ref = weakref.ref(canvas) - self._events = events - # ... = canvas.get_context() -> No, context creation should be lazy! - - # Scheduling variables - if mode not in UpdateMode: - raise ValueError( - f"Invalid update_mode '{mode}', must be in {set(UpdateMode)}." - ) - self._mode = mode - self._min_fps = float(min_fps) - self._max_fps = float(max_fps) - self._draw_requested = True # Start with a draw in ondemand mode - self._last_draw_time = 0 - self._async_draw_event = None - - # Keep track of fps - self._draw_stats = 0, time.perf_counter() - - # Initialise the timer that runs our scheduling loop. - # Note that the backend may do a first draw earlier, starting the loop, and that's fine. - self._last_tick_time = -0.1 - # self._timer = loop.call_later(0.1, self._tick) - - # Register this scheduler/canvas at the loop object - loop._register_scheduler(self) - loop.add_task( - self.__scheduler_task, name=f"{canvas.__class__.__name__} scheduler" - ) - - 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.get_closed(): - # Pretty nice, we can send a close event, even if the canvas no longer exists - self._events._rc_close() - return None - else: - return canvas - - def request_draw(self): - """Request a new draw to be done. Only affects the 'ondemand' mode.""" - # Just set the flag - self._draw_requested = True - - async def __scheduler_task(self): - """Schedule _tick() to be called via our timer.""" - - while True: - # Determine delay - if self._mode == "fastest" or self._max_fps <= 0: - delay = 0 - else: - delay = 1 / self._max_fps - delay = 0 if delay < 0 else delay # 0 means cannot keep up - - # Offset delay for time spent on processing events, etc. - time_since_tick_start = time.perf_counter() - self._last_tick_time - delay -= time_since_tick_start - delay = max(0, delay) - - # Wait - await sleep(delay) - - # Get canvas or stop - if (canvas := self.get_canvas()) is None: - return - - # Below is the "tick" - - self._last_tick_time = time.perf_counter() - - # Process events, handlers may request a draw - await canvas._process_events() - - # Determine what to do next ... - - do_draw = False - - if self._mode == "fastest": - # fastest: draw continuously as fast as possible, ignoring fps settings. - do_draw = True - elif self._mode == "continuous": - # continuous: draw continuously, aiming for a steady max framerate. - do_draw = True - elif self._mode == "ondemand": - # ondemand: draw when needed (detected by calls to request_draw). - # Aim for max_fps when drawing is needed, otherwise min_fps. - if self._draw_requested: - do_draw = True - elif ( - self._min_fps > 0 - and time.perf_counter() - self._last_draw_time > 1 / self._min_fps - ): - do_draw = True - - elif self._mode == "manual": - pass - else: - raise RuntimeError(f"Unexpected scheduling mode: '{self._mode}'") - - # If we don't want to draw, we move to the next iter - if not do_draw: - continue - - canvas._rc_request_draw() - self._async_draw_event = Event() - await self._async_draw_event.wait() - - def on_draw(self): - # Bookkeeping - self._last_draw_time = time.perf_counter() - self._draw_requested = False - - # Keep ticking - if self._async_draw_event: - self._async_draw_event.set() - self._async_draw_event = None - - # Update stats - count, last_time = self._draw_stats - count += 1 - if time.perf_counter() - last_time > 1.0: - fps = count / (time.perf_counter() - last_time) - self._draw_stats = 0, time.perf_counter() - else: - fps = None - self._draw_stats = count, last_time - - # Return fps or None. Will change with better stats at some point - return fps diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py new file mode 100644 index 0000000..0773577 --- /dev/null +++ b/rendercanvas/_scheduler.py @@ -0,0 +1,205 @@ +""" +The scheduler class/loop. +""" + +import time +import weakref + +from ._coreutils import BaseEnum +from ._async_sniffs import sleep, Event # todo: rename this module + + +class UpdateMode(BaseEnum): + """The different modes to schedule draws for the canvas.""" + + manual = None #: Draw events are never scheduled. Draws only happen when you ``canvas.force_draw()``, and maybe when the GUI system issues them (e.g. when resizing). + ondemand = None #: Draws are only scheduled when ``canvas.request_draw()`` is called when an update is needed. Safes your laptop battery. Honours ``min_fps`` and ``max_fps``. + continuous = None #: Continuously schedules draw events, honouring ``max_fps``. Calls to ``canvas.request_draw()`` have no effect. + fastest = None #: Continuously schedules draw events as fast as possible. Gives high FPS (and drains your battery). + + +class Scheduler: + """Helper class to schedule event processing and drawing.""" + + # This class makes the canvas tick. Since we do not own the event-loop, but + # ride on e.g. Qt, asyncio, wx, JS, or something else, our little "loop" is + # implemented with a timer. + # + # The loop looks a little like this: + # + # ________________ __ ________________ __ rd = request_draw + # / wait \ / rd \ / wait \ / rd \ + # | || || || | + # --------------------------------------------------------------------> time + # | | | | | + # schedule tick draw tick draw + # + # With update modes 'ondemand' and 'manual', the loop ticks at the same rate + # as on 'continuous' mode, but won't draw every tick: + # + # ________________ ________________ __ + # / wait \ / wait \ / rd \ + # | || || | + # --------------------------------------------------------------------> time + # | | | | + # schedule tick tick draw + # + # A tick is scheduled by calling _schedule_next_tick(). If this method is + # called when the timer is already running, it has no effect. In the _tick() + # method, events are processed (including animations). Then, depending on + # the mode and whether a draw was requested, a new tick is scheduled, or a + # draw is requested. In the latter case, the timer is not started, but we + # wait for the canvas to perform a draw. In _draw_drame_and_present() the + # draw is done, and a new tick is scheduled. + # + # The next tick is scheduled when a draw is done, and not earlier, otherwise + # the drawing may not keep up with the ticking. + # + # On desktop canvases the draw usually occurs very soon after it is + # requested, but on remote frame buffers, it may take a bit longer. To make + # sure the rendered image reflects the latest state, these backends may + # issue an extra call to _process_events() right before doing the draw. + # + # When the window is minimized, the draw will not occur until the window is + # shown again. For the canvas to detect minimized-state, it will need to + # receive GUI events. This is one of the reasons why the loop object also + # runs a timer-loop. + # + # The drawing itself may take longer than the intended wait time. In that + # case, it will simply take longer than we hoped and get a lower fps. + # + # Note that any extra draws, e.g. via force_draw() or due to window resizes, + # don't affect the scheduling loop; they are just extra draws. + + def __init__(self, canvas, events, loop, *, mode="ondemand", min_fps=1, max_fps=30): + self.name = f"{canvas.__class__.__name__} scheduler" + assert loop is not None + + # We don't keep a ref to the canvas to help gc. This scheduler object can be + # referenced via a callback, but it won't prevent the canvas from being deleted! + self._canvas_ref = weakref.ref(canvas) + self._events = events + # ... = canvas.get_context() -> No, context creation should be lazy! + + # Scheduling variables + if mode not in UpdateMode: + raise ValueError( + f"Invalid update_mode '{mode}', must be in {set(UpdateMode)}." + ) + self._mode = mode + self._min_fps = float(min_fps) + self._max_fps = float(max_fps) + self._draw_requested = True # Start with a draw in ondemand mode + self._last_draw_time = 0 + self._async_draw_event = None + + # Keep track of fps + self._draw_stats = 0, time.perf_counter() + + self._last_tick_time = -0.1 + # todo: some vars maybe moved to inside the coro + + loop.add_scheduler(self) + loop.add_task(self.__scheduler_task) + + 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.get_closed(): + return None + else: + return canvas + + def request_draw(self): + """Request a new draw to be done. Only affects the 'ondemand' mode.""" + # Just set the flag + self._draw_requested = True + + async def __scheduler_task(self): + """The coro that reprsents the scheduling loop for a canvas.""" + + while True: + # Determine delay + if self._mode == "fastest" or self._max_fps <= 0: + delay = 0 + else: + delay = 1 / self._max_fps + delay = 0 if delay < 0 else delay # 0 means cannot keep up + + # Offset delay for time spent on processing events, etc. + time_since_tick_start = time.perf_counter() - self._last_tick_time + delay -= time_since_tick_start + delay = max(0, delay) + + # Wait + await sleep(delay) + + # Get canvas or stop + if (canvas := self.get_canvas()) is None: + break + + # Below is the "tick" + + self._last_tick_time = time.perf_counter() + + # Process events, handlers may request a draw + await canvas._process_events() + + # Determine what to do next ... + + do_draw = False + + if self._mode == "fastest": + # fastest: draw continuously as fast as possible, ignoring fps settings. + do_draw = True + elif self._mode == "continuous": + # continuous: draw continuously, aiming for a steady max framerate. + do_draw = True + elif self._mode == "ondemand": + # ondemand: draw when needed (detected by calls to request_draw). + # Aim for max_fps when drawing is needed, otherwise min_fps. + if self._draw_requested: + do_draw = True + elif ( + self._min_fps > 0 + and time.perf_counter() - self._last_draw_time > 1 / self._min_fps + ): + do_draw = True + + elif self._mode == "manual": + pass + else: + raise RuntimeError(f"Unexpected scheduling mode: '{self._mode}'") + + # If we don't want to draw, we move to the next iter + if not do_draw: + continue + + canvas._rc_request_draw() + self._async_draw_event = Event() + await self._async_draw_event.wait() + + await self._events._rc_canvas_close() + + def on_draw(self): + # Bookkeeping + self._last_draw_time = time.perf_counter() + self._draw_requested = False + + # Keep ticking + if self._async_draw_event: + self._async_draw_event.set() + self._async_draw_event = None + + # Update stats + count, last_time = self._draw_stats + count += 1 + if time.perf_counter() - last_time > 1.0: + fps = count / (time.perf_counter() - last_time) + self._draw_stats = 0, time.perf_counter() + else: + fps = None + self._draw_stats = count, last_time + + # Return fps or None. Will change with better stats at some point + return fps diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py new file mode 100644 index 0000000..293c709 --- /dev/null +++ b/rendercanvas/asyncio.py @@ -0,0 +1,50 @@ +""" +Implements an asyncio event loop for backends that don't have an event loop by themselves, like glfw. +""" + +__all__ = ["AsyncioLoop", "loop"] + + +from .base import BaseLoop + + +class AsyncioLoop(BaseLoop): + _the_loop = None + + def __init__(self): + super().__init__() + self._tasks = [] + + @property + def _loop(self): + if self._the_loop is None: + import asyncio + + try: + self._the_loop = asyncio.get_running_loop() + except Exception: + pass + if self._the_loop is None: + self._the_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._the_loop) + return self._the_loop + + def _rc_add_task(self, func, name): + task = self._loop.create_task(func(), name=name) + self._tasks.append(task) + task.add_done_callback(self._tasks.remove) + return task + + def _rc_run(self): + if not self._loop.is_running(): + self._loop.run_forever() + + def _rc_stop(self): + # Note: is only called when we're inside _rc_run + self._loop.stop() + while self._tasks: + t = self._tasks.pop(-1) + t.cancel() # is a no-op if the task is no longer running + + +loop = AsyncioLoop() diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 31ed77d..0cb5600 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -7,7 +7,8 @@ import importlib from ._events import EventEmitter, EventType # noqa: F401 -from ._loop import Scheduler, BaseLoop +from ._loop import BaseLoop +from ._scheduler import Scheduler from ._coreutils import logger, log_exception @@ -87,7 +88,7 @@ def __init__( self.__scheduler = Scheduler( self, self._events, - self._rc_get_loop(), + loop, min_fps=min_fps, max_fps=max_fps, mode=update_mode, @@ -242,9 +243,7 @@ async def _process_events(self): # when there are no draws (in ondemand and manual mode). # Get events from the GUI into our event mechanism. - loop = self._rc_get_loop() - if loop: - loop._rc_gui_poll() + self._rc_gui_poll() # Flush our events, so downstream code can update stuff. # Maybe that downstream code request a new draw. @@ -417,11 +416,15 @@ def set_title(self, title): def _rc_get_loop(self): """Get the loop instance for this backend. - Must return the global loop instance (a BaseLoop subclass) for the canvas subclass, - or None for a canvas without scheduled draws. + Must return the global loop instance (a BaseLoop subclass) or a + compatible proxy object, or None for a canvas without scheduled draws. """ return None + def _rc_gui_poll(self): + """Process GUI events.""" + pass + def _rc_get_present_methods(self): """Get info on the present methods supported by this canvas. diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 8cab230..7618a2c 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -16,7 +16,8 @@ import glfw from .base import BaseRenderCanvas -from .loops import AsyncioLoop +from ._loop import global_loop_proxy +from .asyncio import loop from ._coreutils import SYSTEM_IS_WAYLAND, weakbind, logger @@ -146,13 +147,23 @@ def get_physical_size(window): return int(psize[0]), int(psize[1]) +glfw_is_terminated = False + + +@atexit.register +def terminate_glfw(): + global glfw_is_terminated + glfw_is_terminated = True + glfw.terminate() + + class GlfwRenderCanvas(BaseRenderCanvas): """A glfw window providing a render canvas.""" # See https://www.glfw.org/docs/latest/group__window.html def __init__(self, *args, present_method=None, **kwargs): - loop.init_glfw() + glfw.init() super().__init__(*args, **kwargs) if present_method == "bitmap": @@ -240,8 +251,6 @@ def _maybe_close(self): if glfw.window_should_close(self._window): self._rc_close() - # %% Methods to implement RenderCanvas - def _set_logical_size(self, new_logical_size): if self._window is None: return @@ -278,15 +287,22 @@ def _set_logical_size(self, new_logical_size): if pixel_ratio != self._pixel_ratio: self._determine_size() + # %% Methods to implement RenderCanvas + def _rc_get_loop(self): - return loop + return global_loop_proxy + + def _rc_gui_poll(self): + glfw.post_empty_event() # Awake the event loop, if it's in wait-mode + glfw.poll_events() + self._maybe_close() def _rc_get_present_methods(self): return get_glfw_present_methods(self._window) def _rc_request_draw(self): if not self._is_minimized: - loop.call_soon(self._draw_frame_and_present) + self._rc_get_loop().call_soon(self._draw_frame_and_present) def _rc_force_draw(self): self._draw_frame_and_present() @@ -312,12 +328,14 @@ 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: + if glfw_is_terminated: + # May not always be able to close the proper way on system exit + self._window = None + elif self._window is not None: glfw.destroy_window(self._window) # not just glfw.hide_window self._window = None self.submit_event({"event_type": "close"}) + poll_glfw_briefly(0.02) def _rc_get_closed(self): return self._window is None @@ -517,41 +535,6 @@ def _on_char(self, window, char): self.submit_event(ev) -# Make available under a name that is the same for all backends -RenderCanvas = GlfwRenderCanvas - - -class GlfwLoop(AsyncioLoop): - def __init__(self): - super().__init__() - self._glfw_initialized = False - atexit.register(self._terminate_glfw) - - def init_glfw(self): - if not self._glfw_initialized: - glfw.init() # Note: safe to call multiple times - self._glfw_initialized = True - - 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() - - def _rc_run(self): - super()._rc_run() - poll_glfw_briefly() - - -loop = GlfwLoop() -run = loop.run # backwards compat - - def poll_glfw_briefly(poll_time=0.1): """Briefly poll glfw for a set amount of time. @@ -565,3 +548,9 @@ def poll_glfw_briefly(poll_time=0.1): end_time = time.perf_counter() + poll_time while time.perf_counter() < end_time: glfw.wait_events_timeout(end_time - time.perf_counter()) + + +# Make available under a name that is the same for all backends +# Note that the loop object is just a default; this backend can be used with any loop +RenderCanvas = GlfwRenderCanvas +run = loop.run # backwards compat diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index bdf0001..28e3a65 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -54,7 +54,10 @@ def get_frame(self): # %% Methods to implement RenderCanvas def _rc_get_loop(self): - return loop + return loop # asyncio only + + def _rc_gui_poll(self): + pass # The Jupyter UI is running in a separate process :) def _rc_get_present_methods(self): # We stick to the two common formats, because these can be easily converted to png @@ -133,9 +136,6 @@ def __init__(self): super().__init__() self._pending_jupyter_canvases = [] - def _rc_gui_poll(self): - pass # Jupyter is running in a separate process :) - def run(self): # Show all widgets that have been created so far. # No need to actually start an event loop, since Jupyter already runs it. diff --git a/rendercanvas/loops.py b/rendercanvas/loops.py deleted file mode 100644 index bd602a6..0000000 --- a/rendercanvas/loops.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Implements an asyncio event loop, used in some backends. -""" - -# This is used for backends that don't have an event loop by themselves, like glfw. -# Would be nice to also allow a loop based on e.g. Trio. But we can likely fit that in -# when the time comes. - -__all__ = ["AsyncioLoop", "TrioLoop"] - - -from .base import BaseLoop - - -class AsyncioLoop(BaseLoop): - _the_loop = None - - def __init__(self): - super().__init__() - self._tasks = [] - - @property - def _loop(self): - if self._the_loop is None: - import asyncio - - try: - self._the_loop = asyncio.get_running_loop() - except Exception: - pass - if self._the_loop is None: - self._the_loop = asyncio.new_event_loop() - asyncio.set_event_loop(self._the_loop) - return self._the_loop - - def _rc_add_task(self, func, name): - task = self._loop.create_task(func(), name=name) - self._tasks.append(task) - task.add_done_callback(self._tasks.remove) - return task - - def _rc_run(self): - if not self._loop.is_running(): - self._loop.run_forever() - - def _rc_stop(self): - # Note: is only called when we're inside _rc_run - self._loop.stop() - while self._tasks: - t = self._tasks.pop(-1) - t.cancel() # is a no-op if the task is no longer running - - # def _rc_call_soon(self, callback, *args): - # self._loop.call_soon(callback, *args) - - def _rc_gui_poll(self): - pass - - -class TrioLoop(BaseLoop): - def __init__(self): - super().__init__() - import trio - - self._pending_tasks = [] - self._cancel_scope = None - self._send_channel, self._receive_channel = trio.open_memory_channel(99) - - def _rc_add_task(self, async_func, name): - self._send_channel.send_nowait((async_func, name)) - return None - - async def _rc_run_async(self): - import trio - - with trio.CancelScope() as self._cancel_scope: - async with trio.open_nursery() as nursery: - while True: - async_func, name = await self._receive_channel.receive() - nursery.start_soon(async_func, name=name) - self._cancel_scope = None - - def _rc_run(self): - import trio - - trio.run(self.run_async, restrict_keyboard_interrupt_to_checkpoints=False) - - def _rc_stop(self): - # Cancel the main task and all its child tasks. - if self._cancel_scope is not None: - self._cancel_scope.cancel() diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index cc21db3..d18ac81 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -23,7 +23,10 @@ def __init__(self, *args, pixel_ratio=1.0, **kwargs): # %% Methods to implement RenderCanvas def _rc_get_loop(self): - return None # No scheduling + return None # no scheduling + + def _rc_gui_poll(self): + pass def _rc_get_present_methods(self): return { @@ -116,9 +119,6 @@ def _rc_stop(self): def _rc_call_soon(self, callback): super()._rc_call_soon(callback) - def _rc_gui_poll(self): - pass - loop = StubLoop() run = loop.run diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index f4f4fa0..9ef1d3c 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -236,7 +236,10 @@ def update(self): # %% Methods to implement RenderCanvas def _rc_get_loop(self): - return loop + return loop # qt loop only + + def _rc_gui_poll(self): + pass # we assume the Qt event loop is running. Calling processEvents() will cause recursive repaints. def _rc_get_present_methods(self): global _show_image_method_warning @@ -513,11 +516,6 @@ def closeEvent(self, event): # noqa: N802 self._subwidget.closeEvent(event) -# Make available under a name that is the same for all gui backends -RenderWidget = QRenderWidget -RenderCanvas = QRenderCanvas - - class QtLoop(BaseLoop): def init_qt(self): _ = self._app @@ -561,12 +559,12 @@ def _rc_run(self): def _rc_stop(self): # Note: is only called when we're inside _rc_run - super()._rc_stop() self._app.quit() - def _rc_gui_poll(self): - pass # we assume the Qt event loop is running. Calling processEvents() will cause recursive repaints. - loop = QtLoop() + +# Make available under a name that is the same for all gui backends +RenderWidget = QRenderWidget +RenderCanvas = QRenderCanvas run = loop.run # backwards compat diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index e4d02f7..44ca62f 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -31,6 +31,9 @@ def _draw_frame_and_present(self): def _rc_get_loop(self): return None + def _rc_gui_poll(self): + pass + def _rc_get_present_methods(self): raise NotImplementedError() @@ -92,9 +95,6 @@ def _rc_stop(self): def _rc_call_soon(self, callback, *args): self.call_later(0, callback, *args) - def _rc_gui_poll(self): - pass - # Make available under a common name RenderCanvas = StubRenderCanvas diff --git a/rendercanvas/trio.py b/rendercanvas/trio.py new file mode 100644 index 0000000..19bea72 --- /dev/null +++ b/rendercanvas/trio.py @@ -0,0 +1,45 @@ +""" +Implements a trio event loop for backends that don't have an event loop by themselves, like glfw. +""" + +__all__ = ["TrioLoop", "loop"] + + +from .base import BaseLoop + + +class TrioLoop(BaseLoop): + def __init__(self): + super().__init__() + import trio + + self._pending_tasks = [] + self._cancel_scope = None + self._send_channel, self._receive_channel = trio.open_memory_channel(99) + + def _rc_add_task(self, async_func, name): + self._send_channel.send_nowait((async_func, name)) + return None + + async def _rc_run_async(self): + import trio + + with trio.CancelScope() as self._cancel_scope: + async with trio.open_nursery() as nursery: + while True: + async_func, name = await self._receive_channel.receive() + nursery.start_soon(async_func, name=name) + self._cancel_scope = None + + def _rc_run(self): + import trio + + trio.run(self.run_async, restrict_keyboard_interrupt_to_checkpoints=False) + + def _rc_stop(self): + # Cancel the main task and all its child tasks. + if self._cancel_scope is not None: + self._cancel_scope.cancel() + + +loop = TrioLoop() diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 0762727..e9d84db 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -202,7 +202,10 @@ def _get_surface_ids(self): # %% Methods to implement RenderCanvas def _rc_get_loop(self): - return loop + return loop # wx loop only + + def _rc_gui_poll(self): + pass # We can assume the wx loop is running. def _rc_get_present_methods(self): if self._surface_ids is None: @@ -450,11 +453,6 @@ def Destroy(self): # noqa: N802 - this is a wx method super().Destroy() -# Make available under a name that is the same for all gui backends -RenderWidget = WxRenderWidget -RenderCanvas = WxRenderCanvas - - class TimerWithCallback(wx.Timer): def __init__(self, callback): super().__init__() @@ -493,9 +491,6 @@ def _rc_stop(self): # to close all windows before stopping a loop. pass - def _rc_gui_poll(self): - pass # We can assume the wx loop is running. - def process_wx_events(self): old = wx.GUIEventLoop.GetActive() new = wx.GUIEventLoop() @@ -506,4 +501,9 @@ def process_wx_events(self): loop = WxLoop() + + +# Make available under a name that is the same for all gui backends +RenderWidget = WxRenderWidget +RenderCanvas = WxRenderCanvas run = loop.run # backwards compat From d5af118307a63f67800da43956a6f88150ab92df Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 28 Nov 2024 16:43:30 +0100 Subject: [PATCH 03/22] clean and tweak --- examples/cube_glfw.py | 2 +- examples/cube_trio.py | 22 ++++++++ rendercanvas/_async_adapter.py | 26 ++++----- rendercanvas/_loop.py | 54 +++++++------------ rendercanvas/_scheduler.py | 19 +++---- rendercanvas/base.py | 7 --- rendercanvas/jupyter.py | 8 --- rendercanvas/offscreen.py | 18 +++---- rendercanvas/qt.py | 8 +-- rendercanvas/stub.py | 11 ++-- .../{_async_sniffs.py => utils/asyncs.py} | 8 +++ rendercanvas/wx.py | 4 +- tests/test_scheduling.py | 4 +- 13 files changed, 95 insertions(+), 96 deletions(-) create mode 100644 examples/cube_trio.py rename rendercanvas/{_async_sniffs.py => utils/asyncs.py} (50%) diff --git a/examples/cube_glfw.py b/examples/cube_glfw.py index 6bd5e9f..040468b 100644 --- a/examples/cube_glfw.py +++ b/examples/cube_glfw.py @@ -2,7 +2,7 @@ Cube glfw --------- -Run a wgpu example on the glfw backend. +Run a wgpu example on the glfw backend (with the default asyncio loop). """ from rendercanvas.glfw import RenderCanvas, loop diff --git a/examples/cube_trio.py b/examples/cube_trio.py new file mode 100644 index 0000000..adc8bca --- /dev/null +++ b/examples/cube_trio.py @@ -0,0 +1,22 @@ +""" +Cube trio +--------- + +Run a wgpu example on the glfw backend, and the trio loop +""" + +from rendercanvas.glfw import RenderCanvas +from rendercanvas.trio import loop + +from rendercanvas.utils.cube import setup_drawing_sync + + +canvas = RenderCanvas( + title="The wgpu cube example on $backend", update_mode="continuous" +) +draw_frame = setup_drawing_sync(canvas) +canvas.request_draw(draw_frame) + + +if __name__ == "__main__": + loop.run() diff --git a/rendercanvas/_async_adapter.py b/rendercanvas/_async_adapter.py index 5382d0e..68a6930 100644 --- a/rendercanvas/_async_adapter.py +++ b/rendercanvas/_async_adapter.py @@ -1,3 +1,7 @@ +""" +A micro async framework that only support sleep() and Event. Behaves well with sniffio. +""" + import time import logging @@ -8,14 +12,15 @@ class Sleeper: - def __init__(self, when): - self.when = when + """Awaitable to implement sleep.""" + + def __init__(self, delay): + self.delay = delay def __await__(self): # This most be a generator, but it is unspecified what must be yielded; this # is framework-specific. So we use our own little protocol. - # todo: make that sleep 0 resolves to call_soon - yield {"wait_method": "sleep", "when": self.when} + yield {"wait_method": "sleep", "delay": self.delay} async def sleep(delay): @@ -42,7 +47,7 @@ def _add_task(self, task): def set(self): self._is_set = True for task in self._tasks: - task.call_step_soon() + task.call_step_later(0) self._tasks = [] @@ -59,7 +64,7 @@ def __init__(self, loop, coro, name): self.name = name self.cancelled = False self._done_callbacks = [] - self.call_step_soon() + self.call_step_later(0) def add_done_callback(self, callback): self._done_callbacks.append(callback) @@ -73,11 +78,8 @@ def _close(self): except Exception: pass - def call_step_soon(self): - self.loop._rc_call_soon(self.step) - - def call_step_at(self, when): - self.loop._rc_call_at(when, self.step) + def call_step_later(self, delay): + self.loop._rc_call_later(delay, self.step) def cancel(self): self.cancelled = True @@ -120,7 +122,7 @@ def step(self): wait_method = result["wait_method"] if wait_method == "sleep": - self.call_step_at(result["when"]) + self.call_step_later(result["delay"]) elif wait_method == "event": result["event"]._add_task(self) else: diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 6b64e2c..044729b 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -6,7 +6,7 @@ from ._coreutils import logger, log_exception from ._scheduler import Scheduler -from ._async_sniffs import sleep +from .utils.asyncs import sleep from asyncio import iscoroutinefunction from ._async_adapter import Task as AsyncAdapterTask @@ -145,8 +145,9 @@ async def _loop_task(self): self._stop() def add_task(self, async_func, *args, name="unnamed"): + """Run an async function in the event-loop.""" if not (callable(async_func) and iscoroutinefunction(async_func)): - raise TypeError("call_soon() expects an async function.") + raise TypeError("add_task() expects an async function.") async def wrapper(): with log_exception(f"Error in {name} task:"): @@ -170,31 +171,20 @@ async def wrapper(): callback(*args) self._rc_add_task(wrapper, "call_soon") - # self._rc_call_soon(callback, *args) def call_later(self, delay, callback, *args): - """Arrange for a callback to be called after the given delay (in seconds). - - Returns a timer object (in one-shot mode) that can be used to - stop the time (i.e. cancel the callback being called), and/or - to restart the timer. - - It's not necessary to hold a reference to the timer object; a - ref is held automatically, and discarded when the timer ends or stops. - """ - raise NotImplementedError() - - def call_repeated(self, interval, callback, *args): - """Arrange for a callback to be called repeatedly. + """Arrange for a callback to be called after the given delay (in seconds).""" + if not callable(callback): + raise TypeError("call_later() expects a callable.") + elif iscoroutinefunction(callback): + raise TypeError("call_later() expects a normal callable, not an async one.") - Returns a timer object (in multi-shot mode) that can be used for - further control. + async def wrapper(): + with log_exception("Callback error:"): + await sleep(delay) + callback(*args) - It's not necessary to hold a reference to the timer object; a - ref is held automatically, and discarded when the timer is - stopped. - """ - raise NotImplementedError() + self._rc_add_task(wrapper, "call_later") def run(self): """Enter the main loop. @@ -310,9 +300,7 @@ def _rc_stop(self): * Stop the running event loop. * Cancel any remaining tasks. - * todo: is the below still (supposed to be) true? - * This will only be called when the process is inside _rc_run(). - I.e. not for interactive mode. + * This will only be called when the process is inside _rc_run(), i.e. not for interactive mode. """ raise NotImplementedError() @@ -320,7 +308,7 @@ def _rc_add_task(self, async_func, name): """Add an async task to the running loop. This method is optional. A backend must either implement ``_rc_add_task`` - or implement both ``_rc_call_soon()`` and ``_rc_call_at``. + or implement ``_rc_call_later``. * Schedule running the task defined by the given co-routine function. * The name is for debugging purposes only. @@ -330,14 +318,10 @@ def _rc_add_task(self, async_func, name): self.__tasks.append(task) task.add_done_callback(self.__tasks.remove) - def _rc_call_soon(self, callback): - """Method to call a callback in the next iteraction of the event-loop. - - This method must only be implemented if ``_rc_add_task()`` is not. - """ - - def _rc_call_at(self, when, callback): - """Method to call a callback at a specific time. + def _rc_call_later(self, delay, callback): + """Method to call a callback in delay number of seconds. This method must only be implemented if ``_rc_add_task()`` is not. + If delay is zero, this should behave like "call_later". """ + raise NotImplementedError() diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index 0773577..afef181 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -6,7 +6,7 @@ import weakref from ._coreutils import BaseEnum -from ._async_sniffs import sleep, Event # todo: rename this module +from .utils.asyncs import sleep, Event class UpdateMode(BaseEnum): @@ -90,15 +90,11 @@ def __init__(self, canvas, events, loop, *, mode="ondemand", min_fps=1, max_fps= self._min_fps = float(min_fps) self._max_fps = float(max_fps) self._draw_requested = True # Start with a draw in ondemand mode - self._last_draw_time = 0 self._async_draw_event = None # Keep track of fps self._draw_stats = 0, time.perf_counter() - self._last_tick_time = -0.1 - # todo: some vars maybe moved to inside the coro - loop.add_scheduler(self) loop.add_task(self.__scheduler_task) @@ -118,6 +114,9 @@ def request_draw(self): async def __scheduler_task(self): """The coro that reprsents the scheduling loop for a canvas.""" + last_draw_time = 0 + last_tick_time = -0.1 + while True: # Determine delay if self._mode == "fastest" or self._max_fps <= 0: @@ -127,7 +126,7 @@ async def __scheduler_task(self): delay = 0 if delay < 0 else delay # 0 means cannot keep up # Offset delay for time spent on processing events, etc. - time_since_tick_start = time.perf_counter() - self._last_tick_time + time_since_tick_start = time.perf_counter() - last_tick_time delay -= time_since_tick_start delay = max(0, delay) @@ -140,7 +139,7 @@ async def __scheduler_task(self): # Below is the "tick" - self._last_tick_time = time.perf_counter() + last_tick_time = time.perf_counter() # Process events, handlers may request a draw await canvas._process_events() @@ -162,7 +161,7 @@ async def __scheduler_task(self): do_draw = True elif ( self._min_fps > 0 - and time.perf_counter() - self._last_draw_time > 1 / self._min_fps + and time.perf_counter() - last_draw_time > 1 / self._min_fps ): do_draw = True @@ -175,15 +174,17 @@ async def __scheduler_task(self): if not do_draw: continue + await self._events.emit({"event_type": "before_draw"}) + canvas._rc_request_draw() self._async_draw_event = Event() await self._async_draw_event.wait() + last_draw_time = time.perf_counter() await self._events._rc_canvas_close() def on_draw(self): # Bookkeeping - self._last_draw_time = time.perf_counter() self._draw_requested = False # Keep ticking diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 0cb5600..6d5d2d7 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -230,7 +230,6 @@ def submit_event(self, event): # %% Scheduling and drawing - # todo: now that it's async, we cannot call it from anywhere ... async def _process_events(self): """Process events and animations. @@ -333,7 +332,6 @@ def _draw_frame_and_present(self): # Process special events # Note that we must not process normal events here, since these can do stuff # with the canvas (resize/close/etc) and most GUI systems don't like that. - # todo: self._events.emit({"event_type": "before_draw"}) # Notify the scheduler if self.__scheduler is not None: @@ -460,11 +458,6 @@ def _rc_request_draw(self): for the canvas subclass to make sure that a draw is made as soon as possible. - Canvases that have a limit on how fast they can 'consume' frames, like - remote frame buffers, do good to call self._process_events() when the - draw had to wait a little. That way the user interaction will lag as - little as possible. - The default implementation does nothing, which is equivalent to waiting for a forced draw or a draw invoked by the GUI system. """ diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index 28e3a65..719d25b 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -40,14 +40,6 @@ def get_frame(self): # present_context.present(), which calls our present() method. # The result is either a numpy array or None, and this matches # with what this method is expected to return. - - # When we had to wait relatively long for the drawn to be made, - # we do another round processing events, to minimize the perceived lag. - # We only do this when the delay is significant, so that under good - # circumstances, the scheduling behaves the same as for other canvases. - if time.perf_counter() - self._draw_request_time > 0.02: - self._process_events() - self._draw_frame_and_present() return self._last_image diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index d18ac81..9789e91 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -102,22 +102,20 @@ class StubLoop(BaseLoop): # In summary, we provide a call_later() and run() that behave pretty # well for the first case. - def _process_timers(self): + def _rc_run(self): pass - # Running this loop processes any timers - # todo: xxxx - # for timer in list(BaseTimer._running_timers): - # if timer.time_left <= 0: - # timer._tick() - def _rc_run(self): - self._process_timers() + async def _rc_run_async(self): + raise NotImplementedError() def _rc_stop(self): pass - def _rc_call_soon(self, callback): - super()._rc_call_soon(callback) + def _rc_add_task(self, async_func, name): + pass + + def _rc_call_later(self, delay, callback): + pass loop = StubLoop() diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 9ef1d3c..3499d21 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -6,7 +6,6 @@ __all__ = ["QRenderWidget", "RenderCanvas", "RenderWidget", "loop"] import sys -import time import ctypes import importlib @@ -530,11 +529,8 @@ def _app(self): self._the_app = app = QtWidgets.QApplication([]) return app - def _rc_call_soon(self, callback): - QtCore.QTimer.singleShot(0, callback) - - def _rc_call_at(self, when, callback): - delay_ms = int(max(0, (when - time.perf_counter()) * 1000)) + def _rc_call_later(self, delay, callback): + delay_ms = int(max(0, delay * 1000)) QtCore.QTimer.singleShot(delay_ms, callback) def _rc_add_task(self, *args): diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index 44ca62f..b19e19a 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -20,8 +20,8 @@ class StubRenderCanvas(BaseRenderCanvas): def _final_canvas_init(self): return super()._final_canvas_init() - def _process_events(self): - return super()._process_events() + async def _process_events(self): + return await super()._process_events() def _draw_frame_and_present(self): return super()._draw_frame_and_present() @@ -92,8 +92,11 @@ def _rc_run(self): def _rc_stop(self): raise NotImplementedError() - def _rc_call_soon(self, callback, *args): - self.call_later(0, callback, *args) + def _rc_add_task(self): + raise NotImplementedError() + + def _rc_call_later(self, delay, callback): + raise NotImplementedError() # Make available under a common name diff --git a/rendercanvas/_async_sniffs.py b/rendercanvas/utils/asyncs.py similarity index 50% rename from rendercanvas/_async_sniffs.py rename to rendercanvas/utils/asyncs.py index 93b8998..398c867 100644 --- a/rendercanvas/_async_sniffs.py +++ b/rendercanvas/utils/asyncs.py @@ -1,14 +1,22 @@ +""" +This module implements all async functionality that one can use in any backend. +This uses sniffio to detect the async framework in use. +""" + import sys import sniffio async def sleep(delay): + """Generic async sleep. Works with trio, asyncio and rendercanvas-native.""" libname = sniffio.current_async_library() sleep = sys.modules[libname].sleep await sleep(delay) class Event: + """Generic async event object. Works with trio, asyncio and rendercanvas-native.""" + def __new__(cls): libname = sniffio.current_async_library() Event = sys.modules[libname].Event # noqa diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index e9d84db..a8d9276 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -479,8 +479,8 @@ def _app(self): wx.App.SetInstance(app) return app - def _rc_call_soon(self, delay, callback, *args): - wx.CallSoon(callback, args) + def _rc_call_later(self, delay, callback, *args): + raise NotImplementedError() # todo: wx.CallSoon(callback, args) def _rc_run(self): self._app.MainLoop() diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index 785df75..9cbeaed 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -55,9 +55,9 @@ def _rc_close(self): def _rc_get_closed(self): return self._closed - def _process_events(self): - super()._process_events() + async def _process_events(self): self.events_count += 1 + return await super()._process_events() def _draw_frame_and_present(self): super()._draw_frame_and_present() From 19b90c7ee0d69a2398b2fd75ca25ddf774d7cc5f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 29 Nov 2024 16:22:54 +0100 Subject: [PATCH 04/22] progress, I guess --- docs/api.rst | 9 +-- examples/cube_trio.py | 1 + rendercanvas/_async_adapter.py | 28 ++++---- rendercanvas/_loop.py | 59 +++++++++-------- rendercanvas/_scheduler.py | 10 ++- rendercanvas/asyncio.py | 28 +++++--- rendercanvas/glfw.py | 2 +- rendercanvas/jupyter.py | 2 +- rendercanvas/offscreen.py | 32 +++++++--- rendercanvas/qt.py | 15 +++-- rendercanvas/stub.py | 2 +- rendercanvas/trio.py | 13 ++-- rendercanvas/wx.py | 22 ++++--- tests/test_backends.py | 113 ++++++++++++--------------------- tests/test_base.py | 11 +++- tests/test_events.py | 88 +++++++++++++++---------- tests/test_glfw.py | 10 ++- tests/test_loop.py | 3 + tests/test_offscreen.py | 24 +++++-- tests/test_scheduling.py | 42 ++++++------ 20 files changed, 283 insertions(+), 231 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 63fc379..0f8b0d7 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,9 +3,8 @@ API These are the base classes that make up the rendercanvas API: -* The :class:`~rendercanvas.BaseRenderCanvas` represets the main API. -* The :class:`~rendercanvas.BaseLoop` provides functionality to work with the event-loop and timers in a generic way. -* The :class:`~rendercanvas.BaseTimer` is returned by some methods of ``loop``. +* The :class:`~rendercanvas.BaseRenderCanvas` represents the main API. +* The :class:`~rendercanvas.BaseLoop` provides functionality to work with the event-loop in a generic way. * The :class:`~rendercanvas.EventType` specifies the different types of events that can be connected to with :func:`canvas.add_event_handler() `. .. autoclass:: rendercanvas.BaseRenderCanvas @@ -16,10 +15,6 @@ These are the base classes that make up the rendercanvas API: :members: :member-order: bysource -.. autoclass:: rendercanvas.BaseTimer - :members: - :member-order: bysource - .. autoclass:: rendercanvas.EventType :members: :member-order: bysource diff --git a/examples/cube_trio.py b/examples/cube_trio.py index adc8bca..70f011c 100644 --- a/examples/cube_trio.py +++ b/examples/cube_trio.py @@ -8,6 +8,7 @@ from rendercanvas.glfw import RenderCanvas from rendercanvas.trio import loop + from rendercanvas.utils.cube import setup_drawing_sync diff --git a/rendercanvas/_async_adapter.py b/rendercanvas/_async_adapter.py index 68a6930..293288e 100644 --- a/rendercanvas/_async_adapter.py +++ b/rendercanvas/_async_adapter.py @@ -2,7 +2,6 @@ A micro async framework that only support sleep() and Event. Behaves well with sniffio. """ -import time import logging from sniffio import thread_local as sniffio_thread_local @@ -24,7 +23,7 @@ def __await__(self): async def sleep(delay): - await Sleeper(time.perf_counter() + delay) + await Sleeper(delay) class Event: @@ -114,16 +113,21 @@ def step(self): if stop: return self._close() + error = None if not (isinstance(result, dict) and result.get("wait_method", None)): - raise RuntimeError( + error = f"Incompatible awaitable result {result!r}. Maybe you used asyncio or trio (this does not run on either)?" + else: + wait_method = result["wait_method"] + if wait_method == "sleep": + self.call_step_later(result["delay"]) + elif wait_method == "event": + result["event"]._add_task(self) + else: + error = f"Unknown wait_method {wait_method!r}." + + if error: + logger.error( f"Incompatible awaitable result {result!r}. Maybe you used asyncio or trio (this does not run on either)?" ) - - wait_method = result["wait_method"] - - if wait_method == "sleep": - self.call_step_later(result["delay"]) - elif wait_method == "event": - result["event"]._add_task(self) - else: - raise RuntimeError(f"Unknown wait_method {wait_method!r}.") + self.cancel() + self.call_step_later(0) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 044729b..3175e87 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -5,7 +5,6 @@ import signal from ._coreutils import logger, log_exception -from ._scheduler import Scheduler from .utils.asyncs import sleep from asyncio import iscoroutinefunction from ._async_adapter import Task as AsyncAdapterTask @@ -48,11 +47,11 @@ def unset_current_loop(self, loop): # proxy methods - def add_scheduler(self, *args): + def _register_scheduler(self, *args): if self._current_loop: - self._current_loop.add_scheduler(*args) + self._current_loop._register_scheduler(*args) else: - self._pending_calls.append(("add_scheduler", args)) + self._pending_calls.append(("_register_scheduler", args)) def add_task(self, *args): if self._current_loop: @@ -79,14 +78,13 @@ class BaseLoop: _loop_proxy = global_loop_proxy def __init__(self): - self.__tasks = [] + self.__tasks = set() self._schedulers = [] self._is_inside_run = False self._should_stop = 0 self.__created_loop_task = False - def add_scheduler(self, scheduler): - assert isinstance(scheduler, Scheduler) + def _register_scheduler(self, scheduler): self._schedulers.append(scheduler) def get_canvases(self): @@ -174,6 +172,9 @@ async def wrapper(): def call_later(self, delay, callback, *args): """Arrange for a callback to be called after the given delay (in seconds).""" + if delay <= 0: + return self.call_soon(callback, *args) + if not callable(callback): raise TypeError("call_later() expects a callable.") elif iscoroutinefunction(callback): @@ -186,6 +187,16 @@ async def wrapper(): self._rc_add_task(wrapper, "call_later") + def activate(self): + # todo: ??? + if self._loop_proxy is not None: + self._loop_proxy.set_current_loop(self) + + # Make sure that the internal timer is running, even if no canvases. + if not self.__created_loop_task: + self.__created_loop_task = True + self.add_task(self._loop_task, name="loop") + def run(self): """Enter the main loop. @@ -197,17 +208,11 @@ def run(self): # for interactive sessions, where the loop is already running, or started # "behind our back". So we don't need to accomodate for this. - if self._loop_proxy is not None: - self._loop_proxy.set_current_loop(self) - # 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. - if not self.__created_loop_task: - self.__created_loop_task = True - self.add_task(self._loop_task, name="loop") + self.activate() # Register interrupt handler prev_sig_handlers = self.__setup_interrupt() @@ -225,10 +230,11 @@ def run(self): signal.signal(sig, cb) async def run_async(self): - """ "Alternative to ``run()``, to enter the mainloop from a running async framework.""" - if self._loop_proxy is not None: - self._loop_proxy.set_current_loop(self) - await self._rc_run_async() + """ "Alternative to ``run()``, to enter the mainloop from a running async framework. + + Only support by the asyncio and trio loops. + """ + raise NotImplementedError() def stop(self): """Close all windows and stop the currently running event loop. @@ -248,10 +254,10 @@ def _stop(self): if self._loop_proxy is not None: with log_exception("unset loop:"): self._loop_proxy.unset_current_loop(self) - for task in self.__tasks: + while self.__tasks: + task = self.__tasks.pop() with log_exception("task cancel:"): task.cancel() - self.__tasks = [] self._rc_stop() def __setup_interrupt(self): @@ -287,14 +293,6 @@ def _rc_run(self): """ raise NotImplementedError() - async def _rc_run_async(self): - """Enter the mainloop by awaiting this co-routine. - - Should only be implemented by loop-backends that are async (asyncio, trio). - Other backends can ignore this. - """ - raise NotImplementedError() - def _rc_stop(self): """Stop the event loop. @@ -315,13 +313,14 @@ def _rc_add_task(self, async_func, name): * The backend is responsible for cancelling remaining tasks in _rc_stop. """ task = AsyncAdapterTask(self, async_func(), name) - self.__tasks.append(task) - task.add_done_callback(self.__tasks.remove) + self.__tasks.add(task) + task.add_done_callback(self.__tasks.discard) def _rc_call_later(self, delay, callback): """Method to call a callback in delay number of seconds. This method must only be implemented if ``_rc_add_task()`` is not. If delay is zero, this should behave like "call_later". + No need to catch errors from the callback; that's dealt with internally. """ raise NotImplementedError() diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index afef181..652085d 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -95,7 +95,7 @@ def __init__(self, canvas, events, loop, *, mode="ondemand", min_fps=1, max_fps= # Keep track of fps self._draw_stats = 0, time.perf_counter() - loop.add_scheduler(self) + loop._register_scheduler(self) loop.add_task(self.__scheduler_task) def get_canvas(self): @@ -115,7 +115,10 @@ async def __scheduler_task(self): """The coro that reprsents the scheduling loop for a canvas.""" last_draw_time = 0 - last_tick_time = -0.1 + last_tick_time = 0 + + # Little startup sleep + await sleep(0.05) while True: # Determine delay @@ -130,7 +133,8 @@ async def __scheduler_task(self): delay -= time_since_tick_start delay = max(0, delay) - # Wait + # Wait. Even if delay is zero, it gives control back to the loop, + # allowing other tasks to do work. await sleep(delay) # Get canvas or stop diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py index 293c709..65c31f6 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -13,7 +13,7 @@ class AsyncioLoop(BaseLoop): def __init__(self): super().__init__() - self._tasks = [] + self.__tasks = set() @property def _loop(self): @@ -29,22 +29,30 @@ def _loop(self): asyncio.set_event_loop(self._the_loop) return self._the_loop - def _rc_add_task(self, func, name): - task = self._loop.create_task(func(), name=name) - self._tasks.append(task) - task.add_done_callback(self._tasks.remove) - return task + async def run_async(self): + pass # todo: xx def _rc_run(self): if not self._loop.is_running(): self._loop.run_forever() def _rc_stop(self): - # Note: is only called when we're inside _rc_run + # Note: is only called when we're inside _rc_run. + # I.e. if the loop was already running + while self.__tasks: + task = self.__tasks.pop() + task.cancel() # is a no-op if the task is no longer running self._loop.stop() - while self._tasks: - t = self._tasks.pop(-1) - t.cancel() # is a no-op if the task is no longer running + self._the_loop = None + + def _rc_add_task(self, func, name): + task = self._loop.create_task(func(), name=name) + self.__tasks.add(task) + task.add_done_callback(self.__tasks.discard) + return task + + def _rc_call_later(self, *args): + raise NotImplementedError() # we implement _rc_add_task instead loop = AsyncioLoop() diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 7618a2c..abd08ad 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -551,6 +551,6 @@ def poll_glfw_briefly(poll_time=0.1): # Make available under a name that is the same for all backends -# Note that the loop object is just a default; this backend can be used with any loop +loop = loop # default loop is AsyncioLoop RenderCanvas = GlfwRenderCanvas run = loop.run # backwards compat diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index 719d25b..7abc727 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -30,7 +30,7 @@ def __init__(self, *args, **kwargs): self._draw_request_time = 0 # Register so this can be display'ed when run() is called - loop._pending_jupyter_canvases.append(weakref.ref(self)) + self._rc_get_loop()._pending_jupyter_canvases.append(weakref.ref(self)) # Set size, title, etc. self._final_canvas_init() diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 9789e91..c8cfbec 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -4,6 +4,8 @@ __all__ = ["RenderCanvas", "loop"] +import time + from .base import BaseRenderCanvas, BaseLoop @@ -80,7 +82,7 @@ def draw(self): This object can be converted to a numpy array (without copying data) using ``np.asarray(arr)``. """ - loop._process_timers() # Little trick to keep the event loop going + loop.process_tasks() # Little trick to keep the event loop going self._draw_frame_and_present() return self._last_image @@ -102,20 +104,34 @@ class StubLoop(BaseLoop): # In summary, we provide a call_later() and run() that behave pretty # well for the first case. - def _rc_run(self): - pass + def __init__(self): + super().__init__() + self._callbacks = [] + + def process_tasks(self): + callbacks_to_run = [] + new_callbacks = [] + for etime, callback in self._callbacks: + if time.perf_counter() >= etime: + callbacks_to_run.append(callback) + else: + new_callbacks.append((etime, callback)) + if callbacks_to_run: + self._callbacks = new_callbacks + for callback in callbacks_to_run: + callback() - async def _rc_run_async(self): - raise NotImplementedError() + def _rc_run(self): + self.process_tasks() def _rc_stop(self): - pass + self._callbacks = [] def _rc_add_task(self, async_func, name): - pass + super()._rc_add_task(async_func, name) def _rc_call_later(self, delay, callback): - pass + self._callbacks.append((time.perf_counter() + delay, callback)) loop = StubLoop() diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 3499d21..1334556 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -529,13 +529,6 @@ def _app(self): self._the_app = app = QtWidgets.QApplication([]) return app - def _rc_call_later(self, delay, callback): - delay_ms = int(max(0, delay * 1000)) - QtCore.QTimer.singleShot(delay_ms, callback) - - def _rc_add_task(self, *args): - return super()._rc_add_task(*args) - def _rc_run(self): # Note: we could detect if asyncio is running (interactive session) and wheter # we can use QtAsyncio. However, there's no point because that's up for the @@ -557,6 +550,14 @@ def _rc_stop(self): # Note: is only called when we're inside _rc_run self._app.quit() + def _rc_add_task(self, async_func, name): + # we use the async adapter with call_later + return super()._rc_add_task(async_func, name) + + def _rc_call_later(self, delay, callback): + delay_ms = int(max(0, delay * 1000)) + QtCore.QTimer.singleShot(delay_ms, callback) + loop = QtLoop() diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index b19e19a..16564f8 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -92,7 +92,7 @@ def _rc_run(self): def _rc_stop(self): raise NotImplementedError() - def _rc_add_task(self): + def _rc_add_task(self, async_func, name): raise NotImplementedError() def _rc_call_later(self, delay, callback): diff --git a/rendercanvas/trio.py b/rendercanvas/trio.py index 19bea72..7ded6b2 100644 --- a/rendercanvas/trio.py +++ b/rendercanvas/trio.py @@ -17,11 +17,7 @@ def __init__(self): self._cancel_scope = None self._send_channel, self._receive_channel = trio.open_memory_channel(99) - def _rc_add_task(self, async_func, name): - self._send_channel.send_nowait((async_func, name)) - return None - - async def _rc_run_async(self): + async def run_async(self): import trio with trio.CancelScope() as self._cancel_scope: @@ -41,5 +37,12 @@ def _rc_stop(self): if self._cancel_scope is not None: self._cancel_scope.cancel() + def _rc_add_task(self, async_func, name): + self._send_channel.send_nowait((async_func, name)) + return None + + def _rc_call_later(self, delay, callback): + raise NotImplementedError() # we implement _rc_add_task() instead + loop = TrioLoop() diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index a8d9276..caf06d1 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -479,8 +479,13 @@ def _app(self): wx.App.SetInstance(app) return app - def _rc_call_later(self, delay, callback, *args): - raise NotImplementedError() # todo: wx.CallSoon(callback, args) + def process_wx_events(self): + old = wx.GUIEventLoop.GetActive() + new = wx.GUIEventLoop() + wx.GUIEventLoop.SetActive(new) + while new.Pending(): + new.Dispatch() + wx.GUIEventLoop.SetActive(old) def _rc_run(self): self._app.MainLoop() @@ -491,13 +496,12 @@ def _rc_stop(self): # to close all windows before stopping a loop. pass - def process_wx_events(self): - old = wx.GUIEventLoop.GetActive() - new = wx.GUIEventLoop() - wx.GUIEventLoop.SetActive(new) - while new.Pending(): - new.Dispatch() - wx.GUIEventLoop.SetActive(old) + def _rc_add_task(self, async_func, name): + # we use the async adapter with call_later + return super()._rc_add_task(async_func, name) + + def _rc_call_later(self, delay, callback): + raise NotImplementedError() # todo: wx.CallSoon(callback, args) loop = WxLoop() diff --git a/tests/test_backends.py b/tests/test_backends.py index bccedc5..da354f5 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -2,7 +2,7 @@ Test basic validity of the backends. * Test that each backend module has the expected names in its namespace. -* Test that the classes (canvas, loop, timer) implement the correct _rx_xx methods. +* Test that the classes (canvas, loop) implement the correct _rx_xx methods. """ import os @@ -121,28 +121,8 @@ def check_loop(self, loop_class): rc_methods = self.get_rc_methods(loop_class) self.check_rc_methods(rc_methods, loop_rc_methods) - def get_timer_class(self, loop_class): - timer_class = None - for statement in loop_class.body: - if ( - isinstance(statement, ast.Assign) - and statement.targets[0].id == "_TimerClass" - ): - timer_class = self.names[statement.value.id] - - assert timer_class - timer_bases = self.get_bases(timer_class) - print(f" loop._TimerClass -> {timer_class.name}: {', '.join(timer_bases)}") - - return timer_class - - def check_timer(self, timer_class): - rc_methods = self.get_rc_methods(timer_class) - self.check_rc_methods(rc_methods, timer_rc_methods) - canvas_rc_methods = get_ref_rc_methods("RenderCanvas") -timer_rc_methods = get_ref_rc_methods("Timer") loop_rc_methods = get_ref_rc_methods("Loop") @@ -174,12 +154,8 @@ def test_ref_rc_methods(): print(" Loop") for x in loop_rc_methods: print(f" {x}") - print(" Timer") - for x in timer_rc_methods: - print(f" {x}") assert len(canvas_rc_methods) >= 10 - assert len(timer_rc_methods) >= 3 assert len(loop_rc_methods) >= 3 @@ -201,9 +177,6 @@ def test_base_module(): loop_class = m.names["BaseLoop"] m.check_loop(loop_class) - timer_class = m.names["BaseTimer"] - m.check_timer(timer_class) - def test_auto_module(): m = Module("auto") @@ -212,16 +185,27 @@ def test_auto_module(): assert "run" in m.names +# %% Test modules that only provide a loop + + def test_asyncio_module(): m = Module("asyncio") + assert "loop" in m.names + assert m.names["loop"] loop_class = m.names["AsyncioLoop"] m.check_loop(loop_class) assert loop_class.name == "AsyncioLoop" - timer_class = m.get_timer_class(loop_class) - m.check_timer(timer_class) - assert timer_class.name == "AsyncioTimer" + +def test_trio_module(): + m = Module("trio") + + assert "loop" in m.names + assert m.names["loop"] + loop_class = m.names["TrioLoop"] + m.check_loop(loop_class) + assert loop_class.name == "TrioLoop" # %% Test the backend modules @@ -238,10 +222,6 @@ def test_stub_module(): m.check_loop(loop_class) assert loop_class.name == "StubLoop" - timer_class = m.get_timer_class(loop_class) - m.check_timer(timer_class) - assert timer_class.name == "StubTimer" - def test_glfw_module(): m = Module("glfw") @@ -250,13 +230,36 @@ def test_glfw_module(): m.check_canvas(canvas_class) assert canvas_class.name == "GlfwRenderCanvas" + # Loop is imported from asyncio + assert m.names["loop"] + + +def test_jupyter_module(): + m = Module("jupyter") + + canvas_class = m.get_canvas_class() + m.check_canvas(canvas_class) + assert canvas_class.name == "JupyterRenderCanvas" + loop_class = m.get_loop_class() - assert loop_class.name == "GlfwAsyncioLoop" + assert loop_class.name == "JupyterAsyncioLoop" # Loop is provided by our asyncio module assert m.get_bases(loop_class) == ["AsyncioLoop"] +def test_offscreen_module(): + m = Module("offscreen") + + canvas_class = m.get_canvas_class() + m.check_canvas(canvas_class) + assert canvas_class.name == "ManualOffscreenRenderCanvas" + + loop_class = m.get_loop_class() + m.check_loop(loop_class) + assert loop_class.name == "StubLoop" + + def test_qt_module(): m = Module("qt") @@ -268,10 +271,6 @@ def test_qt_module(): m.check_loop(loop_class) assert loop_class.name == "QtLoop" - timer_class = m.get_timer_class(loop_class) - m.check_timer(timer_class) - assert timer_class.name == "QtTimer" - def test_pyside6_module(): m = Module("pyside6") @@ -304,40 +303,6 @@ def test_wx_module(): m.check_loop(loop_class) assert loop_class.name == "WxLoop" - timer_class = m.get_timer_class(loop_class) - m.check_timer(timer_class) - assert timer_class.name == "WxTimer" - - -def test_offscreen_module(): - m = Module("offscreen") - - canvas_class = m.get_canvas_class() - m.check_canvas(canvas_class) - assert canvas_class.name == "ManualOffscreenRenderCanvas" - - loop_class = m.get_loop_class() - m.check_loop(loop_class) - assert loop_class.name == "StubLoop" - - timer_class = m.get_timer_class(loop_class) - m.check_timer(timer_class) - assert timer_class.name == "StubTimer" - - -def test_jupyter_module(): - m = Module("jupyter") - - canvas_class = m.get_canvas_class() - m.check_canvas(canvas_class) - assert canvas_class.name == "JupyterRenderCanvas" - - loop_class = m.get_loop_class() - assert loop_class.name == "JupyterAsyncioLoop" - - # Loop is provided by our asyncio module - assert m.get_bases(loop_class) == ["AsyncioLoop"] - if __name__ == "__main__": run_tests(globals()) diff --git a/tests/test_base.py b/tests/test_base.py index eabd040..323238b 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -197,7 +197,16 @@ def handler(event): c.add_event_handler(handler, "key_down") c.submit_event({"event_type": "key_down", "value": 1}) c.submit_event({"event_type": "key_down", "value": 2}) - c._events.flush() + + def sync_flush(events): + coro = events.flush() + while True: + try: + coro.send(None) + except StopIteration: + break + + sync_flush(c._events) assert events == [1, 2] diff --git a/tests/test_events.py b/tests/test_events.py index f8e7788..a400e0f 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -9,8 +9,26 @@ import pytest +class OurEventEmitter(EventEmitter): + def sync_flush(self): + coro = self.flush() + while True: + try: + coro.send(None) + except StopIteration: + break + + def sync_emit(self, event): + coro = self.emit(event) + while True: + try: + coro.send(None) + except StopIteration: + break + + def test_events_event_types(): - ee = EventEmitter() + ee = OurEventEmitter() def handler(event): pass @@ -27,7 +45,7 @@ def handler(event): def test_events_basic(): - ee = EventEmitter() + ee = OurEventEmitter() values = [] @@ -40,23 +58,23 @@ def handler(event): ee.submit({"event_type": "key_down", "value": 2}) assert values == [] - ee.flush() + ee.sync_flush() ee.submit({"event_type": "key_down", "value": 3}) assert values == [1, 2] - ee.flush() + ee.sync_flush() assert values == [1, 2, 3] # Removing a handler affects all events since the last flush ee.submit({"event_type": "key_down", "value": 4}) ee.remove_handler(handler, "key_down") ee.submit({"event_type": "key_down", "value": 5}) - ee.flush() + ee.sync_flush() assert values == [1, 2, 3] def test_events_handler_arg_position(): - ee = EventEmitter() + ee = OurEventEmitter() def handler(event): pass @@ -69,7 +87,7 @@ def handler(event): def test_events_handler_decorated(): - ee = EventEmitter() + ee = OurEventEmitter() values = [] @@ -79,12 +97,12 @@ def handler(event): ee.submit({"event_type": "key_down", "value": 1}) ee.submit({"event_type": "key_up", "value": 2}) - ee.flush() + ee.sync_flush() assert values == [1, 2] def test_direct_emit_(): - ee = EventEmitter() + ee = OurEventEmitter() values = [] @@ -93,18 +111,18 @@ def handler(event): values.append(event["value"]) ee.submit({"event_type": "key_down", "value": 1}) - ee.flush() + ee.sync_flush() ee.submit({"event_type": "key_up", "value": 2}) - ee.emit({"event_type": "key_up", "value": 3}) # goes before pending events + ee.sync_emit({"event_type": "key_up", "value": 3}) # goes before pending events ee.submit({"event_type": "key_up", "value": 4}) - ee.flush() + ee.sync_flush() ee.submit({"event_type": "key_up", "value": 5}) assert values == [1, 3, 2, 4] def test_events_two_types(): - ee = EventEmitter() + ee = OurEventEmitter() values = [] @@ -115,24 +133,24 @@ def handler(event): ee.submit({"event_type": "key_down", "value": 1}) ee.submit({"event_type": "key_up", "value": 2}) - ee.flush() + ee.sync_flush() assert values == [1, 2] ee.remove_handler(handler, "key_down") ee.submit({"event_type": "key_down", "value": 3}) ee.submit({"event_type": "key_up", "value": 4}) - ee.flush() + ee.sync_flush() assert values == [1, 2, 4] ee.remove_handler(handler, "key_up") ee.submit({"event_type": "key_down", "value": 5}) ee.submit({"event_type": "key_up", "value": 6}) - ee.flush() + ee.sync_flush() assert values == [1, 2, 4] def test_events_two_handlers(): - ee = EventEmitter() + ee = OurEventEmitter() values = [] @@ -146,22 +164,22 @@ def handler2(event): ee.add_handler(handler2, "key_down") ee.submit({"event_type": "key_down", "value": 1}) - ee.flush() + ee.sync_flush() assert values == [101, 201] ee.remove_handler(handler1, "key_down") ee.submit({"event_type": "key_down", "value": 2}) - ee.flush() + ee.sync_flush() assert values == [101, 201, 202] ee.remove_handler(handler2, "key_down") ee.submit({"event_type": "key_down", "value": 3}) - ee.flush() + ee.sync_flush() assert values == [101, 201, 202] def test_events_handler_order(): - ee = EventEmitter() + ee = OurEventEmitter() values = [] @@ -185,7 +203,7 @@ def handler3(event): ee.add_handler(handler3, "key_down") ee.submit({"event_type": "key_down", "value": 1}) - ee.flush() + ee.sync_flush() assert values == [101, 201, 301] # Now re-connect with priorities @@ -195,7 +213,7 @@ def handler3(event): ee.add_handler(handler3, "key_down", order=1) ee.submit({"event_type": "key_down", "value": 1}) - ee.flush() + ee.sync_flush() assert values == [101, 301, 201] # Another run using negative priorities too @@ -205,7 +223,7 @@ def handler3(event): ee.add_handler(handler3, "key_down", order=-1) ee.submit({"event_type": "key_down", "value": 1}) - ee.flush() + ee.sync_flush() assert values == [201, 301, 101] # Use floats! @@ -215,12 +233,12 @@ def handler3(event): ee.add_handler(handler3, "key_down", order=0.11) ee.submit({"event_type": "key_down", "value": 1}) - ee.flush() + ee.sync_flush() assert values == [301, 201, 101] def test_events_duplicate_handler(): - ee = EventEmitter() + ee = OurEventEmitter() values = [] @@ -232,17 +250,17 @@ def handler(event): ee.add_handler(handler, "key_down") ee.submit({"event_type": "key_down", "value": 1}) - ee.flush() + ee.sync_flush() assert values == [1] ee.remove_handler(handler, "key_down") ee.submit({"event_type": "key_down", "value": 2}) - ee.flush() + ee.sync_flush() assert values == [1] def test_events_duplicate_handler_with_lambda(): - ee = EventEmitter() + ee = OurEventEmitter() values = [] @@ -254,17 +272,17 @@ def handler(event): ee.add_handler(lambda e: handler(e), "key_down") ee.submit({"event_type": "key_down", "value": 1}) - ee.flush() + ee.sync_flush() assert values == [1, 1] ee.remove_handler(handler, "key_down") ee.submit({"event_type": "key_down", "value": 2}) - ee.flush() + ee.sync_flush() assert values == [1, 1, 2, 2] def test_merging_events(): - ee = EventEmitter() + ee = OurEventEmitter() events = [] @@ -292,7 +310,7 @@ def handler(event): ee.submit({"event_type": "key_down", "value": 1}) ee.submit({"event_type": "key_down", "value": 2}) - ee.flush() + ee.sync_flush() assert len(events) == 7 @@ -317,7 +335,7 @@ def test_mini_benchmark(): # Can be used to tweak internals of the EventEmitter and see the # effect on performance. - ee = EventEmitter() + ee = OurEventEmitter() def handler(event): pass @@ -331,7 +349,7 @@ def handler(event): t0 = time.perf_counter() for _ in range(100): ee.submit({"event_type": "key_down", "value": 2}) - ee.flush() + ee.sync_flush() t2 = time.perf_counter() - t0 print(f"add_handler: {1000*t1:0.0f} ms, emit: {1000*t2:0.0f} ms") diff --git a/tests/test_glfw.py b/tests/test_glfw.py index 00ad05d..843941d 100644 --- a/tests/test_glfw.py +++ b/tests/test_glfw.py @@ -104,7 +104,8 @@ def test_glfw_canvas_render(): import wgpu import glfw - from rendercanvas.glfw import RenderCanvas, loop + from rendercanvas.glfw import RenderCanvas + from rendercanvas.asyncio import loop def run_briefly(): asyncio_loop = loop._loop @@ -123,6 +124,11 @@ def draw_frame2(): frame_counter += 1 draw_frame1() + # todo: we must also deactivate/stop the loop + # do we need a notion of the loop being active vs running? + # aaaaaaarg! + loop.activate() + canvas.request_draw(draw_frame2) run_briefly() @@ -145,7 +151,7 @@ def draw_frame2(): run_briefly() assert frame_counter == 3 - # canvas.close() + canvas.close() glfw.poll_events() diff --git a/tests/test_loop.py b/tests/test_loop.py index cc3f3b6..5d32ab3 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -15,6 +15,9 @@ def __init__(self, refuse_close): self.refuse_close = refuse_close self.is_closed = False + def _rc_gui_poll(self): + pass + def _rc_close(self): # Called by the loop to close a canvas if not self.refuse_close: diff --git a/tests/test_offscreen.py b/tests/test_offscreen.py index 0eee2da..9849df5 100644 --- a/tests/test_offscreen.py +++ b/tests/test_offscreen.py @@ -4,6 +4,7 @@ import os import gc +import time import weakref from testutils import is_pypy, run_tests @@ -72,16 +73,25 @@ def test_offscreen_event_loop(): from rendercanvas.offscreen import loop - ran = False + ran = set() - def check(): - nonlocal ran - ran = True + def check(arg): + ran.add(arg) - loop.call_later(0, check) + loop.call_soon(check, 1) + loop.call_later(0, check, 2) + loop.call_later(0.001, check, 3) loop.run() - - assert ran + assert 1 in ran # call_soon + assert 2 in ran # call_later with zero + assert 3 not in ran + + # When run is called, the task is started, so the delay kicks in from + # that moment, so we need to wait here for the 3d to resolve + # The delay starts from + time.sleep(0.01) + loop.run() + assert 3 in ran # call_later nonzero def test_offscreen_canvas_del(): diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index 9cbeaed..106219d 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -6,28 +6,27 @@ import time from testutils import run_tests -from rendercanvas import BaseRenderCanvas, BaseLoop, BaseTimer - - -class MyTimer(BaseTimer): - def _rc_start(self): - pass - - def _rc_stop(self): - pass +from rendercanvas import BaseRenderCanvas, BaseLoop class MyLoop(BaseLoop): - _TimerClass = MyTimer - def __init__(self): super().__init__() self.__stopped = False - - def process_timers(self): - for timer in list(BaseTimer._running_timers): - if timer.time_left <= 0: - timer._tick() + self._callbacks = [] + + def process_tasks(self): + callbacks_to_run = [] + new_callbacks = [] + for etime, callback in self._callbacks: + if time.perf_counter() >= etime: + callbacks_to_run.append(callback) + else: + new_callbacks.append((etime, callback)) + if callbacks_to_run: + self._callbacks = new_callbacks + for callback in callbacks_to_run: + callback() def _rc_run(self): self.__stopped = False @@ -35,6 +34,13 @@ def _rc_run(self): def _rc_stop(self): self.__stopped = True + def _rc_add_task(self, async_func, name): + # Run tasks via call_later + super()._rc_add_task(async_func, name) + + def _rc_call_later(self, delay, callback): + self._callbacks.append((time.perf_counter() + delay, callback)) + class MyCanvas(BaseRenderCanvas): _loop = MyLoop() @@ -76,7 +82,7 @@ def active_sleep(self, delay): etime = time.perf_counter() + delay while time.perf_counter() < etime: time.sleep(0.001) - loop.process_timers() + loop.process_tasks() self.draw_if_necessary() @@ -97,7 +103,7 @@ def test_scheduling_manual(): canvas.request_draw() canvas.active_sleep(0.11) assert canvas.draw_count == 0 - assert canvas.events_count in range(10, 20) + assert canvas.events_count in range(1, 20) # Only when we force one canvas.force_draw() From ad16cebf835ad8f4d462842c473a39cc602de038 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 5 Dec 2024 11:21:05 +0100 Subject: [PATCH 05/22] Refactoring ... damn this was hard --- rendercanvas/_loop.py | 308 +++++++++++++++++++------------------ rendercanvas/_scheduler.py | 33 ++-- rendercanvas/asyncio.py | 90 +++++++---- rendercanvas/base.py | 74 +++++++-- rendercanvas/glfw.py | 34 ++-- rendercanvas/jupyter.py | 39 ++--- rendercanvas/offscreen.py | 6 +- rendercanvas/qt.py | 118 +++++++------- rendercanvas/stub.py | 11 +- rendercanvas/trio.py | 25 +-- rendercanvas/wx.py | 122 ++++++++------- tests/test_backends.py | 18 +-- tests/test_glfw.py | 31 ++-- tests/test_loop.py | 217 ++++++++++++++++++++++---- tests/test_scheduling.py | 15 +- 15 files changed, 719 insertions(+), 422 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 3175e87..4aed009 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -9,9 +9,6 @@ from asyncio import iscoroutinefunction from ._async_adapter import Task as AsyncAdapterTask -# 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. @@ -19,88 +16,64 @@ ) -class LoopProxy: - """Proxy loop object that canvases can use to register themselves before a loop is selected.""" - - def __init__(self): - self._current_loop = None - self._pending_calls = [] # (method_name, args) elements - - def set_current_loop(self, loop): - if loop is self._current_loop: - return - if self._current_loop: - raise RuntimeError( - "Cannot set the current loop while another loop is active." - ) - self._current_loop = loop - while self._pending_calls: - method_name, args = self._pending_calls.pop(-1) - func = getattr(self._current_loop, method_name) - func(*args) - - def unset_current_loop(self, loop): - if loop is self._current_loop: - self._current_loop = None - else: - raise RuntimeError("Cannot unset loop that is not active.") - - # proxy methods - - def _register_scheduler(self, *args): - if self._current_loop: - self._current_loop._register_scheduler(*args) - else: - self._pending_calls.append(("_register_scheduler", args)) - - def add_task(self, *args): - if self._current_loop: - self._current_loop.add_task(*args) - else: - self._pending_calls.append(("add_task", args)) +class BaseLoop: + """The base class for an event-loop object. - def call_soon(self, *args): - if self._current_loop: - self._current_loop.call_soon(*args) - else: - self._pending_calls.append(("call_soon", args)) + Canvas backends can implement their own loop subclass (like qt and wx do), but a + canvas backend can also rely on one of muliple loop implementations (like glfw + running on asyncio or trio). + The lifecycle states of a loop are: -global_loop_proxy = LoopProxy() + * off (0): the initial state, the subclass should probably not even import dependencies yet. + * ready (1): the first canvas is created, ``_rc_init()`` is called to get the loop ready for running. + * active (2): the loop is active, but not running via our entrypoints. + * running (3): the loop is running via ``_rc_run()`` or ``_rc_run_async()``. + Notes: -class BaseLoop: - """The base class for an event-loop object. + * The loop goes back to the "off" state after all canvases are closed. + * Stopping the loop (via ``.stop()``) closes the canvases, which will then stop the loop. + * From there it can go back to the ready state (which would call ``_rc_init()`` again). + * In backends like Qt, the native loop can be started without us knowing: state "active". + * In interactive settings like an IDE that runs an syncio or Qt loop, the + loop can become "active" as soon as the first canvas is created. - Each backend provides its own loop subclass, so that rendercanvas can run cleanly in the backend's event loop. """ - _loop_proxy = global_loop_proxy - def __init__(self): self.__tasks = set() - self._schedulers = [] - self._is_inside_run = False - self._should_stop = 0 - self.__created_loop_task = False - - def _register_scheduler(self, scheduler): - self._schedulers.append(scheduler) + self.__canvas_groups = set() + self.__state = 0 # 0: idle, 1: ready, 2: active, 3: running via our entrypoint + self.__should_stop = 0 + + def __repr__(self): + state = ["off", "ready", "active", "running"][self.__state] + return f"<{self.__class__.__module__}.{self.__class__.__name__} '{state}' at {hex(id(self))}>" + + def _mark_as_interactive(self): + """For subclasses to set active from ``_rc_init()``""" + if self.__state == 1: + self.__state = 2 + + def _register_canvas_group(self, canvas_group): + # A CanvasGroup will call this every time that a new canvas is created for this loop. + # So now is also a good time to initialize. + if self.__state == 0: + self.__state = 1 + self._rc_init() + self.add_task(self._loop_task) + self.__canvas_groups.add(canvas_group) + + def _unregister_canvas_group(self, canvas_group): + # A CanvasGroup will call this when it selects a different loop. + self.__canvas_groups.discard(canvas_group) 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 - + for canvas_group in self.__canvas_groups: + canvases += canvas_group.get_canvases() return canvases async def _loop_task(self): @@ -112,38 +85,47 @@ async def _loop_task(self): # * Keep the GUI going even when the canvas loop is on pause e.g. because its # minimized (applies to backends that implement _rc_gui_poll). - while True: - await sleep(0.1) - - # Clean internal schedulers list, and keep the loop alive - for canvas in self.get_canvases(): - canvas._rc_gui_poll() - del canvas - - # 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: - continue - - # Should we stop? - if not self._schedulers: - # Stop when there are no more canvases - break - elif self._should_stop >= 2: - # force a stop without waiting for the canvases to close - break - 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() + # Detect active loop + self.__state = max(self.__state, 2) + + try: + while True: + await sleep(0.1) + + canvases = self.get_canvases() + + # Keep canvases alive + for canvas in canvases: + canvas._rc_gui_poll() + del canvas + + # Should we stop? + + if not canvases: + # Stop when there are no more canvases + break + elif self.__should_stop >= 2: + # force a stop without waiting for the canvases to close + break + elif self.__should_stop: + # Close all remaining canvases. Loop will stop in a next iteration. + for canvas in canvases: + if not getattr(canvas, "_rc_closed_by_loop", False): + canvas._rc_closed_by_loop = True + canvas._rc_close() del canvas + del canvases - self._stop() + finally: + self._stop() def add_task(self, async_func, *args, name="unnamed"): - """Run an async function in the event-loop.""" + """Run an async function in the event-loop. + + All tasks are stopped when the loop stops. + """ + # todo: implement iscoroutinefunction outside of asyncio + # todo: test that we don't even import asyncio by default if not (callable(async_func) and iscoroutinefunction(async_func)): raise TypeError("add_task() expects an async function.") @@ -187,80 +169,91 @@ async def wrapper(): self._rc_add_task(wrapper, "call_later") - def activate(self): - # todo: ??? - if self._loop_proxy is not None: - self._loop_proxy.set_current_loop(self) - - # Make sure that the internal timer is running, even if no canvases. - if not self.__created_loop_task: - self.__created_loop_task = True - self.add_task(self._loop_task, name="loop") - 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. - """ - # 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.") + This call usually blocks, but it can also return immediately, e.g. when there are no + canvases, or when the loop is already active (e.g. interactve via IDE). + """ - self.activate() + # Can we enter the loop? + if self.__state == 0: + # Euhm, I guess we can run it one iteration, just make sure our loop-task is running! + self._register_canvas_group(0) + self.__canvas_groups.discard(0) + if self.__state == 1: + # Yes we can + pass + elif self.__state == 2: + # No, already active (interactive mode) + return + else: + # No, what are you doing?? + raise RuntimeError(f"loop.run() is not reentrant ({self.__state}).") # 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 + # 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 and did not call _mark_as_interactive(). + self.__state = 3 try: self._rc_run() finally: - self._is_inside_run = False + self.__state = min(self.__state, 1) for sig, cb in prev_sig_handlers.items(): signal.signal(sig, cb) async def run_async(self): """ "Alternative to ``run()``, to enter the mainloop from a running async framework. - Only support by the asyncio and trio loops. + Only supported by the asyncio and trio loops. """ - raise NotImplementedError() + + # Can we enter the loop? + if self.__state == 0: + # Euhm, I guess we can run it one iteration, just make sure our loop-task is running! + self._register_canvas_group(0) + self.__canvas_groups.discard(0) + if self.__state == 1: + # Yes we can + pass + else: + raise RuntimeError( + f"loop.run_async() can only be awaited once ({self.__state})." + ) + + await self._rc_run_async() def stop(self): """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. + If the loop is active but not running via our ``run()`` method, the loop + moves back to its "off" state, but the underlying loop is not stopped. """ # 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._stop() + 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._stop() def _stop(self): - if self._loop_proxy is not None: - with log_exception("unset loop:"): - self._loop_proxy.unset_current_loop(self) + """Move to our off state.""" + # If we used the async adapter, cancel any tasks while self.__tasks: task = self.__tasks.pop() with log_exception("task cancel:"): task.cancel() + # Turn off + self.__state = 0 self._rc_stop() def __setup_interrupt(self): + """Setup the interrupt handlers.""" + def on_interrupt(sig, _frame): logger.warning(f"Received signal {signal.strsignal(sig)}") self.stop() @@ -282,6 +275,25 @@ def on_interrupt(sig, _frame): break return prev_handlers + def _rc_init(self): + """Put the loop in a ready state. + + Called when the first canvas is created to run in this loop. This is when we + know pretty sure that this loop is going to be used, so time to start the + engines. Note that in interactive settings, this method can be called again, after the + loop has stopped, to restart it. + + * Import any dependencies. + * If this loop supports some kind of interactive mode, activate it! + * Optionally call ``_mark_as_interactive()``. + * Return None. + """ + pass + + def _rc_run_async(self): + """Run async.""" + raise NotImplementedError() + def _rc_run(self): """Start running the event-loop. @@ -294,23 +306,24 @@ def _rc_run(self): raise NotImplementedError() def _rc_stop(self): - """Stop the event loop. + """Clean up the loop, going to the off-state. - * Stop the running event loop. * Cancel any remaining tasks. - * This will only be called when the process is inside _rc_run(), i.e. not for interactive mode. + * Stop the running event loop, if applicable. + * Be ready for another call to ``_rc_init()`` in case the loop is reused. + * Return None. """ raise NotImplementedError() def _rc_add_task(self, async_func, name): """Add an async task to the running loop. - This method is optional. A backend must either implement ``_rc_add_task`` - or implement ``_rc_call_later``. + This method is optional. A subclass must either implement ``_rc_add_task`` or ``_rc_call_later``. * Schedule running the task defined by the given co-routine function. * The name is for debugging purposes only. - * The backend is responsible for cancelling remaining tasks in _rc_stop. + * The subclass is responsible for cancelling remaining tasks in _rc_stop. + * Return None. """ task = AsyncAdapterTask(self, async_func(), name) self.__tasks.add(task) @@ -319,8 +332,11 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): """Method to call a callback in delay number of seconds. - This method must only be implemented if ``_rc_add_task()`` is not. - If delay is zero, this should behave like "call_later". - No need to catch errors from the callback; that's dealt with internally. + This method is optional. A subclass must either implement ``_rc_add_task`` or ``_rc_call_later``. + + * If you implememt this, make ``_rc_add_task()`` call ``super()._rc_add_task()``. + * If delay is zero, this should behave like "call_later". + * No need to catch errors from the callback; that's dealt with internally. + * Return None. """ raise NotImplementedError() diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index 652085d..478232c 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -71,9 +71,8 @@ class Scheduler: # Note that any extra draws, e.g. via force_draw() or due to window resizes, # don't affect the scheduling loop; they are just extra draws. - def __init__(self, canvas, events, loop, *, mode="ondemand", min_fps=1, max_fps=30): + def __init__(self, canvas, events, *, mode="ondemand", min_fps=1, max_fps=30): self.name = f"{canvas.__class__.__name__} scheduler" - assert loop is not None # We don't keep a ref to the canvas to help gc. This scheduler object can be # referenced via a callback, but it won't prevent the canvas from being deleted! @@ -95,8 +94,12 @@ def __init__(self, canvas, events, loop, *, mode="ondemand", min_fps=1, max_fps= # Keep track of fps self._draw_stats = 0, time.perf_counter() - loop._register_scheduler(self) - loop.add_task(self.__scheduler_task) + def get_task(self): + # Get task. Can be called exactly once. Used by the canvas. + task = self.__scheduler_task + self.__scheduler_task = None + assert task is not None + return task def get_canvas(self): """Get the canvas, or None if it is closed or gone.""" @@ -137,16 +140,15 @@ async def __scheduler_task(self): # allowing other tasks to do work. await sleep(delay) - # Get canvas or stop - if (canvas := self.get_canvas()) is None: - break - # Below is the "tick" last_tick_time = time.perf_counter() # Process events, handlers may request a draw + if (canvas := self.get_canvas()) is None: + break await canvas._process_events() + del canvas # Determine what to do next ... @@ -180,12 +182,25 @@ async def __scheduler_task(self): await self._events.emit({"event_type": "before_draw"}) + # Ask the canvas to draw + if (canvas := self.get_canvas()) is None: + break canvas._rc_request_draw() + del canvas + + # Wait for the draw to happen self._async_draw_event = Event() await self._async_draw_event.wait() last_draw_time = time.perf_counter() - await self._events._rc_canvas_close() + # todo: sending the close event is tricky. + # Even if the canvas has submitted its close event, it may not be flushed yet. + # We can flush here, but the problem is that when all canvases are closed, the loop + # closes and cancels all tasks, including this one. We can write a finally-clause, + # so that we can do something even when being cancelled. However, we cannot await + # something there .... sigh! Maybe if we require the close-handlers to be sync? + # self._events._rc_canvas_close() + await self._events.flush() def on_draw(self): # Bookkeeping diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py index 65c31f6..5bb4762 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -1,55 +1,89 @@ """ Implements an asyncio event loop for backends that don't have an event loop by themselves, like glfw. +Also supports a asyncio-friendly way to run or wait for the loop using ``run_async()``. """ __all__ = ["AsyncioLoop", "loop"] - from .base import BaseLoop +import sniffio -class AsyncioLoop(BaseLoop): - _the_loop = None +class AsyncioLoop(BaseLoop): def __init__(self): super().__init__() + # Initialize, but don't even import asyncio yet self.__tasks = set() + self.__pending_tasks = [] + self._interactive_loop = None + self._run_loop = None + self._stop_event = None - @property - def _loop(self): - if self._the_loop is None: - import asyncio + def _rc_init(self): + # This gets called when the first canvas is created (possibly after having run and stopped before). + import asyncio - try: - self._the_loop = asyncio.get_running_loop() - except Exception: - pass - if self._the_loop is None: - self._the_loop = asyncio.new_event_loop() - asyncio.set_event_loop(self._the_loop) - return self._the_loop - - async def run_async(self): - pass # todo: xx + try: + self._interactive_loop = asyncio.get_running_loop() + self._stop_event = asyncio.Event() + self._mark_as_interactive() # prevents _rc_run from being called + except Exception: + self._interactive_loop = None def _rc_run(self): - if not self._loop.is_running(): - self._loop.run_forever() + import asyncio + + asyncio.run(self._rc_run_async()) + + async def _rc_run_async(self): + import asyncio + + # Protect agsinst usage of wrong loop object + libname = sniffio.current_async_library() + if libname != "asyncio": + raise TypeError(f"Attempt to run AsyncioLoop with {libname}.") + + # Assume we have a running loop + self._run_loop = asyncio.get_running_loop() + + # If we had a running loop when we initialized, it must be the same, + # because we submitted our tasks to it :) + if self._interactive_loop and self._interactive_loop is not self._run_loop: + # I cannot see a valid use-case for this situation. If you do have a use-case, please create an issue + # at https://github.com/pygfx/rendercanvas/issues, and we can maybe fix it. + raise RuntimeError( + "Attempt to run AsyncioLoop with a different asyncio-loop than the initialized loop." + ) + + # Create tasks if necessay + while self.__pending_tasks: + self._rc_add_task(*self.__pending_tasks.pop(-1)) + + # Wait for loop to finish + if self._stop_event is None: + self._stop_event = asyncio.Event() + await self._stop_event.wait() def _rc_stop(self): - # Note: is only called when we're inside _rc_run. - # I.e. if the loop was already running + # Clean up our tasks while self.__tasks: task = self.__tasks.pop() task.cancel() # is a no-op if the task is no longer running - self._loop.stop() - self._the_loop = None + # Signal that we stopped + self._stop_event.set() + self._stop_event = None + self._run_loop = None + # Note how we don't explicitly stop a loop, not the interactive loop, nor the running loop def _rc_add_task(self, func, name): - task = self._loop.create_task(func(), name=name) - self.__tasks.add(task) - task.add_done_callback(self.__tasks.discard) - return task + loop = self._interactive_loop or self._run_loop + if loop is None: + self.__pending_tasks.append((func, name)) + else: + task = loop.create_task(func(), name=name) + self.__tasks.add(task) + task.add_done_callback(self.__tasks.discard) def _rc_call_later(self, *args): raise NotImplementedError() # we implement _rc_add_task instead diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 6d5d2d7..938fb32 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -4,6 +4,8 @@ __all__ = ["BaseLoop", "BaseRenderCanvas", "WrapperRenderCanvas"] +import sys +import weakref import importlib from ._events import EventEmitter, EventType # noqa: F401 @@ -23,6 +25,42 @@ # * `._rc_method`: Methods that the subclass must implement. +class BaseCanvasGroup: + """Represents a group of canvas objects from the same class, that share a loop.""" + + def __init__(self, default_loop): + self._canvases = weakref.WeakSet() + self._loop = default_loop + self.select_loop(default_loop) + + def _register_canvas(self, canvas, task): + """Used by the canvas to register itself.""" + self._canvases.add(canvas) + loop = self.get_loop() + loop._register_canvas_group(self) + loop.add_task(task) + + def select_loop(self, loop): + """Select the loop to use for this group of canvases.""" + if not isinstance(loop, BaseLoop): + raise TypeError("select_loop() requires a loop instance.") + elif len(self._canvases): + raise RuntimeError("Cannot select_loop() when live canvases exist.") + elif loop is self._loop: + pass + else: + self._loop._unregister_canvas_group(self) + self._loop = loop + + def get_loop(self): + """Get the currently associated loop.""" + return self._loop + + def get_canvases(self): + """Get a list of currently active (not-closed) canvases for this group.""" + return [canvas for canvas in self._canvases if not canvas.get_closed()] + + class BaseRenderCanvas: """The base canvas class. @@ -50,6 +88,8 @@ class BaseRenderCanvas: """ + _rc_canvas_group = None # todo: doc this + def __init__( self, *args, @@ -83,16 +123,15 @@ def __init__( # Events and scheduler self._events = EventEmitter() self.__scheduler = None - loop = self._rc_get_loop() - if loop is not None: + if self._rc_canvas_group is not None: self.__scheduler = Scheduler( self, self._events, - loop, min_fps=min_fps, max_fps=max_fps, mode=update_mode, ) + self._rc_canvas_group._register_canvas(self, self.__scheduler.get_task()) # We cannot initialize the size and title now, because the subclass may not have done # the initialization to support this. So we require the subclass to call _final_canvas_init. @@ -129,6 +168,20 @@ def __del__(self): except Exception: pass + # %% Static + + @classmethod + def select_loop(cls, loop): + """Select the loop to run newly created canvases with. + Can only be called when there are no live canvases of this class. + """ + group = cls._rc_canvas_group + if group is None: + raise NotImplementedError( + "The {cls.__name__} does not have a canvas group, thus no loop." + ) + group.select_loop(loop) + # %% Implement WgpuCanvasInterface _canvas_context = None # set in get_context() @@ -411,16 +464,8 @@ def set_title(self, title): # %% Methods for the subclass to implement - def _rc_get_loop(self): - """Get the loop instance for this backend. - - Must return the global loop instance (a BaseLoop subclass) or a - compatible proxy object, or None for a canvas without scheduled draws. - """ - return None - def _rc_gui_poll(self): - """Process GUI events.""" + """Process native events.""" pass def _rc_get_present_methods(self): @@ -525,6 +570,11 @@ class WrapperRenderCanvas(BaseRenderCanvas): wrapped canvas and set it as ``_subwidget``. """ + @classmethod + def select_loop(cls, loop): + m = sys.modules[cls.__module__] + return m.RenderWidget.select_loop(loop) + def add_event_handler(self, *args, **kwargs): return self._subwidget._events.add_handler(*args, **kwargs) diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index abd08ad..ae9d985 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -15,8 +15,7 @@ import glfw -from .base import BaseRenderCanvas -from ._loop import global_loop_proxy +from .base import BaseRenderCanvas, BaseCanvasGroup from .asyncio import loop from ._coreutils import SYSTEM_IS_WAYLAND, weakbind, logger @@ -147,14 +146,19 @@ def get_physical_size(window): return int(psize[0]), int(psize[1]) -glfw_is_terminated = False +def enable_glfw(): + glfw.init() + glfw._rc_alive = True @atexit.register def terminate_glfw(): - global glfw_is_terminated - glfw_is_terminated = True glfw.terminate() + glfw._rc_alive = False + + +class GlfwCanvasGroup(BaseCanvasGroup): + pass class GlfwRenderCanvas(BaseRenderCanvas): @@ -162,10 +166,13 @@ class GlfwRenderCanvas(BaseRenderCanvas): # See https://www.glfw.org/docs/latest/group__window.html + _rc_canvas_group = GlfwCanvasGroup(loop) + def __init__(self, *args, present_method=None, **kwargs): - glfw.init() super().__init__(*args, **kwargs) + enable_glfw() + if present_method == "bitmap": logger.warning( "Ignoreing present_method 'bitmap'; glfw can only render to screen" @@ -289,9 +296,6 @@ def _set_logical_size(self, new_logical_size): # %% Methods to implement RenderCanvas - def _rc_get_loop(self): - return global_loop_proxy - def _rc_gui_poll(self): glfw.post_empty_event() # Awake the event loop, if it's in wait-mode glfw.poll_events() @@ -302,7 +306,8 @@ def _rc_get_present_methods(self): def _rc_request_draw(self): if not self._is_minimized: - self._rc_get_loop().call_soon(self._draw_frame_and_present) + loop = self._rc_canvas_group.get_loop() + loop.call_soon(self._draw_frame_and_present) def _rc_force_draw(self): self._draw_frame_and_present() @@ -328,14 +333,17 @@ def _rc_set_logical_size(self, width, height): self._set_logical_size((float(width), float(height))) def _rc_close(self): - if glfw_is_terminated: + if not glfw._rc_alive: # May not always be able to close the proper way on system exit self._window = None elif self._window is not None: glfw.destroy_window(self._window) # not just glfw.hide_window self._window = None self.submit_event({"event_type": "close"}) - poll_glfw_briefly(0.02) + # If this is the last canvas to close, the loop will stop, and glfw will not be polled anymore. + # But on some systems glfw needs a bit of time to properly close the window. + if not self._rc_canvas_group.get_canvases(): + poll_glfw_briefly(0.05) def _rc_get_closed(self): return self._window is None @@ -553,4 +561,4 @@ def poll_glfw_briefly(poll_time=0.1): # Make available under a name that is the same for all backends loop = loop # default loop is AsyncioLoop RenderCanvas = GlfwRenderCanvas -run = loop.run # backwards compat +run = loop.run() # backwards compat diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index 7abc727..99eb7ab 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -6,19 +6,23 @@ __all__ = ["RenderCanvas", "loop"] import time -import weakref -from .base import BaseRenderCanvas -from .asyncio import AsyncioLoop +from .base import BaseCanvasGroup, BaseRenderCanvas +from .asyncio import loop import numpy as np from jupyter_rfb import RemoteFrameBuffer -from IPython.display import display + + +class JupyterCanvasGroup(BaseCanvasGroup): + pass class JupyterRenderCanvas(BaseRenderCanvas, RemoteFrameBuffer): """An ipywidgets widget providing a render canvas. Needs the jupyter_rfb library.""" + _rc_canvas_group = JupyterCanvasGroup(loop) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -29,9 +33,6 @@ def __init__(self, *args, **kwargs): self._is_closed = False self._draw_request_time = 0 - # Register so this can be display'ed when run() is called - self._rc_get_loop()._pending_jupyter_canvases.append(weakref.ref(self)) - # Set size, title, etc. self._final_canvas_init() @@ -45,11 +46,8 @@ def get_frame(self): # %% Methods to implement RenderCanvas - def _rc_get_loop(self): - return loop # asyncio only - def _rc_gui_poll(self): - pass # The Jupyter UI is running in a separate process :) + pass def _rc_get_present_methods(self): # We stick to the two common formats, because these can be easily converted to png @@ -121,22 +119,5 @@ def handle_event(self, event): # Make available under a name that is the same for all backends RenderCanvas = JupyterRenderCanvas - - -class JupyterAsyncioLoop(AsyncioLoop): - def __init__(self): - super().__init__() - self._pending_jupyter_canvases = [] - - def run(self): - # Show all widgets that have been created so far. - # No need to actually start an event loop, since Jupyter already runs it. - canvases = [r() for r in self._pending_jupyter_canvases] - self._pending_jupyter_canvases.clear() - for w in canvases: - if w and not w.get_closed(): - display(w) - - -loop = JupyterAsyncioLoop() +loop = loop run = loop.run diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index c8cfbec..4daf531 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -15,6 +15,8 @@ class ManualOffscreenRenderCanvas(BaseRenderCanvas): Call the ``.draw()`` method to perform a draw and get the result. """ + _rc_canvas_group = None # unmanaged, no loop + def __init__(self, *args, pixel_ratio=1.0, **kwargs): super().__init__(*args, **kwargs) self._pixel_ratio = pixel_ratio @@ -24,9 +26,6 @@ def __init__(self, *args, pixel_ratio=1.0, **kwargs): # %% Methods to implement RenderCanvas - def _rc_get_loop(self): - return None # no scheduling - def _rc_gui_poll(self): pass @@ -90,6 +89,7 @@ def draw(self): RenderCanvas = ManualOffscreenRenderCanvas +# todo: move to different module class StubLoop(BaseLoop): # If we consider the use-cases for using this offscreen canvas: # diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 1334556..8f6dc35 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -10,7 +10,7 @@ import importlib -from .base import WrapperRenderCanvas, BaseRenderCanvas, BaseLoop +from .base import WrapperRenderCanvas, BaseCanvasGroup, BaseRenderCanvas, BaseLoop from ._coreutils import ( logger, SYSTEM_IS_WAYLAND, @@ -157,9 +157,66 @@ def enable_hidpi(): ) +class QtLoop(BaseLoop): + _app = None + _we_run_the_loop = False + + def _rc_init(self): + if self._app is None: + app = QtWidgets.QApplication.instance() + if app is None: + self._app = QtWidgets.QApplication([]) + + def _rc_run(self): + # Note: we could detect if asyncio is running (interactive session) and wheter + # we can use QtAsyncio. However, there's no point because that's up for the + # end-user to decide. + + # Note: its possible, and perfectly ok, if the application is started from user + # code. This works fine because the application object is global. This means + # though, that we cannot assume anything based on whether this method is called + # or not. + + if already_had_app_on_import: + return # Likely in an interactive session or larger application that will start the Qt app. + + self._we_run_the_loop = True + try: + app = self._app + app.setQuitOnLastWindowClosed(False) + app.exec() if hasattr(app, "exec") else app.exec_() + finally: + self._we_run_the_loop = False + + async def _rc_run_async(self): + raise NotImplementedError() + + def _rc_stop(self): + # Note: is only called when we're inside _rc_run + if self._we_run_the_loop: + self._app.quit() + + def _rc_add_task(self, async_func, name): + # we use the async adapter with call_later + return super()._rc_add_task(async_func, name) + + def _rc_call_later(self, delay, callback): + delay_ms = int(max(0, delay * 1000)) + QtCore.QTimer.singleShot(delay_ms, callback) + + +loop = QtLoop() + + +class QtCanvasGroup(BaseCanvasGroup): + pass + + class QRenderWidget(BaseRenderCanvas, QtWidgets.QWidget): """A QWidget representing a render canvas that can be embedded in a Qt application.""" + _rc_canvas_group = QtCanvasGroup(loop) + def __init__(self, *args, present_method=None, **kwargs): super().__init__(*args, **kwargs) @@ -234,11 +291,14 @@ def update(self): # %% Methods to implement RenderCanvas - def _rc_get_loop(self): - return loop # qt loop only - def _rc_gui_poll(self): - pass # we assume the Qt event loop is running. Calling processEvents() will cause recursive repaints. + if isinstance(self._rc_canvas_group.get_loop(), QtLoop): + # If the Qt event loop is running, qt events are already processed, and calling processEvents() will cause recursive repaints. + pass + else: + # Running from another loop. Not recommended, but it could work. + loop._app.sendPostedEvents() + loop._app.processEvents() def _rc_get_present_methods(self): global _show_image_method_warning @@ -482,7 +542,7 @@ class QRenderCanvas(WrapperRenderCanvas, QtWidgets.QWidget): def __init__(self, parent=None, **kwargs): # There needs to be an application before any widget is created. - loop.init_qt() + loop._rc_init() # Any kwargs that we want to pass to *this* class, must be explicitly # specified in the signature. The rest goes to the subwidget. super().__init__(parent) @@ -515,52 +575,6 @@ def closeEvent(self, event): # noqa: N802 self._subwidget.closeEvent(event) -class QtLoop(BaseLoop): - def init_qt(self): - _ = self._app - self._latest_timeout = 0 - - @property - def _app(self): - """Return global instance of Qt app instance or create one if not created yet.""" - # Note: PyQt6 needs the app to be stored, or it will be gc'd. - app = QtWidgets.QApplication.instance() - if app is None: - self._the_app = app = QtWidgets.QApplication([]) - return app - - def _rc_run(self): - # Note: we could detect if asyncio is running (interactive session) and wheter - # we can use QtAsyncio. However, there's no point because that's up for the - # end-user to decide. - - # Note: its possible, and perfectly ok, if the application is started from user - # code. This works fine because the application object is global. This means - # though, that we cannot assume anything based on whether this method is called - # or not. - - if already_had_app_on_import: - return # Likely in an interactive session or larger application that will start the Qt app. - - app = self._app - app.setQuitOnLastWindowClosed(False) - app.exec() if hasattr(app, "exec") else app.exec_() - - def _rc_stop(self): - # Note: is only called when we're inside _rc_run - self._app.quit() - - def _rc_add_task(self, async_func, name): - # we use the async adapter with call_later - return super()._rc_add_task(async_func, name) - - def _rc_call_later(self, delay, callback): - delay_ms = int(max(0, delay * 1000)) - QtCore.QTimer.singleShot(delay_ms, callback) - - -loop = QtLoop() - # Make available under a name that is the same for all gui backends RenderWidget = QRenderWidget RenderCanvas = QRenderCanvas diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index 16564f8..fda90e0 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -28,11 +28,10 @@ def _draw_frame_and_present(self): # Must be implemented by subclasses. - def _rc_get_loop(self): - return None + _rc_canvas_group = None # todo: must end up in the docs def _rc_gui_poll(self): - pass + raise NotImplementedError() def _rc_get_present_methods(self): raise NotImplementedError() @@ -86,9 +85,15 @@ class StubLoop(BaseLoop): Backends must subclass ``BaseLoop`` and implement a set of methods prefixed with ``_rc__``. """ + def _rc_init(self): + raise NotImplementedError() + def _rc_run(self): raise NotImplementedError() + async def _rc_run_async(self): + raise NotImplementedError() + def _rc_stop(self): raise NotImplementedError() diff --git a/rendercanvas/trio.py b/rendercanvas/trio.py index 7ded6b2..ece51c1 100644 --- a/rendercanvas/trio.py +++ b/rendercanvas/trio.py @@ -1,25 +1,35 @@ """ Implements a trio event loop for backends that don't have an event loop by themselves, like glfw. +Also supports a trio-friendly way to run or wait for the loop using ``run_async()``. """ __all__ = ["TrioLoop", "loop"] - from .base import BaseLoop +import sniffio + class TrioLoop(BaseLoop): - def __init__(self): - super().__init__() + def _rc_init(self): import trio - self._pending_tasks = [] self._cancel_scope = None self._send_channel, self._receive_channel = trio.open_memory_channel(99) - async def run_async(self): + def _rc_run(self): + import trio + + trio.run(self._rc_run_async, restrict_keyboard_interrupt_to_checkpoints=False) + + async def _rc_run_async(self): import trio + # Protect against usage of wrong loop object + libname = sniffio.current_async_library() + if libname != "trio": + raise TypeError(f"Attempt to run TrioLoop with {libname}.") + with trio.CancelScope() as self._cancel_scope: async with trio.open_nursery() as nursery: while True: @@ -27,11 +37,6 @@ async def run_async(self): nursery.start_soon(async_func, name=name) self._cancel_scope = None - def _rc_run(self): - import trio - - trio.run(self.run_async, restrict_keyboard_interrupt_to_checkpoints=False) - def _rc_stop(self): # Cancel the main task and all its child tasks. if self._cancel_scope is not None: diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index caf06d1..7840550 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -21,6 +21,7 @@ from .base import ( WrapperRenderCanvas, BaseRenderCanvas, + BaseCanvasGroup, BaseLoop, ) @@ -126,9 +127,60 @@ def enable_hidpi(): ) +class TimerWithCallback(wx.Timer): + def __init__(self, callback): + super().__init__() + self._callback = callback + + def Notify(self, *args): # noqa: N802 + try: + self._callback() + except RuntimeError: + pass # wrapped C/C++ object of type WxRenderWidget has been deleted + + +class WxLoop(BaseLoop): + _app = None + + def _rc_init(self): + if self._app is None: + app = wx.App.GetInstance() + if app is None: + self._app = wx.App() + wx.App.SetInstance(app) + + def _rc_run(self): + self._app.MainLoop() + + async def _rc_run_async(self): + raise NotImplementedError() + + def _rc_stop(self): + # It looks like we cannot make wx stop the loop. + # In general not a problem, because the BaseLoop will try + # to close all windows before stopping a loop. + pass + + def _rc_add_task(self, async_func, name): + # we use the async adapter with call_later + return super()._rc_add_task(async_func, name) + + def _rc_call_later(self, delay, callback): + raise NotImplementedError() # todo: wx.CallSoon(callback, args) + + +loop = WxLoop() + + +class WxCanvasGroup(BaseCanvasGroup): + pass + + class WxRenderWidget(BaseRenderCanvas, wx.Window): """A wx Window representing a render canvas that can be embedded in a wx application.""" + _rc_canvas_group = WxCanvasGroup(loop) + def __init__(self, *args, present_method=None, **kwargs): super().__init__(*args, **kwargs) @@ -201,11 +253,17 @@ def _get_surface_ids(self): # %% Methods to implement RenderCanvas - def _rc_get_loop(self): - return loop # wx loop only - def _rc_gui_poll(self): - pass # We can assume the wx loop is running. + if isinstance(self._rc_canvas_group.get_loop(), WxLoop): + pass # all is well + else: + old_loop = wx.EventLoop.GetActive() + event_loop = wx.EventLoop() + wx.EventLoop.SetActive(event_loop) + while event_loop.Pending(): + event_loop.Dispatch() + self.app.ProcessIdle() + wx.EventLoop.SetActive(old_loop) def _rc_get_present_methods(self): if self._surface_ids is None: @@ -434,7 +492,7 @@ class WxRenderCanvas(WrapperRenderCanvas, wx.Frame): def __init__(self, parent=None, **kwargs): # There needs to be an application before any widget is created. - loop.init_wx() + loop._rc_init() # Any kwargs that we want to pass to *this* class, must be explicitly # specified in the signature. The rest goes to the subwidget. super().__init__(parent) @@ -453,60 +511,6 @@ def Destroy(self): # noqa: N802 - this is a wx method super().Destroy() -class TimerWithCallback(wx.Timer): - def __init__(self, callback): - super().__init__() - self._callback = callback - - def Notify(self, *args): # noqa: N802 - try: - self._callback() - except RuntimeError: - pass # wrapped C/C++ object of type WxRenderWidget has been deleted - - -class WxLoop(BaseLoop): - _the_app = None - - def init_wx(self): - _ = self._app - - @property - def _app(self): - app = wx.App.GetInstance() - if app is None: - self._the_app = app = wx.App() - wx.App.SetInstance(app) - return app - - def process_wx_events(self): - old = wx.GUIEventLoop.GetActive() - new = wx.GUIEventLoop() - wx.GUIEventLoop.SetActive(new) - while new.Pending(): - new.Dispatch() - wx.GUIEventLoop.SetActive(old) - - def _rc_run(self): - self._app.MainLoop() - - def _rc_stop(self): - # It looks like we cannot make wx stop the loop. - # In general not a problem, because the BaseLoop will try - # to close all windows before stopping a loop. - pass - - def _rc_add_task(self, async_func, name): - # we use the async adapter with call_later - return super()._rc_add_task(async_func, name) - - def _rc_call_later(self, delay, callback): - raise NotImplementedError() # todo: wx.CallSoon(callback, args) - - -loop = WxLoop() - - # Make available under a name that is the same for all gui backends RenderWidget = WxRenderWidget RenderCanvas = WxRenderCanvas diff --git a/tests/test_backends.py b/tests/test_backends.py index da354f5..897143e 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -72,9 +72,15 @@ def get_bases(self, class_def): def get_rc_methods(self, class_def): rc_methods = set() for statement in class_def.body: - if isinstance(statement, ast.FunctionDef): + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): if statement.name.startswith("_rc_"): rc_methods.add(statement.name) + # We also have a few attrs, we just use the method-logic here + if isinstance(statement, ast.Assign): + if isinstance(statement.targets[0], ast.Name): + name = statement.targets[0].id + if name.startswith("_rc_"): + rc_methods.add(name) return rc_methods def check_rc_methods(self, rc_methods, ref_rc_methods): @@ -241,12 +247,6 @@ def test_jupyter_module(): m.check_canvas(canvas_class) assert canvas_class.name == "JupyterRenderCanvas" - loop_class = m.get_loop_class() - assert loop_class.name == "JupyterAsyncioLoop" - - # Loop is provided by our asyncio module - assert m.get_bases(loop_class) == ["AsyncioLoop"] - def test_offscreen_module(): m = Module("offscreen") @@ -255,10 +255,6 @@ def test_offscreen_module(): m.check_canvas(canvas_class) assert canvas_class.name == "ManualOffscreenRenderCanvas" - loop_class = m.get_loop_class() - m.check_loop(loop_class) - assert loop_class.name == "StubLoop" - def test_qt_module(): m = Module("qt") diff --git a/tests/test_glfw.py b/tests/test_glfw.py index 843941d..f15814c 100644 --- a/tests/test_glfw.py +++ b/tests/test_glfw.py @@ -67,10 +67,11 @@ def test_glfw_canvas_basics(): def test_glfw_canvas_del(): from rendercanvas.glfw import RenderCanvas, loop + aio_loop = asyncio.new_event_loop() + loop_task = aio_loop.create_task(loop.run_async()) + def run_briefly(): - asyncio_loop = loop._loop - asyncio_loop.run_until_complete(asyncio.sleep(0.5)) - # poll_glfw_briefly() + aio_loop.run_until_complete(asyncio.sleep(0.5)) canvas = RenderCanvas() ref = weakref.ref(canvas) @@ -83,6 +84,11 @@ def run_briefly(): gc.collect() # force garbage collection for pypy assert ref() is None + # Loop shuts down + assert not loop_task.done() + run_briefly() + assert loop_task.done() + shader_source = """ @vertex @@ -103,14 +109,14 @@ def test_glfw_canvas_render(): """Render an orange square ... in a glfw window.""" import wgpu - import glfw from rendercanvas.glfw import RenderCanvas from rendercanvas.asyncio import loop + aio_loop = asyncio.new_event_loop() + loop_task = aio_loop.create_task(loop.run_async()) + def run_briefly(): - asyncio_loop = loop._loop - asyncio_loop.run_until_complete(asyncio.sleep(0.5)) - # poll_glfw_briefly() + aio_loop.run_until_complete(asyncio.sleep(0.5)) canvas = RenderCanvas(max_fps=9999, update_mode="ondemand") @@ -124,11 +130,6 @@ def draw_frame2(): frame_counter += 1 draw_frame1() - # todo: we must also deactivate/stop the loop - # do we need a notion of the loop being active vs running? - # aaaaaaarg! - loop.activate() - canvas.request_draw(draw_frame2) run_briefly() @@ -151,8 +152,12 @@ def draw_frame2(): run_briefly() assert frame_counter == 3 + # Stopping + assert not loop_task.done() canvas.close() - glfw.poll_events() + assert not loop_task.done() + run_briefly() + assert loop_task.done() def _get_draw_function(device, canvas): diff --git a/tests/test_loop.py b/tests/test_loop.py index 5d32ab3..50afd1f 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -4,14 +4,28 @@ import time import signal +import asyncio import threading +from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas from rendercanvas.asyncio import AsyncioLoop +from rendercanvas.trio import TrioLoop from testutils import run_tests +import trio + +import pytest + + +async def fake_task(): + pass + + +class CanvasGroup(BaseCanvasGroup): + pass class FakeCanvas: - def __init__(self, refuse_close): + def __init__(self, refuse_close=False): self.refuse_close = refuse_close self.is_closed = False @@ -23,17 +37,25 @@ def _rc_close(self): if not self.refuse_close: self.is_closed = True + def get_closed(self): + return self.is_closed -class FakeScheduler: - def __init__(self, refuse_close=False): - self._canvas = FakeCanvas(refuse_close) + def manually_close(self): + self.is_closed = True + + +real_loop = AsyncioLoop() + + +class RealRenderCanvas(BaseRenderCanvas): + _rc_canvas_group = CanvasGroup(real_loop) + _is_closed = False - def get_canvas(self): - if self._canvas and not self._canvas.is_closed: - return self._canvas + def _rc_close(self): + self._is_closed = True - def close_canvas(self): - self._canvas = None + def _rc_get_closed(self): + return self._is_closed def test_run_loop_and_close_bc_no_canvases(): @@ -43,19 +65,89 @@ def test_run_loop_and_close_bc_no_canvases(): loop.run() +def test_loop_detects_canvases(): + # After all canvases are closed, it can take one tick before its detected. + + loop = AsyncioLoop() + + group1 = CanvasGroup(loop) + group2 = CanvasGroup(loop) + + assert len(loop._BaseLoop__canvas_groups) == 0 + + canvas1 = FakeCanvas() + group1._register_canvas(canvas1, fake_task) + + assert len(loop._BaseLoop__canvas_groups) == 1 + assert len(loop.get_canvases()) == 1 + + canvas2 = FakeCanvas() + group1._register_canvas(canvas2, fake_task) + + canvas3 = FakeCanvas() + group2._register_canvas(canvas3, fake_task) + + assert len(loop._BaseLoop__canvas_groups) == 2 + assert len(loop.get_canvases()) == 3 + + +def test_run_loop_without_canvases(): + # After all canvases are closed, it can take one tick before its detected. + + loop = AsyncioLoop() + group = CanvasGroup(loop) + + # The loop is in its stopped state, but it fires up briefly to do one tick + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.0 <= et < 0.15 + + # Create a canvas and close it right away + + canvas1 = FakeCanvas() + group._register_canvas(canvas1, fake_task) + assert len(loop.get_canvases()) == 1 + canvas1.manually_close() + assert len(loop.get_canvases()) == 0 + + # This time the loop is in its ready state, so it will actually + # run for one tick for it to notice that all canvases are gone. + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.0 <= et < 0.15 + + # Now its in its stopped state again + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.0 <= et < 0.15 + + def test_run_loop_and_close_canvases(): # After all canvases are closed, it can take one tick before its detected. loop = AsyncioLoop() + group = CanvasGroup(loop) - scheduler1 = FakeScheduler() - scheduler2 = FakeScheduler() - loop._register_scheduler(scheduler1) - loop._register_scheduler(scheduler2) + canvas1 = FakeCanvas() + canvas2 = FakeCanvas() + group._register_canvas(canvas1, fake_task) + group._register_canvas(canvas2, fake_task) loop.call_later(0.1, print, "hi from loop!") - loop.call_later(0.1, scheduler1.close_canvas) - loop.call_later(0.3, scheduler2.close_canvas) + loop.call_later(0.1, canvas1.manually_close) + loop.call_later(0.3, canvas2.manually_close) t0 = time.time() loop.run() @@ -68,11 +160,12 @@ def test_run_loop_and_close_canvases(): def test_run_loop_and_close_with_method(): # Close, then wait at most one tick to close canvases, and another to conform close. loop = AsyncioLoop() + group = CanvasGroup(loop) - scheduler1 = FakeScheduler() - scheduler2 = FakeScheduler() - loop._register_scheduler(scheduler1) - loop._register_scheduler(scheduler2) + canvas1 = FakeCanvas() + canvas2 = FakeCanvas() + group._register_canvas(canvas1, fake_task) + group._register_canvas(canvas2, fake_task) loop.call_later(0.1, print, "hi from loop!") loop.call_later(0.3, loop.stop) @@ -85,15 +178,56 @@ def test_run_loop_and_close_with_method(): assert 0.25 < et < 0.55 +def test_run_loop_and_close_by_deletion(): + # Make the canvases be deleted by the gc. + + loop = AsyncioLoop() + group = CanvasGroup(loop) + + canvases = [FakeCanvas() for _ in range(2)] + for canvas in canvases: + group._register_canvas(canvas, fake_task) + del canvas + + loop.call_later(0.3, canvases.clear) + loop.call_later(1.3, loop.stop) # failsafe + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.25 < et < 0.55 + + +def test_run_loop_and_close_by_deletion_real(): + # Stop by deleting canvases, with a real canvas. + # This tests that e.g. scheduler task does not hold onto the canvas. + loop = real_loop + + canvases = [RealRenderCanvas() for _ in range(2)] + + loop.call_later(0.3, canvases.clear) + loop.call_later(1.3, loop.stop) # failsafe + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.25 < et < 0.55 + + def test_run_loop_and_interrupt(): # Interrupt, calls close, can take one tick to close canvases, and anoter to conform close. loop = AsyncioLoop() + group = CanvasGroup(loop) - scheduler1 = FakeScheduler() - scheduler2 = FakeScheduler() - loop._register_scheduler(scheduler1) - loop._register_scheduler(scheduler2) + canvas1 = FakeCanvas() + canvas2 = FakeCanvas() + group._register_canvas(canvas1, fake_task) + group._register_canvas(canvas2, fake_task) loop.call_later(0.1, print, "hi from loop!") @@ -117,11 +251,12 @@ def test_run_loop_and_interrupt_harder(): # In the next tick after the second interupt, it stops the loop without closing the canvases loop = AsyncioLoop() + group = CanvasGroup(loop) - scheduler1 = FakeScheduler(refuse_close=True) - scheduler2 = FakeScheduler(refuse_close=True) - loop._register_scheduler(scheduler1) - loop._register_scheduler(scheduler2) + canvas1 = FakeCanvas(refuse_close=True) + canvas2 = FakeCanvas(refuse_close=True) + group._register_canvas(canvas1, fake_task) + group._register_canvas(canvas2, fake_task) loop.call_later(0.1, print, "hi from loop!") @@ -149,5 +284,33 @@ def test_loop_threaded(): t.join() +def test_async_loops_check_lib(): + # Cannot run asyncio loop on trio + + asyncio_loop = AsyncioLoop() + group = CanvasGroup(asyncio_loop) + canvas1 = FakeCanvas() + group._register_canvas(canvas1, fake_task) + canvas1.manually_close() + + with pytest.raises(TypeError): + trio.run(asyncio_loop.run_async) + + asyncio.run(asyncio_loop.run_async()) + + # Cannot run trio loop on asyncio + + trio_loop = TrioLoop() + group = CanvasGroup(trio_loop) + canvas1 = FakeCanvas() + group._register_canvas(canvas1, fake_task) + canvas1.manually_close() + + with pytest.raises(TypeError): + asyncio.run(trio_loop.run_async()) + + trio.run(trio_loop.run_async) + + if __name__ == "__main__": run_tests(globals()) diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index 106219d..dfcc9f0 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -6,7 +6,11 @@ import time from testutils import run_tests -from rendercanvas import BaseRenderCanvas, BaseLoop +from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas, BaseLoop + + +class MyCanvasGroup(BaseCanvasGroup): + pass class MyLoop(BaseLoop): @@ -43,17 +47,14 @@ def _rc_call_later(self, delay, callback): class MyCanvas(BaseRenderCanvas): - _loop = MyLoop() - _gui_draw_requested = False + _rc_canvas_group = MyCanvasGroup(MyLoop()) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._closed = False self.draw_count = 0 self.events_count = 0 - - def _rc_get_loop(self): - return self._loop + self._gui_draw_requested = False def _rc_close(self): self._closed = True @@ -78,7 +79,7 @@ def draw_if_necessary(self): self._draw_frame_and_present() def active_sleep(self, delay): - loop = self._rc_get_loop() + loop = self._rc_canvas_group.get_loop() # <---- etime = time.perf_counter() + delay while time.perf_counter() < etime: time.sleep(0.001) From d9502152a65b6a9fd73b1e4d412e5b6a4d525005 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 5 Dec 2024 12:08:27 +0100 Subject: [PATCH 06/22] extra tests --- rendercanvas/_events.py | 2 +- rendercanvas/_loop.py | 2 +- rendercanvas/trio.py | 5 +-- tests/test_meta.py | 99 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 tests/test_meta.py diff --git a/rendercanvas/_events.py b/rendercanvas/_events.py index 19c7944..032e0c0 100644 --- a/rendercanvas/_events.py +++ b/rendercanvas/_events.py @@ -3,7 +3,7 @@ """ import time -from asyncio import iscoroutinefunction # note: is not asyncio-specific +from inspect import iscoroutinefunction # note: is not asyncio-specific from collections import defaultdict, deque from ._coreutils import log_exception, BaseEnum diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 4aed009..5404b12 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -3,10 +3,10 @@ """ import signal +from inspect import iscoroutinefunction from ._coreutils import logger, log_exception from .utils.asyncs import sleep -from asyncio import iscoroutinefunction from ._async_adapter import Task as AsyncAdapterTask diff --git a/rendercanvas/trio.py b/rendercanvas/trio.py index ece51c1..7443753 100644 --- a/rendercanvas/trio.py +++ b/rendercanvas/trio.py @@ -7,6 +7,7 @@ from .base import BaseLoop +import trio import sniffio @@ -18,13 +19,9 @@ def _rc_init(self): self._send_channel, self._receive_channel = trio.open_memory_channel(99) def _rc_run(self): - import trio - trio.run(self._rc_run_async, restrict_keyboard_interrupt_to_checkpoints=False) async def _rc_run_async(self): - import trio - # Protect against usage of wrong loop object libname = sniffio.current_async_library() if libname != "trio": diff --git a/tests/test_meta.py b/tests/test_meta.py new file mode 100644 index 0000000..20418a0 --- /dev/null +++ b/tests/test_meta.py @@ -0,0 +1,99 @@ +""" +Test some meta stuff. +""" + +import sys +import subprocess +from testutils import run_tests + +import rendercanvas + + +CODE = """ +import sys +ignore_names = set(sys.modules) +ignore_names |= set(sys.stdlib_module_names) +ignore_names.discard("asyncio") +import MODULE_NAME +module_names = [n for n in sys.modules if n.split(".")[0] not in ignore_names] +module_names = [n for n in module_names if not n.startswith("_")] +print(', '.join(module_names)) +""" + + +def get_loaded_modules(module_name, depth=1): + """Get what deps are loaded for a given module. + + Import the given module in a subprocess and return a set of + module names that were imported as a result. + + The given depth indicates the module level (i.e. depth=1 will only + yield 'X.Y' but not 'X.Y.Z'). + """ + + code = ( + "; ".join(CODE.strip().splitlines()).strip().replace("MODULE_NAME", module_name) + ) + + p = subprocess.run([sys.executable, "-c", code], capture_output=True) + assert not p.stderr, p.stderr.decode() + loaded_modules = set(name.strip() for name in p.stdout.decode().split(",")) + + # Filter by depth + filtered_modules = set() + if not depth: + filtered_modules = set(loaded_modules) + else: + for m in loaded_modules: + parts = m.split(".") + m = ".".join(parts[:depth]) + filtered_modules.add(m) + + return filtered_modules + + +# %% + + +def test_version_is_there(): + assert rendercanvas.__version__ + assert rendercanvas.version_info + + +def test_namespace(): + # Yes + assert "BaseRenderCanvas" in dir(rendercanvas) + assert "BaseLoop" in dir(rendercanvas) + assert "EventType" in dir(rendercanvas) + + # No + assert "WrapperRenderCanvas" not in dir(rendercanvas) + assert "BaseCanvasGroup" not in dir(rendercanvas) + assert "Scheduler" not in dir(rendercanvas) + + +def test_deps_plain_import(): + modules = get_loaded_modules("rendercanvas", 1) + assert modules == {"rendercanvas", "sniffio"} + + +def test_deps_asyncio(): + # I like it that asyncio is only imported when actually being used. + # Since its the default loop for some backends, it must lazy-import. + # We can do this safely because asyncio is std. + modules = get_loaded_modules("rendercanvas.asyncio", 1) + assert "asyncio" not in modules + + # Check that we can indeed see an asyncio import (asyncio being std) + modules = get_loaded_modules("sys, asyncio", 1) + assert "asyncio" in modules + + +def test_deps_trio(): + # For trio, I like that if the trio module is loaded, trio is imported, fail early. + modules = get_loaded_modules("rendercanvas.trio", 1) + assert "trio" in modules + + +if __name__ == "__main__": + run_tests(globals()) From 9e2d0ab82e1e6d98687b955d608270776d167186 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 5 Dec 2024 13:24:18 +0100 Subject: [PATCH 07/22] Proper closing mechanics, finally --- rendercanvas/_events.py | 25 +++++++++++++++++++------ rendercanvas/_loop.py | 21 ++++++++++++++++----- rendercanvas/_scheduler.py | 12 ++++-------- rendercanvas/base.py | 12 ++++++++++-- 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/rendercanvas/_events.py b/rendercanvas/_events.py index 032e0c0..6e4e875 100644 --- a/rendercanvas/_events.py +++ b/rendercanvas/_events.py @@ -65,7 +65,11 @@ def __init__(self): self._pending_events = deque() self._event_handlers = defaultdict(list) self._closed = False - # todo: remove all handlers when closing + + def _set_closed(self): + self._closed = True + self._pending_events.clear() + self._event_handlers.clear() def add_handler(self, *args, order: float = 0): """Register an event handler to receive events. @@ -133,6 +137,8 @@ def decorator(_callback): return decorator(callback) def _add_handler(self, callback, order, *types): + if self._closed: + return self.remove_handler(callback, *types) for type in types: self._event_handlers[type].append((order, callback)) @@ -157,6 +163,8 @@ def submit(self, event): Events are emitted later by the scheduler. """ + if self._closed: + return event_type = event["event_type"] if event_type not in EventType: raise ValueError(f"Submitting with invalid event_type: '{event_type}'") @@ -202,8 +210,6 @@ async def emit(self, event): """ # Collect callbacks event_type = event.get("event_type") - if event_type == "close": - self._closed = True callbacks = self._event_handlers[event_type] + self._event_handlers["*"] # Dispatch for _order, callback in callbacks: @@ -214,10 +220,17 @@ async def emit(self, event): await callback(event) else: callback(event) + # Close? + if event_type == "close": + self._set_closed() - async def _rc_canvas_close(self): - """Wrap up when the scheduler detects the canvas is closed/dead.""" + async def close(self): + """Close the event handler. + + Drops all pending events, send the close event, and disables the emitter. + """ # This is a little feature because detecting a widget from closing can be tricky. if not self._closed: self._pending_events.clear() - await self.emit({"event_type": "close"}) + self.submit({"event_type": "close"}) + await self.flush() diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 5404b12..2c7daf9 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -88,20 +88,34 @@ async def _loop_task(self): # Detect active loop self.__state = max(self.__state, 2) + # Keep track of event emitter objects + event_emitters = {id(c): c._events for c in self.get_canvases()} + try: while True: await sleep(0.1) + # Get list of canvases, beware to delete the list when we're done with it! canvases = self.get_canvases() + # Send close event for closed canvases + new_event_emitters = {id(c): c._events for c in canvases} + closed_canvas_ids = set(event_emitters) - set(new_event_emitters) + for canvas_id in closed_canvas_ids: + events = event_emitters[canvas_id] + await events.close() + # Keep canvases alive for canvas in canvases: canvas._rc_gui_poll() del canvas + canvas_count = len(canvases) + del canvases + # Should we stop? - if not canvases: + if canvas_count == 0: # Stop when there are no more canvases break elif self.__should_stop >= 2: @@ -109,12 +123,11 @@ async def _loop_task(self): break elif self.__should_stop: # Close all remaining canvases. Loop will stop in a next iteration. - for canvas in canvases: + 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 - del canvases finally: self._stop() @@ -124,8 +137,6 @@ def add_task(self, async_func, *args, name="unnamed"): All tasks are stopped when the loop stops. """ - # todo: implement iscoroutinefunction outside of asyncio - # todo: test that we don't even import asyncio by default if not (callable(async_func) and iscoroutinefunction(async_func)): raise TypeError("add_task() expects an async function.") diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index 478232c..bcf06a7 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -193,14 +193,10 @@ async def __scheduler_task(self): await self._async_draw_event.wait() last_draw_time = time.perf_counter() - # todo: sending the close event is tricky. - # Even if the canvas has submitted its close event, it may not be flushed yet. - # We can flush here, but the problem is that when all canvases are closed, the loop - # closes and cancels all tasks, including this one. We can write a finally-clause, - # so that we can do something even when being cancelled. However, we cannot await - # something there .... sigh! Maybe if we require the close-handlers to be sync? - # self._events._rc_canvas_close() - await self._events.flush() + # Note that when the canvas is closed, we may detect it here and break from the loop. + # But the task may also be waiting for a draw to happen, or something else. In that case + # this task will be cancelled when the loop ends. In any case, this is why this is not + # a good place to detect the canvas getting closed, the loop does this. def on_draw(self): # Bookkeeping diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 938fb32..28e29d3 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -545,8 +545,16 @@ def _rc_set_logical_size(self, width, height): def _rc_close(self): """Close the canvas. - Note that ``BaseRenderCanvas`` implements the ``close()`` method, which - is a rather common name; it may be necessary to re-implement that too. + Note that ``BaseRenderCanvas`` implements the ``close()`` method, which is a + rather common name; it may be necessary to re-implement that too. + + Backends should probably not mark the canvas as closed yet, but wait until the + underlying system really closes the canvas. Otherwise the loop may end before a + canvas gets properly cleaned up. + + Backends can emit a closed event, either in this method, or when the real close + happens, but this is optional, since the loop detects canvases getting closed + and sends the close event if this has not happened yet. """ pass From 5ec4fc4943319c60455ee35a1e09b540ee29367d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 5 Dec 2024 13:47:13 +0100 Subject: [PATCH 08/22] docs --- docs/backendapi.rst | 15 ++++++++++----- rendercanvas/base.py | 6 +++++- rendercanvas/offscreen.py | 3 ++- rendercanvas/stub.py | 25 +++++++++++++++++++++++-- 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/docs/backendapi.rst b/docs/backendapi.rst index 3c31945..1067a62 100644 --- a/docs/backendapi.rst +++ b/docs/backendapi.rst @@ -5,22 +5,27 @@ This page documents what's needed to implement a backend for ``rendercanvas``. T to help maintain current and new backends. Making this internal API clear helps understanding how the backend-system works. Also see https://github.com/pygfx/rendercanvas/blob/main/rendercanvas/stub.py. -It is possible to create a custom backend (outside of the ``rendercanvas`` package). However, we consider this API an internal detail that may change -with each version without warning. +.. note:: -.. autoclass:: rendercanvas.base.WrapperRenderCanvas + It is possible to create a custom backend (outside of the ``rendercanvas`` package). However, we consider this API an internal detail that may change + with each version without warning. -.. autoclass:: rendercanvas.stub.StubRenderCanvas + +.. autoclass:: rendercanvas.stub.StubCanvasGroup :members: :private-members: :member-order: bysource -.. autoclass:: rendercanvas.stub.StubTimer +.. autoclass:: rendercanvas.stub.StubRenderCanvas :members: :private-members: :member-order: bysource + +.. autoclass:: rendercanvas.base.WrapperRenderCanvas + + .. autoclass:: rendercanvas.stub.StubLoop :members: :private-members: diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 28e29d3..7e072ae 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -88,7 +88,11 @@ class BaseRenderCanvas: """ - _rc_canvas_group = None # todo: doc this + _rc_canvas_group = None + """Class attribute that refers to the ``CanvasGroup`` instance to use for canvases of this class. + It specifies what loop is used, and enables users to changing the used loop. + Set to None to not use a loop. + """ def __init__( self, diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 4daf531..29880f6 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -89,8 +89,9 @@ def draw(self): RenderCanvas = ManualOffscreenRenderCanvas -# todo: move to different module class StubLoop(BaseLoop): + # Note: we can move this into its own module if it turns out we need this in more places. + # # If we consider the use-cases for using this offscreen canvas: # # * Using rendercanvas.auto in test-mode: in this case run() should not hang, diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index fda90e0..789f776 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -4,11 +4,26 @@ __all__ = ["RenderCanvas", "loop"] -from .base import WrapperRenderCanvas, BaseRenderCanvas, BaseLoop +from .base import BaseCanvasGroup, WrapperRenderCanvas, BaseRenderCanvas, BaseLoop + + +class StubCanvasGroup(BaseCanvasGroup): + """ + The ``CanvasGroup`` representss a group of canvas objects from the same class, that share a loop. + + Backends can subclass ``BaseCanvasGroup`` and set an instance at their ``RenderCanvas._rc_canvas_group``. + It can also be omitted for canvases that don't need to run in a loop. Note that this class is only + for internal use, mainly to connect canvases to a loop; it is not public API. + + The subclassing is only really done so the group has a distinguishable name. Though we may add ``_rc_`` methods + to this class in the future. + """ class StubRenderCanvas(BaseRenderCanvas): """ + The ``RenderCanvas`` represents the canvas to render to. + Backends must subclass ``BaseRenderCanvas`` and implement a set of methods prefixed with ``_rc_``. This class also shows a few other private methods of the base canvas class, that a backend must be aware of. """ @@ -28,7 +43,7 @@ def _draw_frame_and_present(self): # Must be implemented by subclasses. - _rc_canvas_group = None # todo: must end up in the docs + _rc_canvas_group = None def _rc_gui_poll(self): raise NotImplementedError() @@ -82,6 +97,12 @@ def __init__(self, parent=None, **kwargs): class StubLoop(BaseLoop): """ + The ``Loop`` represents the event loop that drives the rendering and events. + + Some backends will provide a corresponding loop (like qt and ws). Other backends may use + existing loops (like glfw and jupyter). And then there are loop-backends that only implement + a loop (e.g. asyncio or trio). + Backends must subclass ``BaseLoop`` and implement a set of methods prefixed with ``_rc__``. """ From 05bd7f9679579573031d70953a28af255e65bdc7 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 5 Dec 2024 14:29:17 +0100 Subject: [PATCH 09/22] More docs --- docs/backends.rst | 40 ++++++++++++++++++++++++++++-- docs/start.rst | 11 ++++----- rendercanvas/_async_adapter.py | 5 ++++ rendercanvas/_events.py | 6 +++++ rendercanvas/_scheduler.py | 45 +++++++++------------------------- rendercanvas/base.py | 32 ++++++++++-------------- 6 files changed, 78 insertions(+), 61 deletions(-) diff --git a/docs/backends.rst b/docs/backends.rst index ebdf4c7..0eae1ad 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -6,7 +6,7 @@ The auto backend Generally the best approach for examples and small applications is to use the automatically selected backend. This ensures that the code is portable -across different machines and environments. Using ``rendercanvas.auto`` selects a +across different machines and environments. Importinf from ``rendercanvas.auto`` selects a suitable backend depending on the environment and more. See :ref:`interactive_use` for details. @@ -37,6 +37,26 @@ but you can replace ``from rendercanvas.auto`` with ``from rendercanvas.glfw`` t loop.run() +By default, the ``glfw`` backend uses an event loop based on asyncio. But you can also select e.g. trio: + +.. code-block:: py + + from rendercanvas.glfw import RenderCanvas + from rendercanvas.trio import loop + + # Use another loop than the default + RenderCanvas.select_loop(loop) + + canvas = RenderCanvas(title="Example") + canvas.request_draw(your_draw_function) + + async def main(): + .. do your trio stuff + await loop.run_async() + + trio.run(main) + + Support for Qt -------------- @@ -80,6 +100,23 @@ Alternatively, you can select the specific qt library to use, making it easy to loop.run() # calls app.exec_() +It is technically possible to use a Qt canvas with another loop, or to e.g. use a ``glfw`` canvas +with the Qt loop. However, this is not recommended. Results may vary and even get you a segfault. + +.. code-block:: py + + from rendercanvas.pyside6 import RenderCanvas + from rendercanvas.trio import loop + + # Use another loop than the default + RenderCanvas.select_loop(loop) + + canvas = RenderCanvas(title="Example") + canvas.request_draw(your_draw_function) + + trio.run(loop.run_async) + + Support for wx -------------- @@ -104,7 +141,6 @@ embed the canvas as a subwidget, use ``rendercanvas.wx.RenderWidget`` instead. app.MainLoop() - Support for offscreen --------------------- diff --git a/docs/start.rst b/docs/start.rst index 2908e35..d1229a8 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -4,7 +4,7 @@ Getting started Installation ------------ -You can install ``rendercanvas`` via pip or similar. +You can install ``rendercanvas`` via pip (or most other Python package managers). Python 3.9 or higher is required. Pypy is supported. .. code-block:: bash @@ -12,16 +12,15 @@ Python 3.9 or higher is required. Pypy is supported. pip install rendercanvas -Since most users will want to render something to screen, we recommend installing GLFW as well: +Multiple backends are supported, including multiple GUI libraries, but none of these are installed by default. See :doc:`backends` for details. + +We recommend also installing `GLFW `_, so that you have a lightweight backend available from the start: .. code-block:: bash pip install rendercanvas glfw -Multiple backends are supported, including multiple GUI libraries, but none of these are installed by default. See :doc:`backends` for details. - - Creating a canvas ----------------- @@ -44,7 +43,7 @@ Rendering to the canvas The above just shows a grey window. We want to render to it by using wgpu or by generating images. Depending on the tool you'll use to render to the canvas, you need a different context. -The purpose of the context to present the rendered result to the canvas. +The purpose of the context is to present the rendered result to the canvas. There are currently two types of contexts. Rendering using bitmaps: diff --git a/rendercanvas/_async_adapter.py b/rendercanvas/_async_adapter.py index 293288e..ccf6be1 100644 --- a/rendercanvas/_async_adapter.py +++ b/rendercanvas/_async_adapter.py @@ -23,10 +23,13 @@ def __await__(self): async def sleep(delay): + """Async sleep for delay seconds.""" await Sleeper(delay) class Event: + """Event object similar to asyncio.Event and Trio.Event.""" + def __init__(self): self._is_set = False self._tasks = [] @@ -57,6 +60,8 @@ class CancelledError(BaseException): class Task: + """Represetation of task, exectuting a co-routine.""" + def __init__(self, loop, coro, name): self.loop = loop self.coro = coro diff --git a/rendercanvas/_events.py b/rendercanvas/_events.py index 6e4e875..d8ee73d 100644 --- a/rendercanvas/_events.py +++ b/rendercanvas/_events.py @@ -76,6 +76,7 @@ def add_handler(self, *args, order: float = 0): Arguments: callback (callable): The event handler. Must accept a single event argument. + Can be a plain function or a coroutine function. *types (list of strings): A list of event types. order (float): Set callback priority order. Callbacks with lower priorities are called first. Default is 0. @@ -86,6 +87,11 @@ def add_handler(self, *args, order: float = 0): When an event is emitted, callbacks with the same priority are called in the order that they were added. + If you use async callbacks and want to keep your code portable accross + different canvas backends, we recommend using ``sleep`` and ``Event`` from + ``rendercanvas.utils.asyncs``. If you know your code always runs on e.g. the + asyncio loop, you can fully make use of ``asyncio``. + The callback is stored, so it can be a lambda or closure. This also means that if a method is given, a reference to the object is held, which may cause circular references or prevent the Python GC from diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index bcf06a7..aa4b230 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -22,48 +22,25 @@ class Scheduler: """Helper class to schedule event processing and drawing.""" # This class makes the canvas tick. Since we do not own the event-loop, but - # ride on e.g. Qt, asyncio, wx, JS, or something else, our little "loop" is - # implemented with a timer. + # ride on e.g. Qt, asyncio, wx, JS, or something else, we need to abstract the loop. + # We implement it as an async function, that way we can cleanly support async event + # handlers. Note, however, that the draw-event cannot be async, so it does not + # fit well into the loop. An event is used to wait for it. # - # The loop looks a little like this: + # We have to make sure that there is only one invocation of the scheduler task, + # or we'd get double the intended FPS. # - # ________________ __ ________________ __ rd = request_draw - # / wait \ / rd \ / wait \ / rd \ - # | || || || | - # --------------------------------------------------------------------> time - # | | | | | - # schedule tick draw tick draw - # - # With update modes 'ondemand' and 'manual', the loop ticks at the same rate - # as on 'continuous' mode, but won't draw every tick: - # - # ________________ ________________ __ - # / wait \ / wait \ / rd \ - # | || || | - # --------------------------------------------------------------------> time - # | | | | - # schedule tick tick draw - # - # A tick is scheduled by calling _schedule_next_tick(). If this method is - # called when the timer is already running, it has no effect. In the _tick() - # method, events are processed (including animations). Then, depending on - # the mode and whether a draw was requested, a new tick is scheduled, or a - # draw is requested. In the latter case, the timer is not started, but we - # wait for the canvas to perform a draw. In _draw_drame_and_present() the - # draw is done, and a new tick is scheduled. - # - # The next tick is scheduled when a draw is done, and not earlier, otherwise + # After a draw is requested, we wait for it to happen, otherwise # the drawing may not keep up with the ticking. # # On desktop canvases the draw usually occurs very soon after it is - # requested, but on remote frame buffers, it may take a bit longer. To make - # sure the rendered image reflects the latest state, these backends may - # issue an extra call to _process_events() right before doing the draw. + # requested, but on remote frame buffers, it may take a bit longer, + # which may cause a laggy feeling. # # When the window is minimized, the draw will not occur until the window is # shown again. For the canvas to detect minimized-state, it will need to # receive GUI events. This is one of the reasons why the loop object also - # runs a timer-loop. + # runs a loop-task. # # The drawing itself may take longer than the intended wait time. In that # case, it will simply take longer than we hoped and get a lower fps. @@ -95,7 +72,7 @@ def __init__(self, canvas, events, *, mode="ondemand", min_fps=1, max_fps=30): self._draw_stats = 0, time.perf_counter() def get_task(self): - # Get task. Can be called exactly once. Used by the canvas. + """Get task. Can be called exactly once. Used by the canvas.""" task = self.__scheduler_task self.__scheduler_task = None assert task is not None diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 7e072ae..fb35140 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -64,9 +64,6 @@ def get_canvases(self): class BaseRenderCanvas: """The base canvas class. - Each backends provides its own canvas subclass by implementing a predefined - set of private methods. - This base class defines a uniform canvas API so render systems can use code that is portable accross multiple GUI libraries and canvas targets. The scheduling mechanics are generic, even though they run on different backend @@ -94,6 +91,18 @@ class BaseRenderCanvas: Set to None to not use a loop. """ + @classmethod + def select_loop(cls, loop): + """Select the loop to run newly created canvases with. + Can only be called when there are no live canvases of this class. + """ + group = cls._rc_canvas_group + if group is None: + raise NotImplementedError( + "The {cls.__name__} does not have a canvas group, thus no loop." + ) + group.select_loop(loop) + def __init__( self, *args, @@ -172,20 +181,6 @@ def __del__(self): except Exception: pass - # %% Static - - @classmethod - def select_loop(cls, loop): - """Select the loop to run newly created canvases with. - Can only be called when there are no live canvases of this class. - """ - group = cls._rc_canvas_group - if group is None: - raise NotImplementedError( - "The {cls.__name__} does not have a canvas group, thus no loop." - ) - group.select_loop(loop) - # %% Implement WgpuCanvasInterface _canvas_context = None # set in get_context() @@ -288,10 +283,9 @@ def submit_event(self, event): # %% Scheduling and drawing async def _process_events(self): - """Process events and animations. + """Process events and animations (async). Called from the scheduler. - Subclasses *may* call this if the time between ``_rc_request_draw`` and the actual draw is relatively long. """ # We don't want this to be called too often, because we want the From 2eadf4ebf37a0a1bd1d2bf72e063483314b1bde1 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 5 Dec 2024 15:24:10 +0100 Subject: [PATCH 10/22] docs examples tweaks --- docs/backends.rst | 4 ++-- examples/cube_asyncio.py | 31 +++++++++++++++++++++++++++++++ examples/cube_auto.py | 6 ++---- examples/cube_glfw.py | 1 - examples/cube_qt_trio.py | 27 +++++++++++++++++++++++++++ examples/cube_trio.py | 21 +++++++++++++++++---- examples/demo.py | 2 +- examples/qt_app.py | 6 +++++- rendercanvas/_async_adapter.py | 2 +- rendercanvas/_loop.py | 2 +- rendercanvas/base.py | 5 ++++- rendercanvas/glfw.py | 5 +++-- rendercanvas/qt.py | 5 +++++ 13 files changed, 99 insertions(+), 18 deletions(-) create mode 100644 examples/cube_asyncio.py create mode 100644 examples/cube_qt_trio.py diff --git a/docs/backends.rst b/docs/backends.rst index 0eae1ad..bceae22 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -100,8 +100,8 @@ Alternatively, you can select the specific qt library to use, making it easy to loop.run() # calls app.exec_() -It is technically possible to use a Qt canvas with another loop, or to e.g. use a ``glfw`` canvas -with the Qt loop. However, this is not recommended. Results may vary and even get you a segfault. +It is technically possible to e.g. use a ``glfw`` canvas with the Qt loop. However, this is not recommended because Qt gets confused in the precense of other windows and may hang or segfault. +But the other way around, running a Qt canvas in e.g. the trio loop, works fine: .. code-block:: py diff --git a/examples/cube_asyncio.py b/examples/cube_asyncio.py new file mode 100644 index 0000000..37bf087 --- /dev/null +++ b/examples/cube_asyncio.py @@ -0,0 +1,31 @@ +""" +Cube asyncio +------------ + +Run a wgpu example on the glfw backend, and the asyncio loop +""" + +import asyncio + +from rendercanvas.glfw import RenderCanvas +from rendercanvas.asyncio import loop +from rendercanvas.utils.cube import setup_drawing_sync + + +# The asyncio loop is the default, but this may change, so better be explicit. +RenderCanvas.select_loop(loop) + +canvas = RenderCanvas( + title="The wgpu cube on $backend with $loop", update_mode="continuous" +) +draw_frame = setup_drawing_sync(canvas) +canvas.request_draw(draw_frame) + + +if __name__ == "__main__": + + async def main(): + # ... add asyncio stuff here + await loop.run_async() + + asyncio.run(main()) diff --git a/examples/cube_auto.py b/examples/cube_auto.py index 7133ada..15ebe5c 100644 --- a/examples/cube_auto.py +++ b/examples/cube_auto.py @@ -7,11 +7,9 @@ # run_example = true -from rendercanvas.auto import RenderCanvas, run - +from rendercanvas.auto import RenderCanvas, loop from rendercanvas.utils.cube import setup_drawing_sync - canvas = RenderCanvas( title="The wgpu cube example on $backend", update_mode="continuous" ) @@ -20,4 +18,4 @@ if __name__ == "__main__": - run() + loop.run() diff --git a/examples/cube_glfw.py b/examples/cube_glfw.py index 040468b..d191bf6 100644 --- a/examples/cube_glfw.py +++ b/examples/cube_glfw.py @@ -6,7 +6,6 @@ """ from rendercanvas.glfw import RenderCanvas, loop - from rendercanvas.utils.cube import setup_drawing_sync diff --git a/examples/cube_qt_trio.py b/examples/cube_qt_trio.py new file mode 100644 index 0000000..638d30e --- /dev/null +++ b/examples/cube_qt_trio.py @@ -0,0 +1,27 @@ +""" +Cube qt canvas on the trio loop +------------------------------- + +Run a wgpu example on the Qt backend, but with the trio loop. + +Not sure why you'd want this, but it works! Note that the other way +around, e.g. runnning a glfw canvas with the Qt loop does not work so +well. +""" + +# ruff: noqa: E402 + +import trio +from rendercanvas.pyside6 import RenderCanvas +from rendercanvas.trio import loop +from rendercanvas.utils.cube import setup_drawing_sync + +RenderCanvas.select_loop(loop) + +canvas = RenderCanvas(title="The $backend with $loop", update_mode="continuous") +draw_frame = setup_drawing_sync(canvas) +canvas.request_draw(draw_frame) + + +if __name__ == "__main__": + trio.run(loop.run_async) diff --git a/examples/cube_trio.py b/examples/cube_trio.py index 70f011c..4ac486c 100644 --- a/examples/cube_trio.py +++ b/examples/cube_trio.py @@ -5,19 +5,32 @@ Run a wgpu example on the glfw backend, and the trio loop """ +import trio from rendercanvas.glfw import RenderCanvas from rendercanvas.trio import loop - - from rendercanvas.utils.cube import setup_drawing_sync +RenderCanvas.select_loop(loop) + canvas = RenderCanvas( - title="The wgpu cube example on $backend", update_mode="continuous" + title="The wgpu cube on $backend with $loop", update_mode="continuous" ) draw_frame = setup_drawing_sync(canvas) canvas.request_draw(draw_frame) if __name__ == "__main__": - loop.run() + # This works, but is not very trio-ish + # loop.run() + + # This looks more like it + # trio.run(loop.run_async) + + # But for the sake of completeness ... + + async def main(): + # ... add Trio stuff here + await loop.run_async() + + trio.run(main) diff --git a/examples/demo.py b/examples/demo.py index 0d47396..6e3ea85 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -14,7 +14,7 @@ import time -from rendercanvas.auto import RenderCanvas, loop +from rendercanvas.pyside6 import RenderCanvas, loop from rendercanvas.utils.cube import setup_drawing_sync diff --git a/examples/qt_app.py b/examples/qt_app.py index 08c1509..ac74883 100644 --- a/examples/qt_app.py +++ b/examples/qt_app.py @@ -3,8 +3,9 @@ ------ An example demonstrating a qt app with a wgpu viz inside. -If needed, change the PySide6 import to e.g. PyQt6, PyQt5, or PySide2. +Note how the ``rendercanvas.qt.loop`` object is not even imported; +you can simply run ``app.exec()`` the Qt way. """ # ruff: noqa: N802, E402 @@ -13,6 +14,9 @@ import importlib +# Normally you'd just write e.g. +# from PySide6 import QtWidgets + # For the sake of making this example Just Work, we try multiple QT libs for lib in ("PySide6", "PyQt6", "PySide2", "PyQt5"): try: diff --git a/rendercanvas/_async_adapter.py b/rendercanvas/_async_adapter.py index ccf6be1..0d23c12 100644 --- a/rendercanvas/_async_adapter.py +++ b/rendercanvas/_async_adapter.py @@ -60,7 +60,7 @@ class CancelledError(BaseException): class Task: - """Represetation of task, exectuting a co-routine.""" + """Representation of task, exectuting a co-routine.""" def __init__(self, loop, coro, name): self.loop = loop diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 2c7daf9..7cff002 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -62,7 +62,7 @@ def _register_canvas_group(self, canvas_group): if self.__state == 0: self.__state = 1 self._rc_init() - self.add_task(self._loop_task) + self.add_task(self._loop_task, name="loop-task") self.__canvas_groups.add(canvas_group) def _unregister_canvas_group(self, canvas_group): diff --git a/rendercanvas/base.py b/rendercanvas/base.py index fb35140..e60c226 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -38,7 +38,7 @@ def _register_canvas(self, canvas, task): self._canvases.add(canvas) loop = self.get_loop() loop._register_canvas_group(self) - loop.add_task(task) + loop.add_task(task, name="scheduler-task") def select_loop(self, loop): """Select the loop to use for this group of canvases.""" @@ -131,6 +131,9 @@ def __init__( "raw": "", "fps": "?", "backend": self.__class__.__name__, + "loop": self._rc_canvas_group.get_loop().__class__.__name__ + if self._rc_canvas_group + else "no-loop", } # Events and scheduler diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index ae9d985..b6417fd 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -169,9 +169,8 @@ class GlfwRenderCanvas(BaseRenderCanvas): _rc_canvas_group = GlfwCanvasGroup(loop) def __init__(self, *args, present_method=None, **kwargs): - super().__init__(*args, **kwargs) - enable_glfw() + super().__init__(*args, **kwargs) if present_method == "bitmap": logger.warning( @@ -344,6 +343,8 @@ def _rc_close(self): # But on some systems glfw needs a bit of time to properly close the window. if not self._rc_canvas_group.get_canvases(): poll_glfw_briefly(0.05) + # Could also terminate glfw, but we don't know if the application is using glfw in other places. + # terminate_glfw() def _rc_get_closed(self): return self._window is None diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 8f6dc35..b4c729e 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -319,6 +319,11 @@ def _rc_request_draw(self): # Ask Qt to do a paint event QtWidgets.QWidget.update(self) + # When running on another loop, schedule processing events asap + loop = self._rc_canvas_group.get_loop() + if not isinstance(loop, QtLoop): + loop.call_soon(self._rc_gui_poll) + def _rc_force_draw(self): # Call the paintEvent right now. # This works on all platforms I tested, except on MacOS when drawing with the 'image' method. From c800140fab1cd94af2ba7e3185947ce685c7781d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 6 Dec 2024 09:51:36 +0100 Subject: [PATCH 11/22] use async in example --- examples/demo.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/examples/demo.py b/examples/demo.py index 6e3ea85..fa44aa9 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -8,15 +8,17 @@ * Can be closed with Escape or by pressing the window close button. * In both cases, it should print "Close detected" exactly once. -* Hit space to spend 2 seconds doing direct draws. +* Hit "f" to spend 2 seconds doing direct draws. +* Hit "s" to async-sleep the scheduling loop for 2 seconds. Resizing + and closing the window still work. """ import time -from rendercanvas.pyside6 import RenderCanvas, loop +from rendercanvas.glfw import RenderCanvas, loop from rendercanvas.utils.cube import setup_drawing_sync - +from rendercanvas.utils.asyncs import sleep canvas = RenderCanvas( size=(640, 480), @@ -39,13 +41,19 @@ async def process_event(event): if event["event_type"] == "key_down": if event["key"] == "Escape": canvas.close() - elif event["key"] == " ": + elif event["key"] in " f": + # Force draw for 2 secs + print("force-drawing ...") etime = time.time() + 2 i = 0 while time.time() < etime: i += 1 canvas.force_draw() - print(f"force-drawed {i} frames in 2s.") + print(f"Drew {i} frames in 2s.") + elif event["key"] == "s": + print("Async sleep ... zzzz") + await sleep(2) + print("waking up") elif event["event_type"] == "close": # Should see this exactly once, either when pressing escape, or # when pressing the window close button. From 0a3bfeed906d10fb1042706c89f0ce59ef91f4a1 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 6 Dec 2024 10:03:21 +0100 Subject: [PATCH 12/22] fix deps --- pyproject.toml | 2 +- rendercanvas/__pyinstaller/hook-rendercanvas.py | 2 +- rendercanvas/__pyinstaller/test_rendercanvas.py | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7655c37..9578ab1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ keywords = [ "jupyter", ] requires-python = ">= 3.9" -dependencies = [] # no dependencies! +dependencies = ["sniffio"] [project.optional-dependencies] # For users jupyter = ["jupyter_rfb>=0.4.2"] diff --git a/rendercanvas/__pyinstaller/hook-rendercanvas.py b/rendercanvas/__pyinstaller/hook-rendercanvas.py index 04579aa..c18b7d4 100644 --- a/rendercanvas/__pyinstaller/hook-rendercanvas.py +++ b/rendercanvas/__pyinstaller/hook-rendercanvas.py @@ -8,7 +8,7 @@ binaries = [] # Add modules that are safe to add, i.e. don't pull in dependencies that we don't want. -hiddenimports += ["rendercanvas.offscreen"] +hiddenimports += ["asyncio", "rendercanvas.async", "rendercanvas.offscreen"] # Since glfw does not have a hook like this, it does not include the glfw binary # when freezing. We can solve this with the code below. Makes the binary a bit diff --git a/rendercanvas/__pyinstaller/test_rendercanvas.py b/rendercanvas/__pyinstaller/test_rendercanvas.py index 308c685..8746c11 100644 --- a/rendercanvas/__pyinstaller/test_rendercanvas.py +++ b/rendercanvas/__pyinstaller/test_rendercanvas.py @@ -14,10 +14,12 @@ "rendercanvas.glfw", "rendercanvas.offscreen", "glfw", + "asyncio" ] excluded_modules = [ "PySide6.QtGui", "PyQt6.QtGui", + "trio", ] for module_name in included_modules: importlib.import_module(module_name) From 15b1660715ca9c64e5e32d31aad557c0cca0dc44 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 6 Dec 2024 10:05:45 +0100 Subject: [PATCH 13/22] tests need trio --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9578ab1..6828a39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ glfw = ["glfw>=1.9"] lint = ["ruff", "pre-commit"] examples = ["numpy", "wgpu", "glfw", "pyside6", "imageio", "pytest"] docs = ["sphinx>7.2", "sphinx_rtd_theme", "sphinx-gallery", "numpy", "wgpu"] -tests = ["pytest", "numpy", "wgpu", "glfw"] +tests = ["pytest", "numpy", "wgpu", "glfw", "trio"] dev = ["rendercanvas[lint,tests,examples,docs]"] [project.entry-points."pyinstaller40"] From 6dbdc395b6e40c21b8ad0df61f0f5409a4736da1 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 6 Dec 2024 11:07:59 +0100 Subject: [PATCH 14/22] fix test --- rendercanvas/_loop.py | 4 +++- tests/test_loop.py | 30 ++++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 7cff002..fd69c04 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -119,7 +119,9 @@ async def _loop_task(self): # Stop when there are no more canvases break elif self.__should_stop >= 2: - # force a stop without waiting for the canvases to close + # Force a stop without waiting for the canvases to close. + # We could call event.close() for the remaining canvases, but technically they have not closed. + # Since this case is considered a failure, better be honest than consistent, I think. break elif self.__should_stop: # Close all remaining canvases. Loop will stop in a next iteration. diff --git a/tests/test_loop.py b/tests/test_loop.py index 50afd1f..fad39d7 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -24,10 +24,18 @@ class CanvasGroup(BaseCanvasGroup): pass +class FakeEventEmitter: + is_closed = False + + async def close(self): + self.is_closed = True + + class FakeCanvas: def __init__(self, refuse_close=False): self.refuse_close = refuse_close self.is_closed = False + self._events = FakeEventEmitter() def _rc_gui_poll(self): pass @@ -156,8 +164,11 @@ def test_run_loop_and_close_canvases(): print(et) assert 0.25 < et < 0.45 + assert canvas1._events.is_closed + assert canvas2._events.is_closed + -def test_run_loop_and_close_with_method(): +def test_run_loop_and_close_by_loop_stop(): # Close, then wait at most one tick to close canvases, and another to conform close. loop = AsyncioLoop() group = CanvasGroup(loop) @@ -177,6 +188,9 @@ def test_run_loop_and_close_with_method(): print(et) assert 0.25 < et < 0.55 + assert canvas1._events.is_closed + assert canvas2._events.is_closed + def test_run_loop_and_close_by_deletion(): # Make the canvases be deleted by the gc. @@ -185,6 +199,8 @@ def test_run_loop_and_close_by_deletion(): group = CanvasGroup(loop) canvases = [FakeCanvas() for _ in range(2)] + events1 = canvases[0]._events + events2 = canvases[1]._events for canvas in canvases: group._register_canvas(canvas, fake_task) del canvas @@ -199,6 +215,9 @@ def test_run_loop_and_close_by_deletion(): print(et) assert 0.25 < et < 0.55 + assert events1.is_closed + assert events2.is_closed + def test_run_loop_and_close_by_deletion_real(): # Stop by deleting canvases, with a real canvas. @@ -246,6 +265,9 @@ def interrupt_soon(): print(et) assert 0.25 < et < 0.55 + assert canvas1._events.is_closed + assert canvas2._events.is_closed + def test_run_loop_and_interrupt_harder(): # In the next tick after the second interupt, it stops the loop without closing the canvases @@ -277,9 +299,13 @@ def interrupt_soon(): print(et) assert 0.6 < et < 0.75 + # Now the close event is not send! + assert not canvas1._events.is_closed + assert not canvas2._events.is_closed + def test_loop_threaded(): - t = threading.Thread(target=test_run_loop_and_close_with_method) + t = threading.Thread(target=test_run_loop_and_close_by_loop_stop) t.start() t.join() From 78435841fd28b0f23d8fb6fc77343384d71a53a5 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 6 Dec 2024 11:59:47 +0100 Subject: [PATCH 15/22] Changes from self-review --- docs/backends.rst | 6 ++-- examples/qt_app.py | 2 +- examples/qt_app_asyncio.py | 4 +-- rendercanvas/_events.py | 2 +- rendercanvas/_loop.py | 6 ++-- rendercanvas/asyncio.py | 2 +- rendercanvas/base.py | 6 +++- rendercanvas/stub.py | 62 ++++++++++++++++++++------------------ rendercanvas/trio.py | 2 +- tests/test_meta.py | 3 ++ tests/test_offscreen.py | 2 +- 11 files changed, 53 insertions(+), 44 deletions(-) diff --git a/docs/backends.rst b/docs/backends.rst index bceae22..8af0186 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -6,7 +6,7 @@ The auto backend Generally the best approach for examples and small applications is to use the automatically selected backend. This ensures that the code is portable -across different machines and environments. Importinf from ``rendercanvas.auto`` selects a +across different machines and environments. Importing from ``rendercanvas.auto`` selects a suitable backend depending on the environment and more. See :ref:`interactive_use` for details. @@ -37,7 +37,7 @@ but you can replace ``from rendercanvas.auto`` with ``from rendercanvas.glfw`` t loop.run() -By default, the ``glfw`` backend uses an event loop based on asyncio. But you can also select e.g. trio: +By default, the ``glfw`` backend uses an event-loop based on asyncio. But you can also select e.g. trio: .. code-block:: py @@ -227,7 +227,7 @@ making it (about) impossible to tell that we cannot actually use ipywidgets. So it will try to use ``jupyter_rfb``, but cannot render anything. It's therefore advised to either use ``%gui qt`` or set the ``RENDERCANVAS_BACKEND`` env var to "glfw". The latter option works well, because these kernels *do* have a -running asyncio event loop! +running asyncio event-loop! On other environments that have a running ``asyncio`` loop, the glfw backend is preferred. E.g on ``ptpython --asyncio``. diff --git a/examples/qt_app.py b/examples/qt_app.py index ac74883..7711c02 100644 --- a/examples/qt_app.py +++ b/examples/qt_app.py @@ -69,5 +69,5 @@ def whenButtonClicked(self): draw_frame = setup_drawing_sync(example.canvas) example.canvas.request_draw(draw_frame) -# Enter Qt event loop (compatible with qt5/qt6) +# Enter Qt event-loop (compatible with qt5/qt6) app.exec() if hasattr(app, "exec") else app.exec_() diff --git a/examples/qt_app_asyncio.py b/examples/qt_app_asyncio.py index e30dc6f..bad9f88 100644 --- a/examples/qt_app_asyncio.py +++ b/examples/qt_app_asyncio.py @@ -27,7 +27,7 @@ def async_connect(signal, async_function): # Unfortunately, the signal.connect() methods don't detect # coroutine functions, so we have to wrap it in a function that creates - # a Future for the coroutine (which will then run in the current event loop). + # a Future for the coroutine (which will then run in the current event-loop). # # The docs on QtAsyncio do something like # @@ -85,5 +85,5 @@ async def whenButtonClicked(self): draw_frame = setup_drawing_sync(example.canvas) example.canvas.request_draw(draw_frame) -# Enter Qt event loop the asyncio-compatible way +# Enter Qt event-loop the asyncio-compatible way QtAsyncio.run() diff --git a/rendercanvas/_events.py b/rendercanvas/_events.py index d8ee73d..263798c 100644 --- a/rendercanvas/_events.py +++ b/rendercanvas/_events.py @@ -3,7 +3,7 @@ """ import time -from inspect import iscoroutinefunction # note: is not asyncio-specific +from inspect import iscoroutinefunction from collections import defaultdict, deque from ._coreutils import log_exception, BaseEnum diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index fd69c04..ad7949e 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -242,7 +242,7 @@ async def run_async(self): await self._rc_run_async() def stop(self): - """Close all windows and stop the currently running event loop. + """Close all windows and stop the currently running event-loop. If the loop is active but not running via our ``run()`` method, the loop moves back to its "off" state, but the underlying loop is not stopped. @@ -310,7 +310,7 @@ def _rc_run_async(self): def _rc_run(self): """Start running the event-loop. - * Start the event loop. + * Start the event-loop. * 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 @@ -322,7 +322,7 @@ def _rc_stop(self): """Clean up the loop, going to the off-state. * Cancel any remaining tasks. - * Stop the running event loop, if applicable. + * Stop the running event-loop, if applicable. * Be ready for another call to ``_rc_init()`` in case the loop is reused. * Return None. """ diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py index 5bb4762..fbbe786 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -1,5 +1,5 @@ """ -Implements an asyncio event loop for backends that don't have an event loop by themselves, like glfw. +Implements an asyncio event-loop for backends that don't have an event-loop by themselves, like glfw. Also supports a asyncio-friendly way to run or wait for the loop using ``run_async()``. """ diff --git a/rendercanvas/base.py b/rendercanvas/base.py index e60c226..f0e6c0c 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -457,7 +457,11 @@ def set_logical_size(self, width, height): self._rc_set_logical_size(width, height) def set_title(self, title): - """Set the window title.""" + """Set the window title. + + The words "$backend", "$loop", and "$fps" can be used as variables that + are filled in with the corresponding values. + """ self.__title_info["raw"] = title for k, v in self.__title_info.items(): title = title.replace("$" + k, v) diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index 789f776..3d2271b 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -7,10 +7,42 @@ from .base import BaseCanvasGroup, WrapperRenderCanvas, BaseRenderCanvas, BaseLoop +class StubLoop(BaseLoop): + """ + The ``Loop`` represents the event-loop that drives the rendering and events. + + Some backends will provide a corresponding loop (like qt and ws). Other backends may use + existing loops (like glfw and jupyter). And then there are loop-backends that only implement + a loop (e.g. asyncio or trio). + + Backends must subclass ``BaseLoop`` and implement a set of methods prefixed with ``_rc_``. + """ + + def _rc_init(self): + raise NotImplementedError() + + def _rc_run(self): + raise NotImplementedError() + + async def _rc_run_async(self): + raise NotImplementedError() + + def _rc_stop(self): + raise NotImplementedError() + + def _rc_add_task(self, async_func, name): + raise NotImplementedError() + + def _rc_call_later(self, delay, callback): + raise NotImplementedError() + + class StubCanvasGroup(BaseCanvasGroup): """ The ``CanvasGroup`` representss a group of canvas objects from the same class, that share a loop. + The initial/default loop is passed when the ``CanvasGroup`` is instantiated. + Backends can subclass ``BaseCanvasGroup`` and set an instance at their ``RenderCanvas._rc_canvas_group``. It can also be omitted for canvases that don't need to run in a loop. Note that this class is only for internal use, mainly to connect canvases to a loop; it is not public API. @@ -95,36 +127,6 @@ def __init__(self, parent=None, **kwargs): self._subwidget = StubRenderCanvas(self, **kwargs) -class StubLoop(BaseLoop): - """ - The ``Loop`` represents the event loop that drives the rendering and events. - - Some backends will provide a corresponding loop (like qt and ws). Other backends may use - existing loops (like glfw and jupyter). And then there are loop-backends that only implement - a loop (e.g. asyncio or trio). - - Backends must subclass ``BaseLoop`` and implement a set of methods prefixed with ``_rc__``. - """ - - def _rc_init(self): - raise NotImplementedError() - - def _rc_run(self): - raise NotImplementedError() - - async def _rc_run_async(self): - raise NotImplementedError() - - def _rc_stop(self): - raise NotImplementedError() - - def _rc_add_task(self, async_func, name): - raise NotImplementedError() - - def _rc_call_later(self, delay, callback): - raise NotImplementedError() - - # Make available under a common name RenderCanvas = StubRenderCanvas loop = StubLoop() diff --git a/rendercanvas/trio.py b/rendercanvas/trio.py index 7443753..0f75043 100644 --- a/rendercanvas/trio.py +++ b/rendercanvas/trio.py @@ -1,5 +1,5 @@ """ -Implements a trio event loop for backends that don't have an event loop by themselves, like glfw. +Implements a trio event-loop for backends that don't have an event-loop by themselves, like glfw. Also supports a trio-friendly way to run or wait for the loop using ``run_async()``. """ diff --git a/tests/test_meta.py b/tests/test_meta.py index 20418a0..a8072b1 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -78,6 +78,9 @@ def test_deps_plain_import(): def test_deps_asyncio(): + if sys.version_info < (3, 10): + return # skip because stdlib_module_names is not available + # I like it that asyncio is only imported when actually being used. # Since its the default loop for some backends, it must lazy-import. # We can do this safely because asyncio is std. diff --git a/tests/test_offscreen.py b/tests/test_offscreen.py index 9849df5..432c56d 100644 --- a/tests/test_offscreen.py +++ b/tests/test_offscreen.py @@ -68,7 +68,7 @@ def test_offscreen_selection_using_legacyt_env_var(): def test_offscreen_event_loop(): - """Check that the event loop handles queued tasks and then returns.""" + """Check that the event-loop handles queued tasks and then returns.""" # Note: if this test fails, it may run forever, so it's a good idea to have a timeout on the CI job or something from rendercanvas.offscreen import loop From 46eee9d91c2cf82350e08bb6a6a8624f8b0fc28a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 6 Dec 2024 12:09:39 +0100 Subject: [PATCH 16/22] make async adapter rendercanvas-agnostic --- examples/demo.py | 2 +- rendercanvas/_loop.py | 4 ++-- .../{_async_adapter.py => utils/asyncadapter.py} | 11 ++++++----- 3 files changed, 9 insertions(+), 8 deletions(-) rename rendercanvas/{_async_adapter.py => utils/asyncadapter.py} (94%) diff --git a/examples/demo.py b/examples/demo.py index fa44aa9..07fe305 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -16,7 +16,7 @@ import time -from rendercanvas.glfw import RenderCanvas, loop +from rendercanvas.auto import RenderCanvas, loop from rendercanvas.utils.cube import setup_drawing_sync from rendercanvas.utils.asyncs import sleep diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index ad7949e..54b4f75 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -7,7 +7,7 @@ from ._coreutils import logger, log_exception from .utils.asyncs import sleep -from ._async_adapter import Task as AsyncAdapterTask +from .utils import asyncadapter HANDLED_SIGNALS = ( @@ -338,7 +338,7 @@ def _rc_add_task(self, async_func, name): * The subclass is responsible for cancelling remaining tasks in _rc_stop. * Return None. """ - task = AsyncAdapterTask(self, async_func(), name) + task = asyncadapter.Task(self._rc_call_later, async_func(), name) self.__tasks.add(task) task.add_done_callback(self.__tasks.discard) diff --git a/rendercanvas/_async_adapter.py b/rendercanvas/utils/asyncadapter.py similarity index 94% rename from rendercanvas/_async_adapter.py rename to rendercanvas/utils/asyncadapter.py index 0d23c12..80461b3 100644 --- a/rendercanvas/_async_adapter.py +++ b/rendercanvas/utils/asyncadapter.py @@ -7,7 +7,7 @@ from sniffio import thread_local as sniffio_thread_local -logger = logging.getLogger("rendercanvas") +logger = logging.getLogger("asyncadapter") class Sleeper: @@ -62,12 +62,12 @@ class CancelledError(BaseException): class Task: """Representation of task, exectuting a co-routine.""" - def __init__(self, loop, coro, name): - self.loop = loop + def __init__(self, call_later_func, coro, name): + self._call_later = call_later_func + self._done_callbacks = [] self.coro = coro self.name = name self.cancelled = False - self._done_callbacks = [] self.call_step_later(0) def add_done_callback(self, callback): @@ -81,9 +81,10 @@ def _close(self): callback(self) except Exception: pass + self._done_callbacks.clear() def call_step_later(self, delay): - self.loop._rc_call_later(delay, self.step) + self._call_later(delay, self.step) def cancel(self): self.cancelled = True From 664ba5b0037533da71fe9a1426fd44363b12f921 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 6 Dec 2024 12:34:21 +0100 Subject: [PATCH 17/22] Changed that CanvasGroup's loop can be set to None, instead of setting _rc_canvas_group to None --- docs/backendapi.rst | 12 ++++++------ rendercanvas/base.py | 28 ++++++++++++++++++---------- rendercanvas/offscreen.py | 8 ++++++-- rendercanvas/stub.py | 5 ++++- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/docs/backendapi.rst b/docs/backendapi.rst index 1067a62..0617176 100644 --- a/docs/backendapi.rst +++ b/docs/backendapi.rst @@ -11,22 +11,22 @@ Also see https://github.com/pygfx/rendercanvas/blob/main/rendercanvas/stub.py. with each version without warning. -.. autoclass:: rendercanvas.stub.StubCanvasGroup +.. autoclass:: rendercanvas.stub.StubLoop :members: :private-members: :member-order: bysource -.. autoclass:: rendercanvas.stub.StubRenderCanvas +.. autoclass:: rendercanvas.stub.StubCanvasGroup :members: :private-members: :member-order: bysource -.. autoclass:: rendercanvas.base.WrapperRenderCanvas - - -.. autoclass:: rendercanvas.stub.StubLoop +.. autoclass:: rendercanvas.stub.StubRenderCanvas :members: :private-members: :member-order: bysource + + +.. autoclass:: rendercanvas.base.WrapperRenderCanvas diff --git a/rendercanvas/base.py b/rendercanvas/base.py index f0e6c0c..fac5b4d 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -30,30 +30,32 @@ class BaseCanvasGroup: def __init__(self, default_loop): self._canvases = weakref.WeakSet() - self._loop = default_loop + self._loop = None self.select_loop(default_loop) def _register_canvas(self, canvas, task): """Used by the canvas to register itself.""" self._canvases.add(canvas) loop = self.get_loop() - loop._register_canvas_group(self) - loop.add_task(task, name="scheduler-task") + if loop is not None: + loop._register_canvas_group(self) + loop.add_task(task, name="scheduler-task") def select_loop(self, loop): """Select the loop to use for this group of canvases.""" - if not isinstance(loop, BaseLoop): - raise TypeError("select_loop() requires a loop instance.") + if not (loop is None or isinstance(loop, BaseLoop)): + raise TypeError("select_loop() requires a loop instance or None.") elif len(self._canvases): raise RuntimeError("Cannot select_loop() when live canvases exist.") elif loop is self._loop: pass else: - self._loop._unregister_canvas_group(self) + if self._loop is not None: + self._loop._unregister_canvas_group(self) self._loop = loop def get_loop(self): - """Get the currently associated loop.""" + """Get the currently associated loop (can be None for canvases that don't run a scheduler).""" return self._loop def get_canvases(self): @@ -88,7 +90,6 @@ class BaseRenderCanvas: _rc_canvas_group = None """Class attribute that refers to the ``CanvasGroup`` instance to use for canvases of this class. It specifies what loop is used, and enables users to changing the used loop. - Set to None to not use a loop. """ @classmethod @@ -132,14 +133,19 @@ def __init__( "fps": "?", "backend": self.__class__.__name__, "loop": self._rc_canvas_group.get_loop().__class__.__name__ - if self._rc_canvas_group + if (self._rc_canvas_group and self._rc_canvas_group.get_loop()) else "no-loop", } # Events and scheduler self._events = EventEmitter() self.__scheduler = None - if self._rc_canvas_group is not None: + if self._rc_canvas_group is None: + pass # No scheduling, not even grouping + elif self._rc_canvas_group.get_loop() is None: + # Group, but no loop: no scheduling + self._rc_canvas_group._register_canvas(self, None) + else: self.__scheduler = Scheduler( self, self._events, @@ -583,6 +589,8 @@ class WrapperRenderCanvas(BaseRenderCanvas): wrapped canvas and set it as ``_subwidget``. """ + _rc_canvas_group = None # No grouping for these wrappers + @classmethod def select_loop(cls, loop): m = sys.modules[cls.__module__] diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 29880f6..4327188 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -6,7 +6,11 @@ import time -from .base import BaseRenderCanvas, BaseLoop +from .base import BaseCanvasGroup, BaseRenderCanvas, BaseLoop + + +class OffscreenCanvasGroup(BaseCanvasGroup): + pass class ManualOffscreenRenderCanvas(BaseRenderCanvas): @@ -15,7 +19,7 @@ class ManualOffscreenRenderCanvas(BaseRenderCanvas): Call the ``.draw()`` method to perform a draw and get the result. """ - _rc_canvas_group = None # unmanaged, no loop + _rc_canvas_group = OffscreenCanvasGroup(None) # no loop, no scheduling def __init__(self, *args, pixel_ratio=1.0, **kwargs): super().__init__(*args, **kwargs) diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index 3d2271b..f96974c 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -37,6 +37,9 @@ def _rc_call_later(self, delay, callback): raise NotImplementedError() +loop = StubLoop() + + class StubCanvasGroup(BaseCanvasGroup): """ The ``CanvasGroup`` representss a group of canvas objects from the same class, that share a loop. @@ -75,7 +78,7 @@ def _draw_frame_and_present(self): # Must be implemented by subclasses. - _rc_canvas_group = None + _rc_canvas_group = StubCanvasGroup(loop) def _rc_gui_poll(self): raise NotImplementedError() From 759c931fb3b751c71a79472aa7fb831a93556df7 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 6 Dec 2024 12:38:49 +0100 Subject: [PATCH 18/22] fix test --- tests/test_meta.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_meta.py b/tests/test_meta.py index a8072b1..78784b7 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -7,6 +7,7 @@ from testutils import run_tests import rendercanvas +import pytest CODE = """ @@ -72,15 +73,14 @@ def test_namespace(): assert "Scheduler" not in dir(rendercanvas) +@pytest.mark.skipif(sys.version_info < (3, 10), reason="Need py310+") def test_deps_plain_import(): modules = get_loaded_modules("rendercanvas", 1) assert modules == {"rendercanvas", "sniffio"} +@pytest.mark.skipif(sys.version_info < (3, 10), reason="Need py310+") def test_deps_asyncio(): - if sys.version_info < (3, 10): - return # skip because stdlib_module_names is not available - # I like it that asyncio is only imported when actually being used. # Since its the default loop for some backends, it must lazy-import. # We can do this safely because asyncio is std. @@ -92,6 +92,7 @@ def test_deps_asyncio(): assert "asyncio" in modules +@pytest.mark.skipif(sys.version_info < (3, 10), reason="Need py310+") def test_deps_trio(): # For trio, I like that if the trio module is loaded, trio is imported, fail early. modules = get_loaded_modules("rendercanvas.trio", 1) From e3c965031daff23236ca04b2b19cc0198fa5d0ae Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 6 Dec 2024 13:01:39 +0100 Subject: [PATCH 19/22] fix glfw shutdown --- rendercanvas/glfw.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index b6417fd..09631da 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -335,7 +335,8 @@ def _rc_close(self): if not glfw._rc_alive: # May not always be able to close the proper way on system exit self._window = None - elif self._window is not None: + return + 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"}) From 1401a3503f5aebd36c5eeea26448cb1f5d06b785 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 6 Dec 2024 13:36:45 +0100 Subject: [PATCH 20/22] fix wx --- examples/cube_wx.py | 4 ++-- rendercanvas/_loop.py | 26 +++++++++++++++++--------- rendercanvas/asyncio.py | 3 +++ rendercanvas/qt.py | 4 +++- rendercanvas/wx.py | 33 ++++++++++++++++++++++----------- 5 files changed, 47 insertions(+), 23 deletions(-) diff --git a/examples/cube_wx.py b/examples/cube_wx.py index b07784f..bd08daf 100644 --- a/examples/cube_wx.py +++ b/examples/cube_wx.py @@ -5,7 +5,7 @@ Run a wgpu example on the wx backend. """ -from rendercanvas.wx import RenderCanvas, run +from rendercanvas.wx import RenderCanvas, loop from rendercanvas.utils.cube import setup_drawing_sync @@ -18,4 +18,4 @@ if __name__ == "__main__": - run() + loop.run() diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 54b4f75..da47cf1 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -28,7 +28,8 @@ class BaseLoop: * off (0): the initial state, the subclass should probably not even import dependencies yet. * ready (1): the first canvas is created, ``_rc_init()`` is called to get the loop ready for running. * active (2): the loop is active, but not running via our entrypoints. - * running (3): the loop is running via ``_rc_run()`` or ``_rc_run_async()``. + * active (3): the loop is inter-active in e.g. an IDE. + * running (4): the loop is running via ``_rc_run()`` or ``_rc_run_async()``. Notes: @@ -44,17 +45,21 @@ class BaseLoop: def __init__(self): self.__tasks = set() self.__canvas_groups = set() - self.__state = 0 # 0: idle, 1: ready, 2: active, 3: running via our entrypoint self.__should_stop = 0 + self.__state = ( + 0 # 0: off, 1: ready, 2: detected-active, 3: inter-active, 4: running + ) def __repr__(self): - state = ["off", "ready", "active", "running"][self.__state] - return f"<{self.__class__.__module__}.{self.__class__.__name__} '{state}' at {hex(id(self))}>" + full_class_name = f"{self.__class__.__module__}.{self.__class__.__name__}" + state = self.__state + state_str = ["off", "ready", "active", "active", "running"][state] + return f"<{full_class_name} '{state_str}' ({state}) at {hex(id(self))}>" def _mark_as_interactive(self): """For subclasses to set active from ``_rc_init()``""" - if self.__state == 1: - self.__state = 2 + if self.__state in (1, 2): + self.__state = 3 def _register_canvas_group(self, canvas_group): # A CanvasGroup will call this every time that a new canvas is created for this loop. @@ -201,7 +206,10 @@ def run(self): # Yes we can pass elif self.__state == 2: - # No, already active (interactive mode) + # We look active, but have not been marked interactive + pass + elif self.__state == 3: + # No, already marked active (interactive mode) return else: # No, what are you doing?? @@ -245,7 +253,7 @@ def stop(self): """Close all windows and stop the currently running event-loop. If the loop is active but not running via our ``run()`` method, the loop - moves back to its "off" state, but the underlying loop is not stopped. + moves back to its off-state, but the underlying loop is not stopped. """ # Only take action when we're inside the run() method self.__should_stop += 1 @@ -254,7 +262,7 @@ def stop(self): self._stop() def _stop(self): - """Move to our off state.""" + """Move to the off-state.""" # If we used the async adapter, cancel any tasks while self.__tasks: task = self.__tasks.pop() diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py index fbbe786..1b0774a 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -34,6 +34,9 @@ def _rc_init(self): def _rc_run(self): import asyncio + if self._interactive_loop is not None: + return + asyncio.run(self._rc_run_async()) async def _rc_run_async(self): diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index b4c729e..c31fa19 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -166,6 +166,8 @@ def _rc_init(self): app = QtWidgets.QApplication.instance() if app is None: self._app = QtWidgets.QApplication([]) + if already_had_app_on_import: + self._mark_as_interactive() def _rc_run(self): # Note: we could detect if asyncio is running (interactive session) and wheter @@ -178,7 +180,7 @@ def _rc_run(self): # or not. if already_had_app_on_import: - return # Likely in an interactive session or larger application that will start the Qt app. + return self._we_run_the_loop = True try: diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 7840550..3be6e37 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -144,10 +144,10 @@ class WxLoop(BaseLoop): def _rc_init(self): if self._app is None: - app = wx.App.GetInstance() - if app is None: + self._app = wx.App.GetInstance() + if self._app is None: self._app = wx.App() - wx.App.SetInstance(app) + wx.App.SetInstance(self._app) def _rc_run(self): self._app.MainLoop() @@ -166,7 +166,15 @@ def _rc_add_task(self, async_func, name): return super()._rc_add_task(async_func, name) def _rc_call_later(self, delay, callback): - raise NotImplementedError() # todo: wx.CallSoon(callback, args) + wx.CallLater(int(delay * 1000), callback) + + def process_wx_events(self): + old_loop = wx.GUIEventLoop.GetActive() + event_loop = wx.GUIEventLoop() + wx.EventLoop.SetActive(event_loop) + while event_loop.Pending(): + event_loop.Dispatch() + wx.EventLoop.SetActive(old_loop) loop = WxLoop() @@ -257,13 +265,7 @@ def _rc_gui_poll(self): if isinstance(self._rc_canvas_group.get_loop(), WxLoop): pass # all is well else: - old_loop = wx.EventLoop.GetActive() - event_loop = wx.EventLoop() - wx.EventLoop.SetActive(event_loop) - while event_loop.Pending(): - event_loop.Dispatch() - self.app.ProcessIdle() - wx.EventLoop.SetActive(old_loop) + loop.process_wx_events() def _rc_get_present_methods(self): if self._surface_ids is None: @@ -510,6 +512,15 @@ def Destroy(self): # noqa: N802 - this is a wx method self._subwidget._is_closed = True super().Destroy() + # wx stops running its loop as soon as the last canvas closes. + # So when that happens, we manually run the loop for a short while + # so that we can clean up properly + if not self._subwidget._rc_canvas_group.get_canvases(): + etime = time.perf_counter() + 0.15 + while time.perf_counter() < etime: + time.sleep(0.01) + loop.process_wx_events() + # Make available under a name that is the same for all gui backends RenderWidget = WxRenderWidget From c772f5909bab0e56b81ef749e7a88a8347c90a96 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 6 Dec 2024 14:40:09 +0100 Subject: [PATCH 21/22] fix loop re-use --- rendercanvas/_loop.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index da47cf1..e981c5e 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -270,6 +270,7 @@ def _stop(self): task.cancel() # Turn off self.__state = 0 + self.__should_stop = 0 self._rc_stop() def __setup_interrupt(self): From 100e1ec6a6db3e066b815842ca959df4c1039d94 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 6 Dec 2024 14:40:38 +0100 Subject: [PATCH 22/22] add tests for async handlers --- tests/test_loop.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/test_loop.py b/tests/test_loop.py index fad39d7..0cd6b0b 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -10,6 +10,7 @@ from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas from rendercanvas.asyncio import AsyncioLoop from rendercanvas.trio import TrioLoop +from rendercanvas.utils.asyncs import sleep as async_sleep from testutils import run_tests import trio @@ -65,6 +66,10 @@ def _rc_close(self): def _rc_get_closed(self): return self._is_closed + def _rc_request_draw(self): + loop = self._rc_canvas_group.get_loop() + loop.call_soon(self._draw_frame_and_present) + def test_run_loop_and_close_bc_no_canvases(): # Run the loop without canvas; closes immediately @@ -192,6 +197,61 @@ def test_run_loop_and_close_by_loop_stop(): assert canvas2._events.is_closed +def test_run_loop_and_close_by_loop_stop_via_async(): + # Close using a coro + loop = AsyncioLoop() + group = CanvasGroup(loop) + + canvas1 = FakeCanvas() + canvas2 = FakeCanvas() + group._register_canvas(canvas1, fake_task) + group._register_canvas(canvas2, fake_task) + + async def stopper(): + await async_sleep(0.3) + loop.stop() + + loop.add_task(stopper) + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.25 < et < 0.55 + + assert canvas1._events.is_closed + assert canvas2._events.is_closed + + +def test_run_loop_and_close_via_async_event(): + # Stop via an async event, and an async task + loop = real_loop + + canvas1 = RealRenderCanvas() + canvas2 = RealRenderCanvas() # noqa + + @canvas1.add_event_handler("key_down") + async def on_key_down(event): + print(event) + if event["key"] == "q": + await async_sleep(0.2) + loop.stop() + + async def stopper(): + await async_sleep(0.2) + canvas1._events.submit({"event_type": "key_down", "key": "q"}) + + loop.add_task(stopper) + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.35 < et < 0.65 + + def test_run_loop_and_close_by_deletion(): # Make the canvases be deleted by the gc.