From 16e5079fbdcd606b6cf7727c9a3fa33a13512b7e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 9 Dec 2024 13:08:18 +0100 Subject: [PATCH] Refactor to go async (#41) --- docs/api.rst | 9 +- docs/backendapi.rst | 17 +- docs/backends.rst | 42 +- docs/start.rst | 11 +- examples/cube_asyncio.py | 31 + examples/cube_auto.py | 6 +- examples/cube_glfw.py | 7 +- examples/cube_qt_trio.py | 27 + examples/cube_trio.py | 36 + examples/cube_wx.py | 4 +- examples/demo.py | 21 +- examples/qt_app.py | 8 +- examples/qt_app_asyncio.py | 4 +- pyproject.toml | 4 +- rendercanvas/__init__.py | 5 +- .../__pyinstaller/hook-rendercanvas.py | 2 +- .../__pyinstaller/test_rendercanvas.py | 2 + rendercanvas/_events.py | 42 +- rendercanvas/_loop.py | 682 +++++++----------- rendercanvas/_scheduler.py | 198 +++++ rendercanvas/asyncio.py | 126 ++-- rendercanvas/base.py | 130 +++- rendercanvas/glfw.py | 87 ++- rendercanvas/jupyter.py | 49 +- rendercanvas/offscreen.py | 64 +- rendercanvas/qt.py | 152 ++-- rendercanvas/stub.py | 98 +-- rendercanvas/trio.py | 50 ++ rendercanvas/utils/asyncadapter.py | 139 ++++ rendercanvas/utils/asyncs.py | 23 + rendercanvas/wx.py | 144 ++-- tests/test_backends.py | 117 +-- tests/test_base.py | 11 +- tests/test_events.py | 88 ++- tests/test_glfw.py | 31 +- tests/test_loop.py | 310 +++++++- tests/test_meta.py | 103 +++ tests/test_offscreen.py | 26 +- tests/test_scheduling.py | 55 +- 39 files changed, 1906 insertions(+), 1055 deletions(-) create mode 100644 examples/cube_asyncio.py create mode 100644 examples/cube_qt_trio.py create mode 100644 examples/cube_trio.py create mode 100644 rendercanvas/_scheduler.py create mode 100644 rendercanvas/trio.py create mode 100644 rendercanvas/utils/asyncadapter.py create mode 100644 rendercanvas/utils/asyncs.py create mode 100644 tests/test_meta.py 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/docs/backendapi.rst b/docs/backendapi.rst index 3c31945..0617176 100644 --- a/docs/backendapi.rst +++ b/docs/backendapi.rst @@ -5,23 +5,28 @@ 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.StubLoop :members: :private-members: :member-order: bysource -.. autoclass:: rendercanvas.stub.StubTimer +.. autoclass:: rendercanvas.stub.StubCanvasGroup :members: :private-members: :member-order: bysource -.. autoclass:: rendercanvas.stub.StubLoop + +.. autoclass:: rendercanvas.stub.StubRenderCanvas :members: :private-members: :member-order: bysource + + +.. autoclass:: rendercanvas.base.WrapperRenderCanvas diff --git a/docs/backends.rst b/docs/backends.rst index ebdf4c7..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. Using ``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,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 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 + + 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 --------------------- @@ -191,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/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/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 878602e..d191bf6 100644 --- a/examples/cube_glfw.py +++ b/examples/cube_glfw.py @@ -2,11 +2,10 @@ 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, run - +from rendercanvas.glfw import RenderCanvas, loop from rendercanvas.utils.cube import setup_drawing_sync @@ -18,4 +17,4 @@ if __name__ == "__main__": - run() + loop.run() 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 new file mode 100644 index 0000000..4ac486c --- /dev/null +++ b/examples/cube_trio.py @@ -0,0 +1,36 @@ +""" +Cube trio +--------- + +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 on $backend with $loop", update_mode="continuous" +) +draw_frame = setup_drawing_sync(canvas) +canvas.request_draw(draw_frame) + + +if __name__ == "__main__": + # 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/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/examples/demo.py b/examples/demo.py index 436eee5..07fe305 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -8,20 +8,21 @@ * 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.auto import RenderCanvas, loop - from rendercanvas.utils.cube import setup_drawing_sync - +from rendercanvas.utils.asyncs import sleep 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,20 +34,26 @@ @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) 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. diff --git a/examples/qt_app.py b/examples/qt_app.py index 08c1509..7711c02 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: @@ -65,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/pyproject.toml b/pyproject.toml index 7655c37..6828a39 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"] @@ -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"] 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/__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) diff --git a/rendercanvas/_events.py b/rendercanvas/_events.py index a60a304..263798c 100644 --- a/rendercanvas/_events.py +++ b/rendercanvas/_events.py @@ -3,6 +3,7 @@ """ import time +from inspect import iscoroutinefunction from collections import defaultdict, deque from ._coreutils import log_exception, BaseEnum @@ -65,11 +66,17 @@ def __init__(self): self._event_handlers = defaultdict(list) self._closed = False + 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. 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. @@ -80,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 @@ -131,6 +143,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)) @@ -155,11 +169,11 @@ 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}'") - 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) @@ -182,7 +196,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 +206,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 +222,21 @@ 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) + # Close? + if event_type == "close": + self._set_closed() + + async def close(self): + """Close the event handler. - def _rc_close(self): - """Wrap up when the scheduler detects the canvas is closed/dead.""" + 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() self.submit({"event_type": "close"}) - self.flush() + await self.flush() diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 04e6acd..e981c5e 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -1,16 +1,13 @@ """ -The loop mechanics: the base timer, base loop, and scheduler. +The base loop implementation. """ -import time import signal -import weakref +from inspect import iscoroutinefunction -from ._coreutils import logger, log_exception, BaseEnum - - -# Note: technically, we could have a global loop proxy object that defers to any of the other loops. -# That would e.g. allow using glfw with qt together. Probably a too weird use-case for the added complexity. +from ._coreutils import logger, log_exception +from .utils.asyncs import sleep +from .utils import asyncadapter HANDLED_SIGNALS = ( @@ -19,197 +16,142 @@ ) -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. +class BaseLoop: + """The base class for an event-loop object. - * 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() + 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). - def _rc_stop(self): - """Stop the timer. + The lifecycle states of a loop are: - * If the timer is running, cancel the pending call to ``self._tick()``. - * Otherwise, this should do nothing. - """ - raise NotImplementedError() + * 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. + * 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: -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 backends 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._schedulers = [] - self._is_inside_run = False - self._should_stop = 0 + self.__tasks = set() + self.__canvas_groups = set() + self.__should_stop = 0 + self.__state = ( + 0 # 0: off, 1: ready, 2: detected-active, 3: inter-active, 4: running + ) + + def __repr__(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 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. + # 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, name="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) - # The loop object runs a lightweight timer for a few reasons: - # * Support running the loop without windows (e.g. to keep animations going). + def get_canvases(self): + """Get a list of currently active (not-closed) canvases.""" + canvases = [] + for canvas_group in self.__canvas_groups: + canvases += canvas_group.get_canvases() + return canvases + + 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). - self._gui_timer = self._TimerClass(self, self._tick, one_shot=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 + # Detect active loop + self.__state = max(self.__state, 2) - def get_canvases(self): - """Get a list of currently active (not-closed) canvases.""" - canvases = [] - schedulers = [] + # Keep track of event emitter objects + event_emitters = {id(c): c._events for c in self.get_canvases()} - for scheduler in self._schedulers: - canvas = scheduler.get_canvas() - if canvas is not None: - canvases.append(canvas) - schedulers.append(scheduler) + 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 - # Forget schedulers that no longer have a live canvas - self._schedulers = schedulers + canvas_count = len(canvases) + del canvases - return canvases + # Should we stop? - def _tick(self): - # Keep the GUI alive on every tick - self._rc_gui_poll() + if canvas_count == 0: + # Stop when there are no more canvases + break + elif self.__should_stop >= 2: + # 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. + 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 - # Clean internal schedulers list - self.get_canvases() + finally: + self._stop() - # 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 + def add_task(self, async_func, *args, name="unnamed"): + """Run an async function in the event-loop. - # 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 + All tasks are stopped when the loop stops. + """ + if not (callable(async_func) and iscoroutinefunction(async_func)): + raise TypeError("add_task() 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,84 +159,123 @@ 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.") - def call_later(self, delay, callback, *args): - """Arrange for a callback to be called after the given delay (in seconds). + async def wrapper(): + with log_exception("Callback error:"): + callback(*args) - 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. + self._rc_add_task(wrapper, "call_soon") - 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 + 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) - def call_repeated(self, interval, callback, *args): - """Arrange for a callback to be called repeatedly. + 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. - """ - timer = self._TimerClass(self, callback, *args, one_shot=False) - timer.start() - return timer + self._rc_add_task(wrapper, "call_later") 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). + """ - # Make sure that the internal timer is running, even if no canvases. - self._gui_timer.start(0.1) + # 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: + # 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?? + 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 supported by the asyncio and trio loops. + """ + + # 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. + """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._rc_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): + """Move to the 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.__should_stop = 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() @@ -316,10 +297,29 @@ 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. - * 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 @@ -328,223 +328,37 @@ 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. - * This will only be called when the process is inside _rc_run(). - I.e. not for interactive mode. + * Cancel any remaining tasks. + * 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_call_soon(self, callback, *args): - """Method to call a callback in the next iteraction of the event-loop. + def _rc_add_task(self, async_func, name): + """Add an async task to the running 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. - """ - self.call_later(0, callback, *args) - - def _rc_gui_poll(self): - """Process GUI events. + This method is optional. A subclass must either implement ``_rc_add_task`` or ``_rc_call_later``. - 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. + * Schedule running the task defined by the given co-routine function. + * The name is for debugging purposes only. + * The subclass is responsible for cancelling remaining tasks in _rc_stop. + * Return None. """ - 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 - - # 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) - - 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 - - def _schedule_next_tick(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) + task = asyncadapter.Task(self._rc_call_later, async_func(), name) + self.__tasks.add(task) + task.add_done_callback(self.__tasks.discard) - def _tick(self): - """Process event and schedule a new draw or tick.""" + def _rc_call_later(self, delay, callback): + """Method to call a callback in delay number of seconds. - self._last_tick_time = time.perf_counter() + This method is optional. A subclass must either implement ``_rc_add_task`` or ``_rc_call_later``. - # 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() - else: - self._schedule_next_tick() - - elif self._mode == "manual": - # manual: never draw, except when ... ? - self._schedule_next_tick() - - else: - raise RuntimeError(f"Unexpected scheduling mode: '{self._mode}'") - - 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() - - # 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 + * 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 new file mode 100644 index 0000000..aa4b230 --- /dev/null +++ b/rendercanvas/_scheduler.py @@ -0,0 +1,198 @@ +""" +The scheduler class/loop. +""" + +import time +import weakref + +from ._coreutils import BaseEnum +from .utils.asyncs import sleep, Event + + +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, 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. + # + # We have to make sure that there is only one invocation of the scheduler task, + # or we'd get double the intended FPS. + # + # 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, + # 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 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. + # + # 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, *, mode="ondemand", min_fps=1, max_fps=30): + self.name = f"{canvas.__class__.__name__} scheduler" + + # 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._async_draw_event = None + + # Keep track of fps + self._draw_stats = 0, time.perf_counter() + + 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.""" + 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.""" + + last_draw_time = 0 + last_tick_time = 0 + + # Little startup sleep + await sleep(0.05) + + 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() - last_tick_time + delay -= time_since_tick_start + delay = max(0, delay) + + # Wait. Even if delay is zero, it gives control back to the loop, + # allowing other tasks to do work. + await sleep(delay) + + # 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 ... + + 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() - 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 + + 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() + + # 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 + 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 index 2649f97..1b0774a 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -1,71 +1,95 @@ """ -Implements an asyncio event loop, used in some backends. +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()``. """ -# 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", "loop"] -__all__ = ["AsyncioLoop", "AsyncioTimer"] +from .base import BaseLoop -import asyncio +import sniffio -from .base import BaseLoop, BaseTimer - -class AsyncioTimer(BaseTimer): - """Timer based on asyncio.""" - - _handle = 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 def _rc_init(self): - pass + # This gets called when the first canvas is created (possibly after having run and stopped before). + import asyncio - def _rc_start(self): - def tick(): - self._handle = None - self._tick() + 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 - 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_run(self): + import asyncio - def _rc_stop(self): - if self._handle: - self._handle.cancel() - self._handle = None + if self._interactive_loop is not None: + return + asyncio.run(self._rc_run_async()) -class AsyncioLoop(BaseLoop): - _TimerClass = AsyncioTimer - _the_loop = None + async def _rc_run_async(self): + import asyncio - @property - def _loop(self): - if self._the_loop is None: - self._the_loop = self._get_loop() - return self._the_loop + # Protect agsinst usage of wrong loop object + libname = sniffio.current_async_library() + if libname != "asyncio": + raise TypeError(f"Attempt to run AsyncioLoop with {libname}.") - 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 + # Assume we have a running loop + self._run_loop = asyncio.get_running_loop() - def _rc_run(self): - if not self._loop.is_running(): - self._loop.run_forever() + # 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." + ) - def _rc_stop(self): - # Note: is only called when we're inside _rc_run - self._loop.stop() + # Create tasks if necessay + while self.__pending_tasks: + self._rc_add_task(*self.__pending_tasks.pop(-1)) - def _rc_call_soon(self, callback, *args): - self._loop.call_soon(callback, *args) + # Wait for loop to finish + if self._stop_event is None: + self._stop_event = asyncio.Event() + await self._stop_event.wait() - def _rc_gui_poll(self): - pass + def _rc_stop(self): + # Clean up our tasks + while self.__tasks: + task = self.__tasks.pop() + task.cancel() # is a no-op if the task is no longer running + # 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): + 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 + + +loop = AsyncioLoop() diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 64e66cf..fac5b4d 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -2,12 +2,15 @@ The base classes. """ -__all__ = ["WrapperRenderCanvas", "BaseRenderCanvas", "BaseLoop", "BaseTimer"] +__all__ = ["BaseLoop", "BaseRenderCanvas", "WrapperRenderCanvas"] +import sys +import weakref import importlib from ._events import EventEmitter, EventType # noqa: F401 -from ._loop import Scheduler, BaseLoop, BaseTimer +from ._loop import BaseLoop +from ._scheduler import Scheduler from ._coreutils import logger, log_exception @@ -22,12 +25,47 @@ # * `._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 = 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() + 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 (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: + if self._loop is not None: + self._loop._unregister_canvas_group(self) + self._loop = loop + + def get_loop(self): + """Get the currently associated loop (can be None for canvases that don't run a scheduler).""" + 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. - 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 @@ -49,6 +87,23 @@ 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. + """ + + @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, @@ -77,21 +132,28 @@ def __init__( "raw": "", "fps": "?", "backend": self.__class__.__name__, + "loop": self._rc_canvas_group.get_loop().__class__.__name__ + if (self._rc_canvas_group and self._rc_canvas_group.get_loop()) + else "no-loop", } # 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 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, - self._rc_get_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. @@ -229,11 +291,10 @@ def submit_event(self, event): # %% Scheduling and drawing - def _process_events(self): - """Process events and animations. + async def _process_events(self): + """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 @@ -241,13 +302,11 @@ 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. - 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 +392,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. - self._events.emit({"event_type": "before_draw"}) # Notify the scheduler if self.__scheduler is not None: @@ -405,7 +463,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) @@ -413,13 +475,9 @@ 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) for the canvas subclass, - or None for a canvas without scheduled draws. - """ - return None + def _rc_gui_poll(self): + """Process native events.""" + pass def _rc_get_present_methods(self): """Get info on the present methods supported by this canvas. @@ -456,11 +514,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. """ @@ -503,8 +556,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 @@ -528,6 +589,13 @@ 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__] + 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 c3aa38d..09631da 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -15,8 +15,8 @@ import glfw -from .base import BaseRenderCanvas -from .asyncio import AsyncioLoop +from .base import BaseRenderCanvas, BaseCanvasGroup +from .asyncio import loop from ._coreutils import SYSTEM_IS_WAYLAND, weakbind, logger @@ -146,13 +146,30 @@ def get_physical_size(window): return int(psize[0]), int(psize[1]) +def enable_glfw(): + glfw.init() + glfw._rc_alive = True + + +@atexit.register +def terminate_glfw(): + glfw.terminate() + glfw._rc_alive = False + + +class GlfwCanvasGroup(BaseCanvasGroup): + pass + + class GlfwRenderCanvas(BaseRenderCanvas): """A glfw window providing a render canvas.""" # See https://www.glfw.org/docs/latest/group__window.html + _rc_canvas_group = GlfwCanvasGroup(loop) + def __init__(self, *args, present_method=None, **kwargs): - loop.init_glfw() + enable_glfw() super().__init__(*args, **kwargs) if present_method == "bitmap": @@ -240,8 +257,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,14 +293,19 @@ def _set_logical_size(self, new_logical_size): if pixel_ratio != self._pixel_ratio: self._determine_size() - def _rc_get_loop(self): - return loop + # %% Methods to implement RenderCanvas + + 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 = self._rc_canvas_group.get_loop() loop.call_soon(self._draw_frame_and_present) def _rc_force_draw(self): @@ -312,12 +332,20 @@ 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 not glfw._rc_alive: + # May not always be able to close the proper way on system exit + self._window = 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"}) + # 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) + # 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 @@ -517,41 +545,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 GlfwAsyncioLoop(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 = GlfwAsyncioLoop() -run = loop.run # backwards compat - - def poll_glfw_briefly(poll_time=0.1): """Briefly poll glfw for a set amount of time. @@ -565,3 +558,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 +loop = loop # default loop is AsyncioLoop +RenderCanvas = GlfwRenderCanvas +run = loop.run() # backwards compat diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index bdf0001..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 - loop._pending_jupyter_canvases.append(weakref.ref(self)) - # Set size, title, etc. self._final_canvas_init() @@ -40,21 +41,13 @@ 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 # %% Methods to implement RenderCanvas - def _rc_get_loop(self): - return loop + def _rc_gui_poll(self): + pass def _rc_get_present_methods(self): # We stick to the two common formats, because these can be easily converted to png @@ -126,25 +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 _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. - 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 fe5f67c..4327188 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -4,7 +4,13 @@ __all__ = ["RenderCanvas", "loop"] -from .base import BaseRenderCanvas, BaseLoop, BaseTimer +import time + +from .base import BaseCanvasGroup, BaseRenderCanvas, BaseLoop + + +class OffscreenCanvasGroup(BaseCanvasGroup): + pass class ManualOffscreenRenderCanvas(BaseRenderCanvas): @@ -13,6 +19,8 @@ class ManualOffscreenRenderCanvas(BaseRenderCanvas): Call the ``.draw()`` method to perform a draw and get the result. """ + _rc_canvas_group = OffscreenCanvasGroup(None) # no loop, no scheduling + def __init__(self, *args, pixel_ratio=1.0, **kwargs): super().__init__(*args, **kwargs) self._pixel_ratio = pixel_ratio @@ -22,8 +30,8 @@ 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 def _rc_get_present_methods(self): return { @@ -77,7 +85,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 @@ -85,18 +93,9 @@ 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): + # 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, @@ -110,25 +109,34 @@ 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): - # Running this loop processes any timers - for timer in list(BaseTimer._running_timers): - if timer.time_left <= 0: - timer._tick() + 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() def _rc_run(self): - self._process_timers() + self.process_tasks() def _rc_stop(self): - pass + self._callbacks = [] - def _rc_call_soon(self, callback): - super()._rc_call_soon(callback) + def _rc_add_task(self, async_func, name): + super()._rc_add_task(async_func, name) - def _rc_gui_poll(self): - pass + def _rc_call_later(self, delay, callback): + self._callbacks.append((time.perf_counter() + delay, callback)) loop = StubLoop() diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index da8ba47..c31fa19 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -3,18 +3,14 @@ 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 ctypes import importlib -from .base import ( - WrapperRenderCanvas, - BaseRenderCanvas, - BaseLoop, - BaseTimer, -) + +from .base import WrapperRenderCanvas, BaseCanvasGroup, BaseRenderCanvas, BaseLoop from ._coreutils import ( logger, SYSTEM_IS_WAYLAND, @@ -161,9 +157,68 @@ 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([]) + 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 + # 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 + + 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) @@ -238,8 +293,14 @@ def update(self): # %% Methods to implement RenderCanvas - def _rc_get_loop(self): - return loop + def _rc_gui_poll(self): + 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 @@ -260,6 +321,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. @@ -483,7 +549,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) @@ -519,70 +585,4 @@ def closeEvent(self, event): # noqa: N802 # Make available under a name that is the same for all gui backends RenderWidget = QRenderWidget 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 - - @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_call_soon(self, callback, *args): - func = callback - if args: - func = lambda: callback(*args) - QtCore.QTimer.singleshot(0, func) - - 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_gui_poll(self): - pass # we assume the Qt event loop is running. Calling processEvents() will cause recursive repaints. - - -loop = QtLoop() run = loop.run # backwards compat diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index c933b97..f96974c 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -4,11 +4,61 @@ __all__ = ["RenderCanvas", "loop"] -from .base import WrapperRenderCanvas, BaseRenderCanvas, BaseLoop, BaseTimer +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() + + +loop = StubLoop() + + +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. + + 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. """ @@ -20,16 +70,18 @@ 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() # Must be implemented by subclasses. - def _rc_get_loop(self): - return None + _rc_canvas_group = StubCanvasGroup(loop) + + def _rc_gui_poll(self): + raise NotImplementedError() def _rc_get_present_methods(self): raise NotImplementedError() @@ -78,42 +130,6 @@ 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() - - def _rc_stop(self): - raise NotImplementedError() - - 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 loop = StubLoop() diff --git a/rendercanvas/trio.py b/rendercanvas/trio.py new file mode 100644 index 0000000..0f75043 --- /dev/null +++ b/rendercanvas/trio.py @@ -0,0 +1,50 @@ +""" +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 trio +import sniffio + + +class TrioLoop(BaseLoop): + def _rc_init(self): + import trio + + self._cancel_scope = None + self._send_channel, self._receive_channel = trio.open_memory_channel(99) + + def _rc_run(self): + trio.run(self._rc_run_async, restrict_keyboard_interrupt_to_checkpoints=False) + + async def _rc_run_async(self): + # 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: + async_func, name = await self._receive_channel.receive() + nursery.start_soon(async_func, name=name) + self._cancel_scope = None + + def _rc_stop(self): + # Cancel the main task and all its child tasks. + 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/utils/asyncadapter.py b/rendercanvas/utils/asyncadapter.py new file mode 100644 index 0000000..80461b3 --- /dev/null +++ b/rendercanvas/utils/asyncadapter.py @@ -0,0 +1,139 @@ +""" +A micro async framework that only support sleep() and Event. Behaves well with sniffio. +""" + +import logging + +from sniffio import thread_local as sniffio_thread_local + + +logger = logging.getLogger("asyncadapter") + + +class Sleeper: + """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. + yield {"wait_method": "sleep", "delay": self.delay} + + +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 = [] + + 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_later(0) + self._tasks = [] + + +class CancelledError(BaseException): + """Exception raised when a task is cancelled.""" + + pass + + +class Task: + """Representation of task, exectuting a co-routine.""" + + 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.call_step_later(0) + + 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 + self._done_callbacks.clear() + + def call_step_later(self, delay): + self._call_later(delay, 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() + + error = None + if not (isinstance(result, dict) and result.get("wait_method", None)): + 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)?" + ) + self.cancel() + self.call_step_later(0) diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py new file mode 100644 index 0000000..398c867 --- /dev/null +++ b/rendercanvas/utils/asyncs.py @@ -0,0 +1,23 @@ +""" +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 + return Event() diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index aba9ed8..3be6e37 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -21,8 +21,8 @@ from .base import ( WrapperRenderCanvas, BaseRenderCanvas, + BaseCanvasGroup, BaseLoop, - BaseTimer, ) @@ -127,9 +127,68 @@ 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: + self._app = wx.App.GetInstance() + if self._app is None: + self._app = wx.App() + wx.App.SetInstance(self._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): + 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() + + +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) @@ -202,8 +261,11 @@ def _get_surface_ids(self): # %% Methods to implement RenderCanvas - def _rc_get_loop(self): - return loop + def _rc_gui_poll(self): + if isinstance(self._rc_canvas_group.get_loop(), WxLoop): + pass # all is well + else: + loop.process_wx_events() def _rc_get_present_methods(self): if self._surface_ids is None: @@ -432,7 +494,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) @@ -450,73 +512,17 @@ 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 RenderCanvas = WxRenderCanvas - - -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 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): - _ = 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 _rc_call_soon(self, delay, callback, *args): - wx.CallSoon(callback, args) - - 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_gui_poll(self): - pass # We can assume the wx loop is running. - - 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) - - -loop = WxLoop() run = loop.run # backwards compat diff --git a/tests/test_backends.py b/tests/test_backends.py index bccedc5..897143e 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 @@ -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): @@ -121,28 +127,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 +160,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 +183,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 +191,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 +228,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,11 +236,24 @@ def test_glfw_module(): m.check_canvas(canvas_class) assert canvas_class.name == "GlfwRenderCanvas" - loop_class = m.get_loop_class() - assert loop_class.name == "GlfwAsyncioLoop" + # 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" + + +def test_offscreen_module(): + m = Module("offscreen") - # Loop is provided by our asyncio module - assert m.get_bases(loop_class) == ["AsyncioLoop"] + canvas_class = m.get_canvas_class() + m.check_canvas(canvas_class) + assert canvas_class.name == "ManualOffscreenRenderCanvas" def test_qt_module(): @@ -268,10 +267,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 +299,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..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,13 +109,14 @@ def test_glfw_canvas_render(): """Render an orange square ... in a glfw window.""" import wgpu - import glfw - from rendercanvas.glfw import RenderCanvas, loop + 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") @@ -145,8 +152,12 @@ def draw_frame2(): run_briefly() assert frame_counter == 3 - # canvas.close() - glfw.poll_events() + # Stopping + assert not loop_task.done() + canvas.close() + 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 cc3f3b6..0cd6b0b 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -4,33 +4,71 @@ 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 rendercanvas.utils.asyncs import sleep as async_sleep from testutils import run_tests +import trio + +import pytest + + +async def fake_task(): + pass + + +class CanvasGroup(BaseCanvasGroup): + pass + + +class FakeEventEmitter: + is_closed = False + + async def close(self): + self.is_closed = True class FakeCanvas: - def __init__(self, refuse_close): + def __init__(self, refuse_close=False): self.refuse_close = refuse_close self.is_closed = False + self._events = FakeEventEmitter() + + def _rc_gui_poll(self): + pass def _rc_close(self): # Called by the loop to close a canvas if not self.refuse_close: self.is_closed = True + def get_closed(self): + return self.is_closed + + def manually_close(self): + self.is_closed = True -class FakeScheduler: - def __init__(self, refuse_close=False): - self._canvas = FakeCanvas(refuse_close) - def get_canvas(self): - if self._canvas and not self._canvas.is_closed: - return self._canvas +real_loop = AsyncioLoop() + + +class RealRenderCanvas(BaseRenderCanvas): + _rc_canvas_group = CanvasGroup(real_loop) + _is_closed = False + + 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 _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(): @@ -40,19 +78,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() @@ -61,15 +169,19 @@ 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) - 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) @@ -81,16 +193,120 @@ 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_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. + + loop = AsyncioLoop() + 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 + + 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 + + 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. + # 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!") @@ -109,16 +325,20 @@ 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 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!") @@ -139,12 +359,44 @@ 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() +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_meta.py b/tests/test_meta.py new file mode 100644 index 0000000..78784b7 --- /dev/null +++ b/tests/test_meta.py @@ -0,0 +1,103 @@ +""" +Test some meta stuff. +""" + +import sys +import subprocess +from testutils import run_tests + +import rendercanvas +import pytest + + +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) + + +@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(): + # 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 + + +@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) + assert "trio" in modules + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/tests/test_offscreen.py b/tests/test_offscreen.py index 0eee2da..432c56d 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 @@ -67,21 +68,30 @@ 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 - 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 785df75..dfcc9f0 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -6,28 +6,31 @@ import time from testutils import run_tests -from rendercanvas import BaseRenderCanvas, BaseLoop, BaseTimer +from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas, BaseLoop -class MyTimer(BaseTimer): - def _rc_start(self): - pass - - def _rc_stop(self): - pass +class MyCanvasGroup(BaseCanvasGroup): + pass 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,19 +38,23 @@ 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() - _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 @@ -55,9 +62,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() @@ -72,11 +79,11 @@ 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) - loop.process_timers() + loop.process_tasks() self.draw_if_necessary() @@ -97,7 +104,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()