From 6d43bf47e78de3d8d789c49448271cef93fb36d4 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 6 Nov 2024 09:40:36 +0100 Subject: [PATCH] Implement scheduling (#6) --- docs/conf.py | 1 - docs/gui.rst | 10 + examples/gui_demo.py | 55 ++++ examples/gui_multiple.py | 23 ++ examples/gui_threading.py | 52 ++++ pyproject.toml | 9 +- rendercanvas/__init__.py | 7 +- rendercanvas/_coreutils.py | 2 +- rendercanvas/_events.py | 215 ++++++++++++++ rendercanvas/_loop.py | 440 +++++++++++++++++++++++++++ rendercanvas/asyncio.py | 71 +++++ rendercanvas/auto.py | 7 +- rendercanvas/base.py | 495 +++++++++++++++---------------- rendercanvas/glfw.py | 158 ++++------ rendercanvas/jupyter.py | 75 +++-- rendercanvas/offscreen.py | 77 +++-- rendercanvas/qt.py | 213 ++++++++----- rendercanvas/wx.py | 208 ++++++++----- tests/test_gui_auto_offscreen.py | 66 ----- tests/test_gui_base.py | 152 ++++------ tests/test_gui_events.py | 341 +++++++++++++++++++++ tests/test_gui_glfw.py | 114 ++----- tests/test_gui_offscreen.py | 69 +++++ tests/test_gui_scheduling.py | 212 +++++++++++++ tests/test_gui_utils.py | 42 +++ tests/testutils.py | 78 +++++ 26 files changed, 2362 insertions(+), 830 deletions(-) create mode 100644 examples/gui_demo.py create mode 100644 examples/gui_multiple.py create mode 100644 examples/gui_threading.py create mode 100644 rendercanvas/_events.py create mode 100644 rendercanvas/_loop.py create mode 100644 rendercanvas/asyncio.py delete mode 100644 tests/test_gui_auto_offscreen.py create mode 100644 tests/test_gui_events.py create mode 100644 tests/test_gui_offscreen.py create mode 100644 tests/test_gui_scheduling.py create mode 100644 tests/test_gui_utils.py create mode 100644 tests/testutils.py diff --git a/docs/conf.py b/docs/conf.py index cfd68f7..aad2807 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,7 +12,6 @@ import os import sys -import shutil ROOT_DIR = os.path.abspath(os.path.join(__file__, "..", "..")) diff --git a/docs/gui.rst b/docs/gui.rst index 4c2d5b3..fa55c02 100644 --- a/docs/gui.rst +++ b/docs/gui.rst @@ -20,6 +20,16 @@ support for events (interactivity). In the next sections we demonstrates the dif canvas classes that you can use. +Events +------ + +To implement interaction with a ``WgpuCanvas``, use the :func:`WgpuCanvasBase.add_event_handler()` method. +Events come in the following flavours: + +.. autoclass:: WgpuEventType + :members: + + The auto GUI backend -------------------- diff --git a/examples/gui_demo.py b/examples/gui_demo.py new file mode 100644 index 0000000..d99ccdf --- /dev/null +++ b/examples/gui_demo.py @@ -0,0 +1,55 @@ +""" +An example that uses events to trigger some canvas functionality. + +A nice demo, and very convenient to test the different backends. + +* 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. + +""" + +import time + +from rendercanvas.auto import WgpuCanvas, loop + +from cube import setup_drawing_sync + + +canvas = WgpuCanvas( + size=(640, 480), + title="Canvas events on $backend - $fps fps", + max_fps=10, + update_mode="continuous", + present_method="", +) + + +draw_frame = setup_drawing_sync(canvas) +canvas.request_draw(lambda: (draw_frame(), canvas.request_draw())) + + +@canvas.add_event_handler("*") +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"] == " ": + etime = time.time() + 2 + i = 0 + while time.time() < etime: + i += 1 + canvas.force_draw() + print(f"force-drawed {i} frames in 2s.") + elif event["event_type"] == "close": + # Should see this exactly once, either when pressing escape, or + # when pressing the window close button. + print("Close detected!") + assert canvas.is_closed() + + +if __name__ == "__main__": + loop.run() diff --git a/examples/gui_multiple.py b/examples/gui_multiple.py new file mode 100644 index 0000000..53294f0 --- /dev/null +++ b/examples/gui_multiple.py @@ -0,0 +1,23 @@ +""" +Run triangle and cube examples two canvases. +""" + +# test_example = true + +from rendercanvas.auto import WgpuCanvas, loop + +from triangle import setup_drawing_sync as setup_drawing_sync_triangle +from cube import setup_drawing_sync as setup_drawing_sync_cube + + +canvas1 = WgpuCanvas(title=f"Triangle example on {WgpuCanvas.__name__}") +draw_frame1 = setup_drawing_sync_triangle(canvas1) +canvas1.request_draw(draw_frame1) + +canvas2 = WgpuCanvas(title=f"Cube example on {WgpuCanvas.__name__}") +draw_frame2 = setup_drawing_sync_cube(canvas2) +canvas2.request_draw(draw_frame2) + + +if __name__ == "__main__": + loop.run() diff --git a/examples/gui_threading.py b/examples/gui_threading.py new file mode 100644 index 0000000..d929382 --- /dev/null +++ b/examples/gui_threading.py @@ -0,0 +1,52 @@ +""" +Example that renders frames in a separate thread. + +This uses an offscreen canvas, the result is only used to print the +frame shape. But one can see how one can e.g. render a movie this way. + +Threaded rendering using a real GUI is not supported right now, since +this is tricky to do with both Qt and glfw. Plus in general its a bad +idea to run your UI in anything other than the main thread. In other +words, you should probably only use threaded rendering for off-screen +stuff. + +""" + +# test_example = true + +import time +import threading + +from rendercanvas.offscreen import WgpuCanvas + +from cube import setup_drawing_sync + + +# create canvas +canvas = WgpuCanvas() +draw_frame = setup_drawing_sync(canvas) + + +def main(): + frame_count = 0 + canvas.request_draw(draw_frame) + + while not canvas.is_closed(): + image = canvas.draw() + frame_count += 1 + print(f"Rendered {frame_count} frames, last shape is {image.shape}") + + +if __name__ == "__main__": + t1 = threading.Thread(target=main) + t1.start() + + # In the main thread, we wait a little + time.sleep(1) + + # ... then change the canvas size, and wait some more + canvas.set_logical_size(200, 200) + time.sleep(1) + + # Close the canvas to stop the tread + canvas.close() diff --git a/pyproject.toml b/pyproject.toml index 776cc0e..91e48b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ glfw = ["glfw>=1.9"] lint = ["ruff", "pre-commit"] examples = ["numpy", "wgpu", "glfw", "pyside6"] docs = ["sphinx>7.2", "sphinx_rtd_theme", "sphinx-gallery"] -tests = ["pytest", "psutil", "glfw", "pyside6"] +tests = ["pytest", "numpy", "psutil", "wgpu", "glfw"] dev = ["rendercanvas[lint,tests,examples,docs]"] [project.entry-points."pyinstaller40"] @@ -54,7 +54,8 @@ line-length = 88 [tool.ruff.lint] select = ["F", "E", "W", "N", "B", "RUF"] ignore = [ - "E501", # Line too long - "E731", # Do not assign a `lambda` expression, use a `def` - "B007", # Loop control variable `x` not used within loop body + "E501", # Line too long + "E731", # Do not assign a `lambda` expression, use a `def` + "B007", # Loop control variable `x` not used within loop body + "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` ] diff --git a/rendercanvas/__init__.py b/rendercanvas/__init__.py index ce152ad..3bc61e4 100644 --- a/rendercanvas/__init__.py +++ b/rendercanvas/__init__.py @@ -6,10 +6,13 @@ from ._version import __version__, version_info from . import _gui_utils -from .base import WgpuCanvasInterface, WgpuCanvasBase, WgpuAutoGui +from ._events import WgpuEventType +from .base import WgpuCanvasInterface, WgpuCanvasBase, WgpuLoop, WgpuTimer __all__ = [ "WgpuCanvasInterface", "WgpuCanvasBase", - "WgpuAutoGui", + "WgpuEventType", + "WgpuLoop", + "WgpuTimer", ] diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 56b2b21..c8389ef 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -86,7 +86,7 @@ def __getitem__(cls, key): def __repr__(cls): if cls is BaseEnum: - return "" + return "" pkg = cls.__module__.split(".")[0] name = cls.__name__ options = [] diff --git a/rendercanvas/_events.py b/rendercanvas/_events.py new file mode 100644 index 0000000..31ef567 --- /dev/null +++ b/rendercanvas/_events.py @@ -0,0 +1,215 @@ +import time +from collections import defaultdict, deque + +from ._gui_utils import log_exception +from ._coreutils import BaseEnum + + +class WgpuEventType(BaseEnum): + """The WgpuEventType enum specifies the possible events for a WgpuCanvas. + + This includes the events from the jupyter_rfb event spec (see + https://jupyter-rfb.readthedocs.io/en/stable/events.html) plus some + wgpu-specific events. + """ + + # Jupter_rfb spec + + resize = None #: The canvas has changed size. Has 'width' and 'height' in logical pixels, 'pixel_ratio'. + close = None #: The canvas is closed. No additional fields. + pointer_down = None #: The pointing device is pressed down. Has 'x', 'y', 'button', 'butons', 'modifiers', 'ntouches', 'touches'. + pointer_up = None #: The pointing device is released. Same fields as pointer_down. + pointer_move = None #: The pointing device is moved. Same fields as pointer_down. + double_click = None #: A double-click / long-tap. This event looks like a pointer event, but without the touches. + wheel = None #: The mouse-wheel is used (scrolling), or the touchpad/touchscreen is scrolled/pinched. Has 'dx', 'dy', 'x', 'y', 'modifiers'. + key_down = None #: A key is pressed down. Has 'key', 'modifiers'. + key_up = None #: A key is released. Has 'key', 'modifiers'. + + # Pending for the spec, may become part of key_down/key_up + char = None #: Experimental + + # Our extra events + + before_draw = ( + None #: Event emitted right before a draw is performed. Has no extra fields. + ) + animate = None #: Animation event. Has 'step' representing the step size in seconds. This is stable, except when the 'catch_up' field is nonzero. + + +class EventEmitter: + """The EventEmitter stores event handlers, collects incoming events, and dispatched them. + + Subsequent events of ``event_type`` 'pointer_move' and 'wheel' are merged. + """ + + _EVENTS_THAT_MERGE = { + "pointer_move": { + "match_keys": {"buttons", "modifiers", "ntouches"}, + "accum_keys": {}, + }, + "wheel": { + "match_keys": {"modifiers"}, + "accum_keys": {"dx", "dy"}, + }, + "resize": { + "match_keys": {}, + "accum_keys": {}, + }, + } + + def __init__(self): + self._pending_events = deque() + self._event_handlers = defaultdict(list) + self._closed = False + + 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. + *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. + + For the available events, see + https://jupyter-rfb.readthedocs.io/en/stable/events.html. + + When an event is emitted, callbacks with the same priority are called in + the order that they were added. + + 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 + destroying that object. + + Example: + + .. code-block:: py + + def my_handler(event): + print(event) + + canvas.add_event_handler(my_handler, "pointer_up", "pointer_down") + + Can also be used as a decorator: + + .. code-block:: py + + @canvas.add_event_handler("pointer_up", "pointer_down") def + my_handler(event): + print(event) + + Catch 'm all: + + .. code-block:: py + + canvas.add_event_handler(my_handler, "*") + + """ + order = float(order) + decorating = not callable(args[0]) + callback = None if decorating else args[0] + types = args if decorating else args[1:] + + if not types: + raise TypeError("No event types are given to add_event_handler.") + for type in types: + if not isinstance(type, str): + raise TypeError(f"Event types must be str, but got {type}") + if not (type == "*" or type in WgpuEventType): + raise ValueError(f"Adding handler with invalid event_type: '{type}'") + + def decorator(_callback): + self._add_handler(_callback, order, *types) + return _callback + + if decorating: + return decorator + return decorator(callback) + + def _add_handler(self, callback, order, *types): + self.remove_handler(callback, *types) + for type in types: + self._event_handlers[type].append((order, callback)) + self._event_handlers[type].sort(key=lambda x: x[0]) + # Note: that sort is potentially expensive. I tried an approach with a custom dequeu to add the handler + # at the correct position, but the overhead was apparently larger than the benefit of avoiding sort. + + def remove_handler(self, callback, *types): + """Unregister an event handler. + + Arguments: + callback (callable): The event handler. + *types (list of strings): A list of event types. + """ + for type in types: + self._event_handlers[type] = [ + (o, cb) for o, cb in self._event_handlers[type] if cb is not callback + ] + + def submit(self, event): + """Submit an event. + + Events are emitted later by the scheduler. + """ + event_type = event["event_type"] + if event_type not in WgpuEventType: + 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) + + if event_merge_info and self._pending_events: + # Try merging the event with the last one + last_event = self._pending_events[-1] + if last_event["event_type"] == event_type: + match_keys = event_merge_info["match_keys"] + accum_keys = event_merge_info["accum_keys"] + if all( + event.get(key, None) == last_event.get(key, None) + for key in match_keys + ): + # Merge-able event + self._pending_events.pop() # remove last_event + # Update new event + for key in accum_keys: + event[key] += last_event[key] + + self._pending_events.append(event) + + def flush(self): + """Dispatch all pending events. + + This should generally be left to the scheduler. + """ + while True: + try: + event = self._pending_events.popleft() + except IndexError: + break + self.emit(event) + + def emit(self, event): + """Directly emit the given event. + + In most cases events should be submitted, so that they are flushed + with the rest at a good time. + """ + # Collect callbacks + event_type = event.get("event_type") + callbacks = self._event_handlers[event_type] + self._event_handlers["*"] + # Dispatch + for _order, callback in callbacks: + if event.get("stop_propagation", False): + break + with log_exception(f"Error during handling {event_type} event"): + callback(event) + + def _wgpu_close(self): + """Wrap up when the scheduler detects the canvas is closed/dead.""" + # This is a little feature because detecting a widget from closing can be tricky. + if not self._closed: + self.submit({"event_type": "close"}) + self.flush() diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py new file mode 100644 index 0000000..adffa36 --- /dev/null +++ b/rendercanvas/_loop.py @@ -0,0 +1,440 @@ +""" +Implemens loop mechanics: The base timer, base loop, and scheduler. +""" + +import time +import weakref + +from ._gui_utils import log_exception +from ._coreutils import 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. + + +class WgpuTimer: + """Base class for a timer objects.""" + + _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._init() + if self.is_running: + self._stop() + WgpuTimer._running_timers.add(self) + self._interval = max(0.0, float(interval)) + self._expect_tick_at = time.perf_counter() + self._interval + self._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. + """ + WgpuTimer._running_timers.discard(self) + self._expect_tick_at = None + self._stop() + + def _tick(self): + """The implementations must call this method.""" + # Stop or restart + if self._one_shot: + WgpuTimer._running_timers.discard(self) + self._expect_tick_at = None + else: + self._expect_tick_at = time.perf_counter() + self._interval + self._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.""" + return self._expect_tick_at is not None + + @property + def is_one_shot(self): + """Whether the timer is one-shot or continuous.""" + return self._one_shot + + def _init(self): + """For the subclass to implement: + + Opportunity to initialize the timer object. This is called right + before the timer is first started. + """ + pass + + def _start(self): + """For the subclass to implement: + + * 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._stop()`` is called before the timer finished, the call to ``self._tick()`` must be cancelled. + """ + raise NotImplementedError() + + def _stop(self): + """For the subclass to implement: + + * If the timer is running, cancel the pending call to ``self._tick()``. + * Otherwise, this should do nothing. + """ + raise NotImplementedError() + + +class WgpuLoop: + """Base class for event-loop objects.""" + + _TimerClass = None # subclases must set this + + def __init__(self): + self._schedulers = set() + self._stop_when_no_canvases = False + + # The loop object runs a lightweight timer for a few reasons: + # * Support running the loop without windows (e.g. to keep animations going). + # * Detect closed windows. Relying on the backend alone is tricky, since the + # loop usually stops when the last window is closed, so the close event may + # not be fired. + # * Keep the GUI going even when the canvas loop is on pause e.g. because its + # minimized (applies to backends that implement _wgpu_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.add(scheduler) + self._gui_timer.start(0.1) # (re)start our internal timer + + def _tick(self): + # Keep the GUI alive on every tick + self._wgpu_gui_poll() + + # Check all schedulers + schedulers_to_close = [] + for scheduler in self._schedulers: + if scheduler._get_canvas() is None: + schedulers_to_close.append(scheduler) + + # Forget schedulers that no longer have an live canvas + for scheduler in schedulers_to_close: + self._schedulers.discard(scheduler) + + # Check whether we must stop the loop + if self._stop_when_no_canvases and not self._schedulers: + self.stop() + + def call_soon(self, callback, *args): + """Arrange for a callback to be called as soon as possible. + + 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._call_soon(callback, *args) + + def call_later(self, delay, callback, *args): + """Arrange for a callback to be called after the given delay (in seconds). + + Returns a timer object (in one-shot mode) that can be used to + stop the time (i.e. cancel the callback being called), and/or + to restart the timer. + + It's not necessary to hold a reference to the timer object; a + ref is held automatically, and discarded when the timer ends or stops. + """ + timer = self._TimerClass(self, callback, *args, one_shot=True) + timer.start(delay) + return timer + + def call_repeated(self, interval, callback, *args): + """Arrange for a callback to be called repeatedly. + + Returns a timer object (in multi-shot mode) that can be used for + further control. + + 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 + + def run(self, stop_when_no_canvases=True): + """Enter the main loop. + + This provides a generic API to start the loop. When building an application (e.g. with Qt) + its fine to start the loop in the normal way. + """ + self._stop_when_no_canvases = bool(stop_when_no_canvases) + self._run() + + def stop(self): + """Stop the currently running event loop.""" + self._stop() + + def _run(self): + """For the subclass to implement: + + * Start the event loop. + * The rest of the loop object must work just fine, also when the loop is + started in the "normal way" (i.e. this method may not be called). + """ + raise NotImplementedError() + + def _stop(self): + """For the subclass to implement: + + * Stop the running event loop. + * When running in an interactive session, this call should probably be ignored. + """ + raise NotImplementedError() + + def _call_soon(self, callback, *args): + """For the subclass to implement: + + * 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 _wgpu_gui_poll(self): + """For the subclass to implement: + + Some event loops (e.g. asyncio) are just that and dont have a GUI to update. + Other loops (like Qt) already process events. So this is only intended for + backends like glfw. + """ + pass + + +class UpdateMode(BaseEnum): + """The different modes to schedule draws for the canvas.""" + + manual = None #: Draw events are never scheduled. Draws only happen when you ``canvas.force_draw()``, and maybe when the GUI system issues them (e.g. when resizing). + ondemand = None #: Draws are only scheduled when ``canvas.request_draw()`` is called when an update is needed. Safes your laptop battery. Honours ``min_fps`` and ``max_fps``. + continuous = None #: Continuously schedules draw events, honouring ``max_fps``. Calls to ``canvas.request_draw()`` have no effect. + fastest = None #: Continuously schedules draw events as fast as possible. Gives high FPS (and drains your battery). + + +class Scheduler: + """Helper class to schedule event processing and drawing.""" + + # This class makes the canvas tick. Since we do not own the event-loop, but + # ride on e.g. Qt, asyncio, wx, JS, or something else, our little "loop" is + # implemented with a timer. + # + # The loop looks a little like this: + # + # ________________ __ ________________ __ rd = request_draw + # / wait \ / rd \ / wait \ / rd \ + # | || || || | + # --------------------------------------------------------------------> time + # | | | | | + # schedule tick draw tick draw + # + # With update modes 'ondemand' and 'manual', the loop ticks at the same rate + # as on 'continuous' mode, but won't draw every tick: + # + # ________________ ________________ __ + # / wait \ / wait \ / rd \ + # | || || | + # --------------------------------------------------------------------> time + # | | | | + # schedule tick tick draw + # + # A tick is scheduled by calling _schedule_next_tick(). If this method is + # called when the timer is already running, it has no effect. In the _tick() + # method, events are processed (including animations). Then, depending on + # the mode and whether a draw was requested, a new tick is scheduled, or a + # draw is requested. In the latter case, the timer is not started, but we + # wait for the canvas to perform a draw. In _draw_drame_and_present() the + # draw is done, and a new tick is scheduled. + # + # The next tick is scheduled when a draw is done, and not earlier, otherwise + # the drawing may not keep up with the ticking. + # + # On desktop canvases the draw usually occurs very soon after it is + # requested, but on remote frame buffers, it may take a bit longer. To make + # sure the rendered image reflects the latest state, these backends may + # issue an extra call to _process_events() right before doing the draw. + # + # When the window is minimized, the draw will not occur until the window is + # shown again. For the canvas to detect minimized-state, it will need to + # receive GUI events. This is one of the reasons why the loop object also + # runs a timer-loop. + # + # The drawing itself may take longer than the intended wait time. In that + # case, it will simply take longer than we hoped and get a lower fps. + # + # Note that any extra draws, e.g. via force_draw() or due to window resizes, + # don't affect the scheduling loop; they are just extra draws. + + def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30): + # 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 = canvas._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() + + assert loop is not None + + # Initialise the timer that runs our scheduling loop. + # Note that the gui 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): + canvas = self._canvas_ref() + if canvas is None or canvas.is_closed(): + # Pretty nice, we can send a close event, even if the canvas no longer exists + self._events._wgpu_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": + delay = 0 + else: + delay = 1 / self._max_fps + delay = 0 if delay < 0 else delay # 0 means cannot keep up + + # Offset delay for time spent on processing events, etc. + time_since_tick_start = time.perf_counter() - self._last_tick_time + delay -= time_since_tick_start + delay = max(0, delay) + + # Go! + self._timer.start(delay) + + def _tick(self): + """Process event and schedule a new draw or tick.""" + + self._last_tick_time = time.perf_counter() + + # Get canvas or stop + if (canvas := self._get_canvas()) is None: + return + + # Process events, handlers may request a draw + canvas._process_events() + + # Determine what to do next ... + + if self._mode == "fastest": + # fastest: draw continuously as fast as possible, ignoring fps settings. + canvas._request_draw() + + elif self._mode == "continuous": + # continuous: draw continuously, aiming for a steady max framerate. + canvas._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._request_draw() + elif ( + self._min_fps > 0 + and time.perf_counter() - self._last_draw_time > 1 / self._min_fps + ): + canvas._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 diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py new file mode 100644 index 0000000..808040f --- /dev/null +++ b/rendercanvas/asyncio.py @@ -0,0 +1,71 @@ +"""Implements an asyncio event loop.""" + +# This is used for GUI 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. + +import asyncio + +from .base import WgpuLoop, WgpuTimer + + +class AsyncioWgpuTimer(WgpuTimer): + """Wgpu timer based on asyncio.""" + + _handle = None + + def _start(self): + def tick(): + self._handle = None + self._tick() + + if self._handle is not None: + self._handle.cancel() + asyncio_loop = self._loop._loop + self._handle = asyncio_loop.call_later(self._interval, tick) + + def _stop(self): + if self._handle: + self._handle.cancel() + self._handle = None + + +class AsyncioWgpuLoop(WgpuLoop): + _TimerClass = AsyncioWgpuTimer + _the_loop = None + _is_interactive = False + + @property + def _loop(self): + if self._the_loop is None: + self._the_loop = self._get_loop() + return self._the_loop + + def _get_loop(self): + try: + return asyncio.get_running_loop() + except Exception: + pass + # todo: get_event_loop is on a deprecation path. + # but there still is `set_event_loop()` so I'm a bit confused + try: + return asyncio.get_event_loop() + except RuntimeError: + pass + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + def _run(self): + if self._loop.is_running(): + self._is_interactive = True + else: + self._is_interactive = False + self._loop.run_forever() + + def _stop(self): + if not self._is_interactive: + self._loop.stop() + + def _call_soon(self, callback, *args): + self._loop.call_soon(callback, *args) diff --git a/rendercanvas/auto.py b/rendercanvas/auto.py index 65536ec..dfb107f 100644 --- a/rendercanvas/auto.py +++ b/rendercanvas/auto.py @@ -5,7 +5,7 @@ for e.g. wx later. Or we might decide to stick with these three. """ -__all__ = ["WgpuCanvas", "run", "call_later"] +__all__ = ["WgpuCanvas", "loop", "run"] import os import sys @@ -13,7 +13,7 @@ from ._gui_utils import logger, QT_MODULE_NAMES, get_imported_qt_lib, asyncio_is_running -# Note that wx is not in here, because it does not (yet) implement base.WgpuAutoGui +# Note that wx is not in here, because it does not (yet) fully implement base.WgpuCanvasBase WGPU_GUI_BACKEND_NAMES = ["glfw", "qt", "jupyter", "offscreen"] @@ -188,4 +188,5 @@ def backends_by_trying_in_order(): # Load! module = select_backend() -WgpuCanvas, run, call_later = module.WgpuCanvas, module.run, module.call_later +WgpuCanvas, loop = module.WgpuCanvas, module.loop +run = loop.run # backwards compat diff --git a/rendercanvas/base.py b/rendercanvas/base.py index cc95767..8fddf5d 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -1,25 +1,10 @@ import sys -import time -from collections import defaultdict +from ._events import EventEmitter, WgpuEventType # noqa: F401 +from ._loop import Scheduler, WgpuLoop, WgpuTimer # noqa: F401 from ._gui_utils import log_exception -def create_canvas_context(canvas): - """Create a GPUCanvasContext for the given canvas. - - Helper function to keep the implementation of WgpuCanvasInterface - as small as possible. - """ - backend_module = sys.modules["wgpu"].gpu.__module__ - if backend_module == "wgpu._classes": - raise RuntimeError( - "A backend must be selected (e.g. with request_adapter()) before canvas.get_context() can be called." - ) - CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 - return CanvasContext(canvas) - - class WgpuCanvasInterface: """The minimal interface to be a valid canvas. @@ -29,10 +14,7 @@ class WgpuCanvasInterface: In most cases it's more convenient to subclass :class:`WgpuCanvasBase `. """ - def __init__(self, *args, **kwargs): - # The args/kwargs are there because we may be mixed with e.g. a Qt widget - super().__init__(*args, **kwargs) - self._canvas_context = None + _canvas_context = None # set in get_context() def get_present_info(self): """Get information about the surface to render to. @@ -80,7 +62,15 @@ def get_context(self, kind="webgpu"): # here the only valid arg is 'webgpu', which is also made the default. assert kind == "webgpu" if self._canvas_context is None: - self._canvas_context = create_canvas_context(self) + backend_module = "" + if "wgpu" in sys.modules: + backend_module = sys.modules["wgpu"].gpu.__module__ + if backend_module in ("", "wgpu._classes"): + raise RuntimeError( + "A backend must be selected (e.g. with wgpu.gpu.request_adapter()) before canvas.get_context() can be called." + ) + CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 + self._canvas_context = CanvasContext(self) return self._canvas_context def present_image(self, image, **kwargs): @@ -94,28 +84,53 @@ def present_image(self, image, **kwargs): class WgpuCanvasBase(WgpuCanvasInterface): - """A convenient base canvas class. - - This class provides a uniform API and implements common - functionality, to increase consistency and reduce code duplication. - It is convenient (but not strictly necessary) for canvas classes - to inherit from this class (but all builtin canvases do). + """The base canvas class. + + This class provides a uniform canvas API so render systems can be use + code that is portable accross multiple GUI libraries and canvas targets. + + Arguments: + update_mode (WgpuEventType): The mode for scheduling draws and events. Default 'ondemand'. + min_fps (float): A minimal frames-per-second to use when the ``update_mode`` is 'ondemand'. + The default is 1: even without draws requested, it still draws every second. + max_fps (float): A maximal frames-per-second to use when the ``update_mode`` is 'ondemand' or 'continuous'. + The default is 30, which is usually enough. + vsync (bool): Whether to sync the draw with the monitor update. Helps + against screen tearing, but can reduce fps. Default True. + present_method (str | None): The method to present the rendered image. + Can be set to 'screen' or 'image'. Default None (auto-select). - This class provides an API for scheduling draws (``request_draw()``) - and implements a mechanism to call the provided draw function - (``draw_frame()``) and then present the result to the canvas. - - This class also implements draw rate limiting, which can be set - with the ``max_fps`` attribute (default 30). For benchmarks you may - also want to set ``vsync`` to False. """ - def __init__(self, *args, max_fps=30, vsync=True, present_method=None, **kwargs): + def __init__( + self, + *args, + update_mode="ondemand", + min_fps=1.0, + max_fps=30.0, + vsync=True, + present_method=None, + **kwargs, + ): super().__init__(*args, **kwargs) - self._last_draw_time = 0 - self._max_fps = float(max_fps) self._vsync = bool(vsync) - present_method # noqa - We just catch the arg here in case a backend does implement support it + present_method # noqa - We just catch the arg here in case a backend does implement it + + # Canvas + self.__raw_title = "" + self.__title_kwargs = { + "fps": "?", + "backend": self.__class__.__name__, + } + + self.__is_drawing = False + self._events = EventEmitter() + self._scheduler = None + loop = self._get_loop() + if loop: + self._scheduler = Scheduler( + self, loop, min_fps=min_fps, max_fps=max_fps, mode=update_mode + ) def __del__(self): # On delete, we call the custom close method. @@ -130,21 +145,77 @@ def __del__(self): except Exception: pass - def draw_frame(self): - """The function that gets called at each draw. - - You can implement this method in a subclass, or set it via a - call to request_draw(). + # === Events + + def add_event_handler(self, *args, **kwargs): + return self._events.add_handler(*args, **kwargs) + + def remove_event_handler(self, *args, **kwargs): + return self._events.remove_handler(*args, **kwargs) + + def submit_event(self, event): + # Not strictly necessary for normal use-cases, but this allows + # the ._event to be an implementation detail to subclasses, and it + # allows users to e.g. emulate events in tests. + return self._events.submit(event) + + add_event_handler.__doc__ = EventEmitter.add_handler.__doc__ + remove_event_handler.__doc__ = EventEmitter.remove_handler.__doc__ + submit_event.__doc__ = EventEmitter.submit.__doc__ + + def _process_events(self): + """Process events and animations. Called from the scheduler.""" + + # We don't want this to be called too often, because we want the + # accumulative events to accumulate. Once per draw, and at max_fps + # when there are no draws (in ondemand and manual mode). + + # Get events from the GUI into our event mechanism. + loop = self._get_loop() + if loop: + loop._wgpu_gui_poll() + + # Flush our events, so downstream code can update stuff. + # Maybe that downstream code request a new draw. + self._events.flush() + + # TODO: implement later (this is a start but is not tested) + # Schedule animation events until the lag is gone + # step = self._animation_step + # self._animation_time = self._animation_time or time.perf_counter() # start now + # animation_iters = 0 + # while self._animation_time > time.perf_counter() - step: + # self._animation_time += step + # self._events.submit({"event_type": "animate", "step": step, "catch_up": 0}) + # # Do the animations. This costs time. + # self._events.flush() + # # Abort when we cannot keep up + # # todo: test this + # animation_iters += 1 + # if animation_iters > 20: + # n = (time.perf_counter() - self._animation_time) // step + # self._animation_time += step * n + # self._events.submit( + # {"event_type": "animate", "step": step * n, "catch_up": n} + # ) + + # === Scheduling and drawing + + def _draw_frame(self): + """The method to call to draw a frame. + + Cen be overriden by subclassing, or by passing a callable to request_draw(). """ pass def request_draw(self, draw_function=None): """Schedule a new draw event. - This function does not perform a draw directly, but schedules - a draw event at a suitable moment in time. In the draw event - the draw function is called, and the resulting rendered image - is presented to screen. + This function does not perform a draw directly, but schedules a draw at + a suitable moment in time. At that time the draw function is called, and + the resulting rendered image is presented to screen. + + Only affects drawing with schedule-mode 'ondemand'. Arguments: draw_function (callable or None): The function to set as the new draw @@ -152,238 +223,158 @@ def request_draw(self, draw_function=None): """ if draw_function is not None: - self.draw_frame = draw_function - self._request_draw() + self._draw_frame = draw_function + if self._scheduler is not None: + self._scheduler.request_draw() + + # -> Note that the draw func is likely to hold a ref to the canvas. By + # storing it here, the gc can detect this case, and its fine. However, + # this fails if we'd store _draw_frame on the scheduler! + + def force_draw(self): + """Perform a draw right now. + + In most cases you want to use ``request_draw()``. If you find yourself using + this, consider using a timer. Nevertheless, sometimes you just want to force + a draw right now. + """ + if self.__is_drawing: + raise RuntimeError("Cannot force a draw while drawing.") + self._force_draw() def _draw_frame_and_present(self): """Draw the frame and present the result. Errors are logged to the "wgpu" logger. Should be called by the - subclass at an appropriate time. + subclass at its draw event. """ - self._last_draw_time = time.perf_counter() - # Perform the user-defined drawing code. When this errors, - # we should report the error and then continue, otherwise we crash. - # Returns the result of the context's present() call or None. - with log_exception("Draw error"): - self.draw_frame() - with log_exception("Present error"): - if self._canvas_context: - return self._canvas_context.present() - - def _get_draw_wait_time(self): - """Get time (in seconds) to wait until the next draw in order to honour max_fps.""" - now = time.perf_counter() - target_time = self._last_draw_time + 1.0 / self._max_fps - return max(0, target_time - now) - - # Methods that must be overloaded - - def get_pixel_ratio(self): - """Get the float ratio between logical and physical pixels.""" - raise NotImplementedError() - - def get_logical_size(self): - """Get the logical size in float pixels.""" - raise NotImplementedError() - def get_physical_size(self): - """Get the physical size in integer pixels.""" - raise NotImplementedError() - - def set_logical_size(self, width, height): - """Set the window size (in logical pixels).""" - raise NotImplementedError() - - def set_title(self, title): - """Set the window title.""" - raise NotImplementedError() - - def close(self): - """Close the window.""" - pass - - def is_closed(self): - """Get whether the window is closed.""" - raise NotImplementedError() - - def _request_draw(self): - """GUI-specific implementation for ``request_draw()``. + # Re-entrent drawing is problematic. Let's actively prevent it. + if self.__is_drawing: + return + self.__is_drawing = True - * This should invoke a new draw at a later time. - * The call itself should return directly. - * Multiple calls should result in a single new draw. - * Preferably the ``max_fps`` and ``vsync`` are honored. + try: + # This method is called from the GUI layer. It can be called from a + # "draw event" that we requested, or as part of a forced draw. + + # Cannot draw to a closed canvas. + if self.is_closed(): + return + + # 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: + fps = self._scheduler.on_draw() + + # Maybe update title + if fps is not None: + self.__title_kwargs["fps"] = f"{fps:0.1f}" + if "$fps" in self.__raw_title: + self.set_title(self.__raw_title) + + # Perform the user-defined drawing code. When this errors, + # we should report the error and then continue, otherwise we crash. + with log_exception("Draw error"): + self._draw_frame() + with log_exception("Present error"): + # Note: we use canvas._canvas_context, so that if the draw_frame is a stub we also dont trigger creating a context. + # Note: if vsync is used, this call may wait a little (happens down at the level of the driver or OS) + context = self._canvas_context + if context: + context.present() + + finally: + self.__is_drawing = False + + def _get_loop(self): + """For the subclass to implement: + + Must return the global loop instance (WgpuLoop) for the canvas subclass, + or None for a canvas without scheduled draws. """ - raise NotImplementedError() - + return None -class WgpuAutoGui: - """Mixin class for canvases implementing autogui. + def _request_draw(self): + """For the subclass to implement: - This class provides a common API for handling events and registering - event handlers. It adds to :class:`WgpuCanvasBase ` - that interactive examples and applications can be written in a - generic way (no-GUI specific code). - """ + Request the GUI layer to perform a draw. Like requestAnimationFrame in + JS. The draw must be performed by calling _draw_frame_and_present(). + It's the responsibility for the canvas subclass to make sure that a draw + is made as soon as possible. - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._last_event_time = 0 - self._pending_events = {} - self._event_handlers = defaultdict(list) + 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. - def _get_event_wait_time(self): - """Calculate the time to wait for the next event dispatching. - - Used for rate-limited events. + The default implementation does nothing, which is equivalent to waiting + for a forced draw or a draw invoked by the GUI system. """ - rate = 75 # events per second - now = time.perf_counter() - target_time = self._last_event_time + 1.0 / rate - return max(0, target_time - now) - - def _handle_event_rate_limited( - self, event, call_later_func, match_keys, accum_keys - ): - """Alternative `to handle_event()` for events that must be rate-limited. - - If any of the ``match_keys`` keys of the new event differ from the currently - pending event, the old event is dispatched now. The ``accum_keys`` keys of - the current and new event are added together (e.g. to accumulate wheel delta). - - The (accumulated) event is handled in the following cases: - * When the timer runs out. - * When a non-rate-limited event is dispatched. - * When a rate-limited event of the same type is scheduled - that has different match_keys (e.g. modifiers changes). + pass - Subclasses that use this method must use ``_handle_event_and_flush()`` - where they would otherwise call ``handle_event()``, to preserve event order. - """ - event_type = event["event_type"] - event.setdefault("time_stamp", time.perf_counter()) - # We may need to emit the old event. Otherwise, we need to update the new one. - old = self._pending_events.get(event_type, None) - if old: - if any(event[key] != old[key] for key in match_keys): - self.handle_event(old) - else: - for key in accum_keys: - event[key] = old[key] + event[key] - # Make sure that we have scheduled a moment to handle events - if not self._pending_events: - call_later_func(self._get_event_wait_time(), self._handle_pending_events) - # Store the event object - self._pending_events[event_type] = event - - def _handle_event_and_flush(self, event): - """Call handle_event after flushing any pending (rate-limited) events.""" - event.setdefault("time_stamp", time.perf_counter()) - self._handle_pending_events() - self.handle_event(event) - - def _handle_pending_events(self): - """Handle any pending rate-limited events.""" - if self._pending_events: - events = self._pending_events.values() - self._last_event_time = time.perf_counter() - self._pending_events = {} - for ev in events: - self.handle_event(ev) - - def handle_event(self, event): - """Handle an incoming event. - - Subclasses can overload this method. Events include widget - resize, mouse/touch interaction, key events, and more. An event - is a dict with at least the key event_type. For details, see - https://jupyter-rfb.readthedocs.io/en/stable/events.html - - The default implementation dispatches the event to the - registered event handlers. + def _force_draw(self): + """For the subclass to implement: - Arguments: - event (dict): the event to handle. + Perform a synchronous draw. When it returns, the draw must have been done. + The default implementation just calls _draw_frame_and_present(). """ - # Collect callbacks - event_type = event.get("event_type") - callbacks = self._event_handlers[event_type] + self._event_handlers["*"] - # Dispatch - for _, callback in callbacks: - with log_exception(f"Error during handling {event['event_type']} event"): - if event.get("stop_propagation", False): - break - callback(event) - - def add_event_handler(self, *args, order=0): - """Register an event handler to receive events. - - Arguments: - callback (callable): The event handler. Must accept a single event argument. - *types (list of strings): A list of event types. - order (int): The order in which the handler is called. Lower numbers are called first. Default is 0. - - For the available events, see - https://jupyter-rfb.readthedocs.io/en/stable/events.html. + self._draw_frame_and_present() - 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 - destroying that object. + # === Primary canvas management methods - Example: + # todo: we require subclasses to implement public methods, while everywhere else the implementable-methods are private. - .. code-block:: py - - def my_handler(event): - print(event) - - canvas.add_event_handler(my_handler, "pointer_up", "pointer_down") - - Can also be used as a decorator: + def get_physical_size(self): + """Get the physical size in integer pixels.""" + raise NotImplementedError() - .. code-block:: py + def get_logical_size(self): + """Get the logical size in float pixels.""" + raise NotImplementedError() - @canvas.add_event_handler("pointer_up", "pointer_down") - def my_handler(event): - print(event) + def get_pixel_ratio(self): + """Get the float ratio between logical and physical pixels.""" + raise NotImplementedError() - Catch 'm all: + def close(self): + """Close the window.""" + pass - .. code-block:: py + def is_closed(self): + """Get whether the window is closed.""" + return False - canvas.add_event_handler(my_handler, "*") + # === Secondary canvas management methods - """ - decorating = not callable(args[0]) - callback = None if decorating else args[0] - types = args if decorating else args[1:] + # These methods provide extra control over the canvas. Subclasses should + # implement the methods they can, but these features are likely not critical. - if not types: - raise TypeError("No event types are given to add_event_handler.") - for type in types: - if not isinstance(type, str): - raise TypeError(f"Event types must be str, but got {type}") + def set_logical_size(self, width, height): + """Set the window size (in logical pixels).""" + pass - def decorator(_callback): - for type in types: - self._event_handlers[type].append((order, _callback)) - self._event_handlers[type].sort(key=lambda x: x[0]) - return _callback + def set_title(self, title): + """Set the window title.""" + self.__raw_title = title + for k, v in self.__title_kwargs.items(): + title = title.replace("$" + k, v) + self._set_title(title) - if decorating: - return decorator - return decorator(callback) + def _set_title(self, title): + pass - def remove_event_handler(self, callback, *types): - """Unregister an event handler. - Arguments: - callback (callable): The event handler. - *types (list of strings): A list of event types. - """ - for type in types: - self._event_handlers[type] = [ - (o, cb) for o, cb in self._event_handlers[type] if cb is not callback - ] +def pop_kwargs_for_base_canvas(kwargs_dict): + """Convenience functions for wrapper canvases like in Qt and wx.""" + code = WgpuCanvasBase.__init__.__code__ + base_kwarg_names = code.co_varnames[: code.co_argcount + code.co_kwonlyargcount] + d = {} + for key in base_kwarg_names: + if key in kwargs_dict: + d[key] = kwargs_dict.pop(key) + return d diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 54d2491..d116c51 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -11,11 +11,11 @@ import time import atexit import weakref -import asyncio import glfw -from .base import WgpuCanvasBase, WgpuAutoGui +from .base import WgpuCanvasBase +from .asyncio import AsyncioWgpuLoop from ._gui_utils import SYSTEM_IS_WAYLAND, weakbind, logger @@ -133,7 +133,7 @@ def get_glfw_present_info(window): "display": int(glfw.get_x11_display()), } else: - raise RuntimeError(f"Cannot get GLFW surafce info on {sys.platform}.") + raise RuntimeError(f"Cannot get GLFW surface info on {sys.platform}.") def get_physical_size(window): @@ -141,19 +141,20 @@ def get_physical_size(window): return int(psize[0]), int(psize[1]) -class GlfwWgpuCanvas(WgpuAutoGui, WgpuCanvasBase): +class GlfwWgpuCanvas(WgpuCanvasBase): """A glfw window providing a wgpu canvas.""" # See https://www.glfw.org/docs/latest/group__window.html def __init__(self, *, size=None, title=None, **kwargs): - app.init_glfw() + loop.init_glfw() super().__init__(**kwargs) # Handle inputs + if title is None: + title = "glfw canvas" if not size: size = 640, 480 - title = str(title or "glfw wgpu canvas") # Set window hints glfw.window_hint(glfw.CLIENT_API, glfw.NO_API) @@ -163,13 +164,11 @@ def __init__(self, *, size=None, title=None, **kwargs): self._window = glfw.create_window(int(size[0]), int(size[1]), title, None, None) # Other internal variables - self._need_draw = False - self._request_draw_timer_running = False self._changing_pixel_ratio = False self._is_minimized = False # Register ourselves - app.all_glfw_canvases.add(self) + loop.all_glfw_canvases.add(self) # Register callbacks. We may get notified too often, but that's # ok, they'll result in a single draw. @@ -197,8 +196,10 @@ def __init__(self, *, size=None, title=None, **kwargs): # Initialize the size self._pixel_ratio = -1 self._screen_size_is_logical = False + + # Apply incoming args via the proper route self.set_logical_size(*size) - self._request_draw() + self.set_title(title) # Callbacks to provide a minimal working canvas for wgpu @@ -210,11 +211,11 @@ def _on_pixelratio_change(self, *args): self._set_logical_size(self._logical_size) finally: self._changing_pixel_ratio = False - self._request_draw() + self.request_draw() def _on_size_change(self, *args): self._determine_size() - self._request_draw() + self.request_draw() def _check_close(self, *args): # Follow the close flow that glfw intended. @@ -224,25 +225,22 @@ def _check_close(self, *args): self._on_close() def _on_close(self, *args): - app.all_glfw_canvases.discard(self) + loop.all_glfw_canvases.discard(self) if self._window is not None: glfw.destroy_window(self._window) # not just glfw.hide_window self._window = None - self._handle_event_and_flush({"event_type": "close"}) + self.submit_event({"event_type": "close"}) def _on_window_dirty(self, *args): - self._request_draw() + self.request_draw() def _on_iconify(self, window, iconified): self._is_minimized = bool(iconified) + if not self._is_minimized: + self._request_draw() # helpers - def _mark_ready_for_draw(self): - self._request_draw_timer_running = False - self._need_draw = True # The event loop looks at this flag - glfw.post_empty_event() # Awake the event loop, if it's in wait-mode - def _determine_size(self): if self._window is None: return @@ -262,7 +260,7 @@ def _determine_size(self): "height": self._logical_size[1], "pixel_ratio": self._pixel_ratio, } - self._handle_event_and_flush(ev) + self.submit_event(ev) def _set_logical_size(self, new_logical_size): if self._window is None: @@ -302,6 +300,16 @@ def _set_logical_size(self, new_logical_size): # API + def _get_loop(self): + return loop + + def _request_draw(self): + if not self._is_minimized: + loop.call_soon(self._draw_frame_and_present) + + def _force_draw(self): + self._draw_frame_and_present() + def get_present_info(self): return get_glfw_present_info(self._window) @@ -319,14 +327,9 @@ def set_logical_size(self, width, height): raise ValueError("Window width and height must not be negative") self._set_logical_size((float(width), float(height))) - def set_title(self, title): + def _set_title(self, title): glfw.set_window_title(self._window, title) - def _request_draw(self): - if not self._request_draw_timer_running: - self._request_draw_timer_running = True - call_later(self._get_draw_wait_time(), self._mark_ready_for_draw) - def close(self): if self._window is not None: glfw.set_window_should_close(self._window, True) @@ -376,7 +379,7 @@ def _on_mouse_button(self, window, but, action, mods): } # Emit the current event - self._handle_event_and_flush(ev) + self.submit_event(ev) # Maybe emit a double-click event self._follow_double_click(action, button) @@ -428,7 +431,7 @@ def _follow_double_click(self, action, button): "ntouches": 0, # glfw does not have touch support "touches": {}, } - self._handle_event_and_flush(ev) + self.submit_event(ev) def _on_cursor_pos(self, window, x, y): # Store pointer position in logical coordinates @@ -448,9 +451,7 @@ def _on_cursor_pos(self, window, x, y): "touches": {}, } - match_keys = {"buttons", "modifiers", "ntouches"} - accum_keys = {} - self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys) + self.submit_event(ev) def _on_scroll(self, window, dx, dy): # wheel is 1 or -1 in glfw, in jupyter_rfb this is ~100 @@ -463,9 +464,7 @@ def _on_scroll(self, window, dx, dy): "buttons": tuple(self._pointer_buttons), "modifiers": tuple(self._key_modifiers), } - match_keys = {"modifiers"} - accum_keys = {"dx", "dy"} - self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys) + self.submit_event(ev) def _on_key(self, window, key, scancode, action, mods): modifier = KEY_MAP_MOD.get(key, None) @@ -505,7 +504,7 @@ def _on_key(self, window, key, scancode, action, mods): "key": keyname, "modifiers": tuple(self._key_modifiers), } - self._handle_event_and_flush(ev) + self.submit_event(ev) def _on_char(self, window, char): # Undocumented char event to make imgui work, see https://github.com/pygfx/wgpu-py/issues/530 @@ -514,7 +513,7 @@ def _on_char(self, window, char): "char_str": chr(char), "modifiers": tuple(self._key_modifiers), } - self._handle_event_and_flush(ev) + self.submit_event(ev) def present_image(self, image, **kwargs): raise NotImplementedError() @@ -527,13 +526,11 @@ def present_image(self, image, **kwargs): WgpuCanvas = GlfwWgpuCanvas -class AppState: - """Little container for state about the loop and glfw.""" - +class GlfwAsyncioWgpuLoop(AsyncioWgpuLoop): def __init__(self): + super().__init__() self.all_glfw_canvases = weakref.WeakSet() - self._loop = None - self.stop_if_no_more_canvases = False + self.stop_if_no_more_canvases = True self._glfw_initialized = False def init_glfw(self): @@ -542,59 +539,20 @@ def init_glfw(self): self._glfw_initialized = True atexit.register(glfw.terminate) - def get_loop(self): - if self._loop is None: - self._loop = self._get_loop() - self._loop.create_task(keep_glfw_alive()) - return self._loop - - def _get_loop(self): - try: - return asyncio.get_running_loop() - except Exception: - pass - try: - return asyncio.get_event_loop() - except RuntimeError: - pass - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - return loop - + def _wgpu_gui_poll(self): + glfw.post_empty_event() # Awake the event loop, if it's in wait-mode + glfw.poll_events() + if self.stop_if_no_more_canvases and not tuple(self.all_glfw_canvases): + self.stop() -app = AppState() + def _run(self): + super()._run() + if not self._is_interactive: + poll_glfw_briefly() -def update_glfw_canvasses(): - """Call this in your glfw event loop to draw each canvas that needs - an update. Returns the number of visible canvases. - """ - # Note that _draw_frame_and_present already catches errors, it can - # only raise errors if the logging system fails. - canvases = tuple(app.all_glfw_canvases) - for canvas in canvases: - if canvas._need_draw and not canvas._is_minimized: - canvas._need_draw = False - canvas._draw_frame_and_present() - return len(canvases) - - -async def keep_glfw_alive(): - """Co-routine that lives forever, keeping glfw going. - - Although it stops the event-loop if there are no more canvases (and we're - running the loop), this task stays active and continues when the loop is - restarted. - """ - # TODO: this is not particularly pretty. It'd be better to use normal asyncio to - # schedule draws and then also process events. But let's address when we do #355 / #391 - while True: - await asyncio.sleep(0.001) - glfw.poll_events() - n = update_glfw_canvasses() - if app.stop_if_no_more_canvases and not n: - loop = asyncio.get_running_loop() - loop.stop() +loop = GlfwAsyncioWgpuLoop() +run = loop.run # backwards compat def poll_glfw_briefly(poll_time=0.1): @@ -610,19 +568,3 @@ 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()) - - -def call_later(delay, callback, *args): - loop = app.get_loop() - loop.call_later(delay, callback, *args) - - -def run(): - loop = app.get_loop() - if loop.is_running(): - return # Interactive mode! - - app.stop_if_no_more_canvases = True - loop.run_forever() - app.stop_if_no_more_canvases = False - poll_glfw_briefly() diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index 082223a..5f22762 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -3,20 +3,18 @@ can be used as cell output, or embedded in an ipywidgets gui. """ +import time import weakref -import asyncio -from .base import WgpuAutoGui, WgpuCanvasBase +from .base import WgpuCanvasBase +from .asyncio import AsyncioWgpuLoop import numpy as np from jupyter_rfb import RemoteFrameBuffer from IPython.display import display -pending_jupyter_canvases = [] - - -class JupyterWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, RemoteFrameBuffer): +class JupyterWgpuCanvas(WgpuCanvasBase, RemoteFrameBuffer): """An ipywidgets widget providing a wgpu canvas. Needs the jupyter_rfb library.""" def __init__(self, *, size=None, title=None, **kwargs): @@ -27,10 +25,10 @@ def __init__(self, *, size=None, title=None, **kwargs): self._pixel_ratio = 1 self._logical_size = 0, 0 self._is_closed = False - self._request_draw_timer_running = False + self._draw_request_time = 0 # Register so this can be display'ed when run() is called - pending_jupyter_canvases.append(weakref.ref(self)) + loop._pending_jupyter_canvases.append(weakref.ref(self)) # Initialize size if size is not None: @@ -46,21 +44,29 @@ def handle_event(self, event): self._pixel_ratio = event["pixel_ratio"] self._logical_size = event["width"], event["height"] - # No need to rate-limit the pointer_move and wheel events; - # they're already rate limited by jupyter_rfb in the client. - super().handle_event(event) + self.submit_event(event) def get_frame(self): - self._request_draw_timer_running = False # The _draw_frame_and_present() does the drawing and then calls # 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 # Implementation needed for WgpuCanvasBase + def _get_loop(self): + return loop + def get_pixel_ratio(self): return self._pixel_ratio @@ -76,7 +82,7 @@ def set_logical_size(self, width, height): self.css_width = f"{width}px" self.css_height = f"{height}px" - def set_title(self, title): + def _set_title(self, title): pass # not supported yet def close(self): @@ -86,9 +92,18 @@ def is_closed(self): return self._is_closed def _request_draw(self): - if not self._request_draw_timer_running: - self._request_draw_timer_running = True - call_later(self._get_draw_wait_time(), RemoteFrameBuffer.request_draw, self) + self._draw_request_time = time.perf_counter() + RemoteFrameBuffer.request_draw(self) + + def _force_draw(self): + # A bit hacky to use the internals of jupyter_rfb this way. + # This pushes frames to the browser as long as the websocket + # buffer permits it. It works! + # But a better way would be `await canvas.wait_draw()`. + # Todo: would also be nice if jupyter_rfb had a public api for this. + array = self.get_frame() + if array is not None: + self._rfb_send_frame(array) # Implementation needed for WgpuCanvasInterface @@ -111,16 +126,22 @@ def present_image(self, image, **kwargs): WgpuCanvas = JupyterWgpuCanvas -def call_later(delay, callback, *args): - loop = asyncio.get_event_loop() - loop.call_later(delay, callback, *args) +class JupyterAsyncioWgpuLoop(AsyncioWgpuLoop): + def __init__(self): + super().__init__() + self._pending_jupyter_canvases = [] + + def _wgpu_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.is_closed(): + display(w) -def run(): - # 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 pending_jupyter_canvases] - pending_jupyter_canvases.clear() - for w in canvases: - if w and not w.is_closed(): - display(w) +loop = JupyterAsyncioWgpuLoop() diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index b9ce898..16c2d05 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -1,9 +1,7 @@ -import time +from .base import WgpuCanvasBase, WgpuLoop, WgpuTimer -from .base import WgpuCanvasBase, WgpuAutoGui - -class WgpuManualOffscreenCanvas(WgpuAutoGui, WgpuCanvasBase): +class WgpuManualOffscreenCanvas(WgpuCanvasBase): """An offscreen canvas intended for manual use. Call the ``.draw()`` method to perform a draw and get the result. @@ -13,7 +11,6 @@ def __init__(self, *args, size=None, pixel_ratio=1, title=None, **kwargs): super().__init__(*args, **kwargs) self._logical_size = (float(size[0]), float(size[1])) if size else (640, 480) self._pixel_ratio = pixel_ratio - self._title = title self._closed = False self._last_image = None @@ -40,7 +37,7 @@ def get_physical_size(self): def set_logical_size(self, width, height): self._logical_size = width, height - def set_title(self, title): + def _set_title(self, title): pass def close(self): @@ -49,10 +46,17 @@ def close(self): def is_closed(self): return self._closed + def _get_loop(self): + return None # No scheduling + def _request_draw(self): - # Deliberately a no-op, because people use .draw() instead. + # Ok, cool, the scheduler want a draw. But we only draw when the user + # calls draw(), so that's how this canvas ticks. pass + def _force_draw(self): + self._draw_frame_and_present() + def draw(self): """Perform a draw and get the resulting image. @@ -60,6 +64,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 self._draw_frame_and_present() return self._last_image @@ -67,33 +72,41 @@ def draw(self): WgpuCanvas = WgpuManualOffscreenCanvas -# If we consider the use-cases for using this offscreen canvas: -# -# * Using wgpu.gui.auto in test-mode: in this case run() should not hang, -# and call_later should not cause lingering refs. -# * Using the offscreen canvas directly, in a script: in this case you -# do not have/want an event system. -# * Using the offscreen canvas in an evented app. In that case you already -# have an app with a specific event-loop (it might be PySide6 or -# something else entirely). -# -# In summary, we provide a call_later() and run() that behave pretty -# well for the first case. - -_pending_calls = [] +class StubWgpuTimer(WgpuTimer): + def _start(self): + pass + def _stop(self): + pass -def call_later(delay, callback, *args): - # Note that this module never calls call_later() itself; request_draw() is a no-op. - etime = time.time() + delay - _pending_calls.append((etime, callback, args)) +class StubLoop(WgpuLoop): + # If we consider the use-cases for using this offscreen canvas: + # + # * Using rendercanvas.auto in test-mode: in this case run() should not hang, + # and call_later should not cause lingering refs. + # * Using the offscreen canvas directly, in a script: in this case you + # do not have/want an event system. + # * Using the offscreen canvas in an evented app. In that case you already + # have an app with a specific event-loop (it might be PySide6 or + # something else entirely). + # + # In summary, we provide a call_later() and run() that behave pretty + # well for the first case. + + _TimerClass = StubWgpuTimer # subclases must set this + + def _process_timers(self): + # Running this loop processes any timers + for timer in list(WgpuTimer._running_timers): + if timer.time_left <= 0: + timer._tick() + + def _run(self): + self._process_timers() + + def _stop(self): + pass -def run(): - # Process pending calls - for etime, callback, args in _pending_calls.copy(): - if time.time() >= etime: - callback(*args) - # Clear any leftover scheduled calls, to avoid lingering refs. - _pending_calls.clear() +loop = StubLoop() diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 0487391..b079862 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -7,13 +7,12 @@ import ctypes import importlib -from .base import WgpuCanvasBase, WgpuAutoGui +from .base import WgpuCanvasBase, WgpuLoop, WgpuTimer, pop_kwargs_for_base_canvas from ._gui_utils import ( logger, SYSTEM_IS_WAYLAND, get_alt_x11_display, get_alt_wayland_display, - weakbind, get_imported_qt_lib, ) @@ -42,7 +41,7 @@ Keys = QtCore.Qt else: raise ImportError( - "Before importing wgpu.gui.qt, import one of PySide6/PySide2/PyQt6/PyQt5 to select a Qt toolkit." + "Before importing rendercanvas.qt, import one of PySide6/PySide2/PyQt6/PyQt5 to select a Qt toolkit." ) @@ -140,14 +139,14 @@ def enable_hidpi(): ) -class QWgpuWidget(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): +class QWgpuWidget(WgpuCanvasBase, QtWidgets.QWidget): """A QWidget representing a wgpu canvas that can be embedded in a Qt application.""" def __init__(self, *args, present_method=None, **kwargs): super().__init__(*args, **kwargs) # Determine present method - self._surface_ids = self._get_surface_ids() + self._surface_ids = None if not present_method: self._present_to_screen = True if SYSTEM_IS_WAYLAND: @@ -164,6 +163,8 @@ def __init__(self, *args, present_method=None, **kwargs): else: raise ValueError(f"Invalid present_method {present_method}") + self._is_closed = False + self.setAttribute(WA_PaintOnScreen, self._present_to_screen) self.setAutoFillBackground(False) self.setAttribute(WA_DeleteOnClose, True) @@ -171,12 +172,6 @@ def __init__(self, *args, present_method=None, **kwargs): self.setMouseTracking(True) self.setFocusPolicy(FocusPolicy.StrongFocus) - # A timer for limiting fps - self._request_draw_timer = QtCore.QTimer() - self._request_draw_timer.setTimerType(PreciseTimer) - self._request_draw_timer.setSingleShot(True) - self._request_draw_timer.timeout.connect(self.update) - def paintEngine(self): # noqa: N802 - this is a Qt method # https://doc.qt.io/qt-5/qt.html#WidgetAttribute-enum WA_PaintOnScreen if self._present_to_screen: @@ -189,6 +184,20 @@ def paintEvent(self, event): # noqa: N802 - this is a Qt method # Methods that we add from wgpu (snake_case) + def _request_draw(self): + # Ask Qt to do a paint event + QtWidgets.QWidget.update(self) + + def _force_draw(self): + # Call the paintEvent right now. + # This works on all platforms I tested, except on MacOS when drawing with the 'image' method. + # Not sure why this is. It be made to work by calling processEvents() but that has all sorts + # of nasty side-effects (e.g. the scheduler timer keeps ticking, invoking other draws, etc.). + self.repaint() + + def _get_loop(self): + return loop + def _get_surface_ids(self): if sys.platform.startswith("win") or sys.platform.startswith("darwin"): return { @@ -208,9 +217,13 @@ def _get_surface_ids(self): "window": int(self.winId()), "display": int(get_alt_x11_display()), } + else: + raise RuntimeError(f"Cannot get Qt surface info on {sys.platform}.") def get_present_info(self): global _show_image_method_warning + if self._surface_ids is None: + self._surface_ids = self._get_surface_ids() if self._present_to_screen: info = {"method": "screen"} info.update(self._surface_ids) @@ -253,20 +266,24 @@ def get_physical_size(self): def set_logical_size(self, width, height): if width < 0 or height < 0: raise ValueError("Window width and height must not be negative") - self.resize(width, height) # See comment on pixel ratio - - def set_title(self, title): - self.setWindowTitle(title) + parent = self.parent() + if isinstance(parent, QWgpuCanvas): + parent.resize(width, height) + else: + self.resize(width, height) # See comment on pixel ratio - def _request_draw(self): - if not self._request_draw_timer.isActive(): - self._request_draw_timer.start(int(self._get_draw_wait_time() * 1000)) + def _set_title(self, title): + # A QWidgets title can actually be shown when the widget is shown in a dock. + # But the application should probably determine that title, not us. + parent = self.parent() + if isinstance(parent, QWgpuCanvas): + parent.setWindowTitle(title) def close(self): QtWidgets.QWidget.close(self) def is_closed(self): - return not self.isVisible() + return self._is_closed # User events to jupyter_rfb events @@ -282,7 +299,7 @@ def _key_event(self, event_type, event): "key": KEY_MAP.get(event.key(), event.text()), "modifiers": modifiers, } - self._handle_event_and_flush(ev) + self.submit_event(ev) def _char_input_event(self, char_str): ev = { @@ -290,7 +307,7 @@ def _char_input_event(self, char_str): "char_str": char_str, "modifiers": None, } - self._handle_event_and_flush(ev) + self.submit_event(ev) def keyPressEvent(self, event): # noqa: N802 self._key_event("key_down", event) @@ -335,12 +352,7 @@ def _mouse_event(self, event_type, event, touches=True): } ) - if event_type == "pointer_move": - match_keys = {"buttons", "modifiers", "ntouches"} - accum_keys = {} - self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys) - else: - self._handle_event_and_flush(ev) + self.submit_event(ev) def mousePressEvent(self, event): # noqa: N802 self._mouse_event("pointer_down", event) @@ -377,9 +389,7 @@ def wheelEvent(self, event): # noqa: N802 "buttons": buttons, "modifiers": modifiers, } - match_keys = {"modifiers"} - accum_keys = {"dx", "dy"} - self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys) + self.submit_event(ev) def resizeEvent(self, event): # noqa: N802 ev = { @@ -388,15 +398,23 @@ def resizeEvent(self, event): # noqa: N802 "height": float(event.size().height()), "pixel_ratio": self.get_pixel_ratio(), } - self._handle_event_and_flush(ev) + self.submit_event(ev) def closeEvent(self, event): # noqa: N802 - self._handle_event_and_flush({"event_type": "close"}) + self._is_closed = True + self.submit_event({"event_type": "close"}) + + # Methods related to presentation of resulting image data def present_image(self, image_data, **kwargs): size = image_data.shape[1], image_data.shape[0] # width, height + rect1 = QtCore.QRect(0, 0, size[0], size[1]) + rect2 = self.rect() painter = QtGui.QPainter(self) + # backingstore = self.backingStore() + # backingstore.beginPaint(rect2) + # painter = QtGui.QPainter(backingstore.paintDevice()) # We want to simply blit the image (copy pixels one-to-one on framebuffer). # Maybe Qt does this when the sizes match exactly (like they do here). @@ -416,8 +434,6 @@ def present_image(self, image_data, **kwargs): QtGui.QImage.Format.Format_RGBA8888, ) - rect1 = QtCore.QRect(0, 0, size[0], size[1]) - rect2 = self.rect() painter.drawImage(rect2, image, rect1) # Uncomment for testing purposes @@ -425,8 +441,12 @@ def present_image(self, image_data, **kwargs): # painter.setFont(QtGui.QFont("Arial", 30)) # painter.drawText(100, 100, "This is an image") + painter.end() + # backingstore.endPaint() + # backingstore.flush(rect2) + -class QWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): +class QWgpuCanvas(WgpuCanvasBase, QtWidgets.QWidget): """A toplevel Qt widget providing a wgpu canvas.""" # Most of this is proxying stuff to the inner widget. @@ -434,23 +454,25 @@ class QWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): # size can be set to subpixel (logical) values, without being able to # detect this. See https://github.com/pygfx/wgpu-py/pull/68 - def __init__( - self, *, size=None, title=None, max_fps=30, present_method=None, **kwargs - ): + def __init__(self, *, size=None, title=None, **kwargs): # When using Qt, there needs to be an # application before any widget is created - get_app() + loop.init_qt() + + sub_kwargs = pop_kwargs_for_base_canvas(kwargs) super().__init__(**kwargs) + # Handle inputs + if title is None: + title = "qt canvas" + if not size: + size = 640, 480 + self.setAttribute(WA_DeleteOnClose, True) - self.set_logical_size(*(size or (640, 480))) - self.setWindowTitle(title or "qt wgpu canvas") self.setMouseTracking(True) - self._subwidget = QWgpuWidget( - self, max_fps=max_fps, present_method=present_method - ) - self._subwidget.add_event_handler(weakbind(self.handle_event), "*") + self._subwidget = QWgpuWidget(self, **sub_kwargs) + self._events = self._subwidget._events # Note: At some point we called `self._subwidget.winId()` here. For some # reason this was needed to "activate" the canvas. Otherwise the viz was @@ -462,23 +484,26 @@ def __init__( self.setLayout(layout) layout.addWidget(self._subwidget) + self.set_logical_size(*size) + self.set_title(title) self.show() # Qt methods - def update(self): - super().update() - self._subwidget.update() + def closeEvent(self, event): # noqa: N802 + self._subwidget._is_closed = True + self.submit_event({"event_type": "close"}) # Methods that we add from wgpu (snake_case) - @property - def draw_frame(self): - return self._subwidget.draw_frame + def _request_draw(self): + self._subwidget._request_draw() - @draw_frame.setter - def draw_frame(self, f): - self._subwidget.draw_frame = f + def _force_draw(self): + self._subwidget._force_draw() + + def _get_loop(self): + return None # This means this outer widget won't have a scheduler def get_present_info(self): return self._subwidget.get_present_info() @@ -497,21 +522,17 @@ def set_logical_size(self, width, height): raise ValueError("Window width and height must not be negative") self.resize(width, height) # See comment on pixel ratio - def set_title(self, title): - self.setWindowTitle(title) - - def _request_draw(self): - return self._subwidget._request_draw() - def close(self): - self._subwidget.close() QtWidgets.QWidget.close(self) def is_closed(self): - return not self.isVisible() + return self._subwidget.is_closed() # Methods that we need to explicitly delegate to the subwidget + def set_title(self, *args): + self._subwidget.set_title(*args) + def get_context(self, *args, **kwargs): return self._subwidget.get_context(*args, **kwargs) @@ -527,20 +548,64 @@ def present_image(self, image, **kwargs): WgpuCanvas = QWgpuCanvas -def get_app(): - """Return global instance of Qt app instance or create one if not created yet.""" - return QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) +class QtWgpuTimer(WgpuTimer): + """Wgpu timer basef on Qt.""" + + def _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 _start(self): + self._qt_timer.start(int(self._interval * 1000)) + + def _stop(self): + self._qt_timer.stop() + + +class QtWgpuLoop(WgpuLoop): + _TimerClass = QtWgpuTimer + + 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.""" + return QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + + def _call_soon(self, callback, *args): + func = callback + if args: + func = lambda: callback(*args) + QtCore.QTimer.singleshot(0, func) + + def _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 run(): - if already_had_app_on_import: - return # Likely in an interactive session or larger application that will start the Qt app. - app = get_app() + def _stop(self): + if not already_had_app_on_import: + self._app.quit() - # todo: we could detect if asyncio is running (interactive session) and wheter we can use QtAsyncio. - # But let's wait how things look with new scheduler etc. - app.exec() if hasattr(app, "exec") else app.exec_() + def _wgpu_gui_poll(self): + pass # we assume the Qt event loop is running. Calling processEvents() will cause recursive repaints. -def call_later(delay, callback, *args): - QtCore.QTimer.singleShot(int(delay * 1000), lambda: callback(*args)) +loop = QtWgpuLoop() +run = loop.run # backwards compat diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index f314d24..091222b 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -3,8 +3,9 @@ can be used as a standalone window or in a larger GUI. """ -import ctypes import sys +import time +import ctypes from typing import Optional import wx @@ -14,9 +15,8 @@ SYSTEM_IS_WAYLAND, get_alt_x11_display, get_alt_wayland_display, - weakbind, ) -from .base import WgpuCanvasBase, WgpuAutoGui +from .base import WgpuCanvasBase, WgpuLoop, WgpuTimer, pop_kwargs_for_base_canvas BUTTON_MAP = { @@ -120,26 +120,14 @@ 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 WxWgpuWindow has been deleted - - -class WxWgpuWindow(WgpuAutoGui, WgpuCanvasBase, wx.Window): +class WxWgpuWindow(WgpuCanvasBase, wx.Window): """A wx Window representing a wgpu canvas that can be embedded in a wx application.""" def __init__(self, *args, present_method=None, **kwargs): super().__init__(*args, **kwargs) # Determine present method - self._surface_ids = self._get_surface_ids() + self._surface_ids = None if not present_method: self._present_to_screen = True if SYSTEM_IS_WAYLAND: @@ -152,8 +140,7 @@ def __init__(self, *args, present_method=None, **kwargs): else: raise ValueError(f"Invalid present_method {present_method}") - # A timer for limiting fps - self._request_draw_timer = TimerWithCallback(self.Refresh) + self._is_closed = False # We keep a timer to prevent draws during a resize. This prevents # issues with mismatching present sizes during resizing (on Linux). @@ -170,6 +157,11 @@ def __init__(self, *args, present_method=None, **kwargs): self.Bind(wx.EVT_MOUSE_EVENTS, self._on_mouse_events) self.Bind(wx.EVT_MOTION, self._on_mouse_move) + self.Show() + + def _get_loop(self): + return loop + def on_paint(self, event): dc = wx.PaintDC(self) # needed for wx if not self._draw_lock: @@ -189,11 +181,11 @@ def _on_resize(self, event: wx.SizeEvent): "height": float(size.GetHeight()), "pixel_ratio": self.get_pixel_ratio(), } - self._handle_event_and_flush(ev) + self.submit_event(ev) def _on_resize_done(self, *args): self._draw_lock = False - self._request_draw() + self.Refresh() # Methods for input events @@ -220,7 +212,7 @@ def _key_event(self, event_type: str, event: wx.KeyEvent, char_str: Optional[str "key": KEY_MAP.get(event.GetKeyCode(), char_str), "modifiers": modifiers, } - self._handle_event_and_flush(ev) + self.submit_event(ev) def _char_input_event(self, char_str: Optional[str]): if char_str is None: @@ -231,7 +223,7 @@ def _char_input_event(self, char_str: Optional[str]): "char_str": char_str, "modifiers": None, } - self._handle_event_and_flush(ev) + self.submit_event(ev) @staticmethod def _get_char_from_event(event: wx.KeyEvent) -> Optional[str]: @@ -300,19 +292,11 @@ def _mouse_event(self, event_type: str, event: wx.MouseEvent, touches: bool = Tr ev.update({"dx": -dx, "dy": -dy}) - match_keys = {"modifiers"} - accum_keys = {"dx", "dy"} - self._handle_event_rate_limited( - ev, self._call_later, match_keys, accum_keys - ) + self.submit_event(ev) elif event_type == "pointer_move": - match_keys = {"buttons", "modifiers", "ntouches"} - accum_keys = {} - self._handle_event_rate_limited( - ev, self._call_later, match_keys, accum_keys - ) + self.submit_event(ev) else: - self._handle_event_and_flush(ev) + self.submit_event(ev) def _on_mouse_events(self, event: wx.MouseEvent): event_type = event.GetEventType() @@ -348,9 +332,11 @@ def _get_surface_ids(self): "display": int(get_alt_x11_display()), } else: - raise RuntimeError(f"Cannot get Qt surafce info on {sys.platform}.") + raise RuntimeError(f"Cannot get wx surface info on {sys.platform}.") def get_present_info(self): + if self._surface_ids is None: + self._surface_ids = self._get_surface_ids() global _show_image_method_warning if self._present_to_screen and self._surface_ids: info = {"method": "screen"} @@ -384,24 +370,36 @@ def get_physical_size(self): def set_logical_size(self, width, height): if width < 0 or height < 0: raise ValueError("Window width and height must not be negative") - self.SetSize(width, height) + parent = self.Parent + if isinstance(parent, WxWgpuCanvas): + parent.SetSize(width, height) + else: + self.SetSize(width, height) - def set_title(self, title): - pass # only on frames + def _set_title(self, title): + # Set title only on frame + parent = self.Parent + if isinstance(parent, WxWgpuCanvas): + parent.SetTitle(title) def _request_draw(self): - # Despite the FPS limiting the delayed call to refresh solves - # that drawing only happens when the mouse is down, see #209. - if not self._request_draw_timer.IsRunning(): - self._request_draw_timer.Start( - max(1, int(self._get_draw_wait_time() * 1000)), wx.TIMER_ONE_SHOT - ) + if self._draw_lock: + return + try: + self.Refresh() + except Exception: + pass # avoid errors when window no longer lives + + def _force_draw(self): + self.Refresh() + self.Update() def close(self): + self._is_closed = True self.Hide() def is_closed(self): - return not self.IsShown() + return self._is_closed @staticmethod def _call_later(delay, callback, *args): @@ -419,7 +417,7 @@ def present_image(self, image_data, **kwargs): dc.DrawBitmap(bitmap, 0, 0, False) -class WxWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, wx.Frame): +class WxWgpuCanvas(WgpuCanvasBase, wx.Frame): """A toplevel wx Frame providing a wgpu canvas.""" # Most of this is proxying stuff to the inner widget. @@ -430,31 +428,42 @@ def __init__( parent=None, size=None, title=None, - max_fps=30, - present_method=None, **kwargs, ): - get_app() + loop.init_wx() + sub_kwargs = pop_kwargs_for_base_canvas(kwargs) super().__init__(parent, **kwargs) - self.set_logical_size(*(size or (640, 480))) - self.SetTitle(title or "wx wgpu canvas") + # Handle inputs + if title is None: + title = "wx canvas" + if not size: + size = 640, 480 - self._subwidget = WxWgpuWindow( - parent=self, max_fps=max_fps, present_method=present_method - ) - self._subwidget.add_event_handler(weakbind(self.handle_event), "*") + self._subwidget = WxWgpuWindow(parent=self, **sub_kwargs) + self._events = self._subwidget._events self.Bind(wx.EVT_CLOSE, lambda e: self.Destroy()) self.Show() + # Force the canvas to be shown, so that it gets a valid handle. + # Otherwise GetHandle() is initially 0, and getting a surface will fail. + etime = time.perf_counter() + 1 + while self._subwidget.GetHandle() == 0 and time.perf_counter() < etime: + loop.process_wx_events() + + self.set_logical_size(*size) + self.set_title(title) + # wx methods - def Refresh(self): # noqa: N802 - super().Refresh() - self._subwidget.Refresh() + def Destroy(self): # noqa: N802 - this is a wx method + self._subwidget._is_closed = True + super().Destroy() # Methods that we add from wgpu + def _get_loop(self): + return None # wrapper widget does not have scheduling def get_present_info(self): return self._subwidget.get_present_info() @@ -474,17 +483,20 @@ def set_logical_size(self, width, height): self.SetSize(width, height) def set_title(self, title): - self.SetTitle(title) + self._subwiget.set_title(title) def _request_draw(self): return self._subwidget._request_draw() + def _force_draw(self): + return self._subwidget._force_draw() + def close(self): - self._handle_event_and_flush({"event_type": "close"}) - super().close() + self._subwidget._is_closed = True + super().Close() def is_closed(self): - return not self.isVisible() + return self._subwidget._is_closed # Methods that we need to explicitly delegate to the subwidget @@ -502,18 +514,68 @@ def present_image(self, image, **kwargs): WgpuWidget = WxWgpuWindow WgpuCanvas = WxWgpuCanvas -_the_app = None +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 WxWgpuWindow has been deleted + + +class WxWgpuTimer(WgpuTimer): + def _init(self): + self._wx_timer = TimerWithCallback(self._tick) + + def _start(self): + self._wx_timer.StartOnce(int(self._interval * 1000)) + + def _stop(self): + self._wx_timer.Stop() + + +class WxWgpuLoop(WgpuLoop): + _TimerClass = WxWgpuTimer + _the_app = None + _frame_to_keep_loop_alive = 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 _call_soon(self, delay, callback, *args): + wx.CallSoon(callback, args) + + def _run(self): + self._frame_to_keep_loop_alive = wx.Frame(None) + self._app.MainLoop() + + def _stop(self): + self._frame_to_keep_loop_alive.Destroy() + _frame_to_keep_loop_alive = None + + def _wgpu_gui_poll(self): + pass # We can assume the wx loop is running. -def get_app(): - global _the_app - app = wx.App.GetInstance() - if app is None: - print("zxc") - _the_app = app = wx.App() - wx.App.SetInstance(app) - return app + def process_wx_events(self): + old = wx.GUIEventLoop.GetActive() + new = wx.GUIEventLoop() + wx.GUIEventLoop.SetActive(new) + while new.Pending(): + new.Dispatch() + wx.GUIEventLoop.SetActive(old) -def run(): - get_app().MainLoop() +loop = WxWgpuLoop() +run = loop.run # backwards compat diff --git a/tests/test_gui_auto_offscreen.py b/tests/test_gui_auto_offscreen.py deleted file mode 100644 index e01ef8a..0000000 --- a/tests/test_gui_auto_offscreen.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Test the force offscreen auto gui mechanism. -""" - -import os -import gc -import weakref - -import wgpu -from pytest import fixture, skip -from testutils import can_use_wgpu_lib, is_pypy - - -if not can_use_wgpu_lib: - skip("Skipping tests that need the wgpu lib", allow_module_level=True) - - -@fixture(autouse=True, scope="module") -def force_offscreen(): - os.environ["WGPU_FORCE_OFFSCREEN"] = "true" - try: - yield - finally: - del os.environ["WGPU_FORCE_OFFSCREEN"] - - -def test_canvas_class(): - """Check if we get an offscreen canvas when the WGPU_FORCE_OFFSCREEN - environment variable is set.""" - from wgpu.gui.auto import WgpuCanvas - from wgpu.gui.offscreen import WgpuManualOffscreenCanvas - - assert WgpuCanvas is WgpuManualOffscreenCanvas - assert issubclass(WgpuCanvas, wgpu.gui.WgpuCanvasBase) - assert issubclass(WgpuCanvas, wgpu.gui.WgpuAutoGui) - - -def test_event_loop(): - """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 wgpu.gui.auto import run, call_later - - ran = False - - def check(): - nonlocal ran - ran = True - - call_later(0, check) - run() - - assert ran - - -def test_offscreen_canvas_del(): - from wgpu.gui.offscreen import WgpuCanvas - - canvas = WgpuCanvas() - ref = weakref.ref(canvas) - - assert ref() is not None - del canvas - if is_pypy: - gc.collect() - assert ref() is None diff --git a/tests/test_gui_base.py b/tests/test_gui_base.py index 7174eba..ed439d7 100644 --- a/tests/test_gui_base.py +++ b/tests/test_gui_base.py @@ -1,23 +1,42 @@ """ -Test the canvas basics. +Test the base canvas class. """ -import gc import sys import subprocess import numpy as np -import wgpu.gui -from testutils import run_tests, can_use_wgpu_lib, is_pypy -from pytest import mark, raises +import rendercanvas +from testutils import run_tests, can_use_wgpu_lib +from pytest import mark -class TheTestCanvas(wgpu.gui.WgpuCanvasBase): +def test_base_canvas_context(): + assert hasattr(rendercanvas.WgpuCanvasInterface, "get_context") + + +def test_canvas_get_context_needs_backend_to_be_selected(): + code = "from rendercanvas import WgpuCanvasBase; canvas = WgpuCanvasBase(); canvas.get_context()" + + result = subprocess.run( + [sys.executable, "-c", code], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + ) + out = result.stdout.rstrip() + + assert "RuntimeError" in out + assert "backend must be selected" in out.lower() + assert "canvas.get_context" in out.lower() + + +class CanvasThatRaisesErrorsDuringDrawing(rendercanvas.WgpuCanvasBase): def __init__(self): super().__init__() self._count = 0 - def draw_frame(self): + def _draw_frame(self): self._count += 1 if self._count <= 4: self.foo_method() @@ -35,22 +54,13 @@ def spam_method(self): raise Exception(msg) -def test_base_canvas_context(): - assert not issubclass(wgpu.gui.WgpuCanvasInterface, wgpu.GPUCanvasContext) - assert hasattr(wgpu.gui.WgpuCanvasInterface, "get_context") - canvas = wgpu.gui.WgpuCanvasInterface() - # Cannot instantiate, because get_present_info is not implemented - with raises(NotImplementedError): - wgpu.GPUCanvasContext(canvas) - - def test_canvas_logging(caplog): """As we attempt to draw, the canvas will error, which are logged. Each first occurrence is logged with a traceback. Subsequent same errors are much shorter and have a counter. """ - canvas = TheTestCanvas() + canvas = CanvasThatRaisesErrorsDuringDrawing() canvas._draw_frame_and_present() # prints traceback canvas._draw_frame_and_present() # prints short logs ... @@ -81,7 +91,7 @@ def test_canvas_logging(caplog): assert text.count("intended-fail") == 4 -class MyOffscreenCanvas(wgpu.gui.WgpuCanvasBase): +class MyOffscreenCanvas(rendercanvas.WgpuCanvasBase): def __init__(self): super().__init__() self.frame_count = 0 @@ -106,10 +116,6 @@ def get_logical_size(self): def get_physical_size(self): return self.physical_size - def _request_draw(self): - # Note: this would normally schedule a call in a later event loop iteration - self._draw_frame_and_present() - @mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") def test_run_bare_canvas(): @@ -117,37 +123,22 @@ def test_run_bare_canvas(): # This is (more or less) the equivalent of: # - # from wgpu.gui.auto import WgpuCanvas, run + # from rendercanvas.auto import WgpuCanvas, loop # canvas = WgpuCanvas() - # run() + # loop.run() # - # Note: run() calls _draw_frame_and_present() in event loop. + # Note: loop.run() calls _draw_frame_and_present() in event loop. canvas = MyOffscreenCanvas() canvas._draw_frame_and_present() -def test_canvas_context_not_base(): - """Check that it is prevented that canvas context is instance of base context class.""" - code = "from wgpu.gui import WgpuCanvasBase; canvas = WgpuCanvasBase(); canvas.get_context()" - - result = subprocess.run( - [sys.executable, "-c", code], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - universal_newlines=True, - ) - out = result.stdout.rstrip() - - assert "RuntimeError" in out - assert "backend must be selected" in out.lower() - assert "canvas.get_context" in out.lower() - - @mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") -def test_offscreen_canvas(): +def test_simple_offscreen_canvas(): + import wgpu + canvas = MyOffscreenCanvas() - device = wgpu.utils.get_default_device() + device = wgpu.gpu.request_adapter_sync().request_device_sync() present_context = canvas.get_context() present_context.configure(device=device, format=None) @@ -174,14 +165,16 @@ def draw_frame(): assert canvas.frame_count == 0 - # Draw 1 canvas.request_draw(draw_frame) + + # Draw 1 + canvas.force_draw() assert canvas.array.shape == (100, 100, 4) assert np.all(canvas.array[:, :, 0] == 0) assert np.all(canvas.array[:, :, 1] == 255) # Draw 2 - canvas.request_draw(draw_frame) + canvas.force_draw() assert canvas.array.shape == (100, 100, 4) assert np.all(canvas.array[:, :, 0] == 0) assert np.all(canvas.array[:, :, 1] == 255) @@ -190,7 +183,7 @@ def draw_frame(): canvas.physical_size = 120, 100 # Draw 3 - canvas.request_draw(draw_frame) + canvas.force_draw() assert canvas.array.shape == (100, 120, 4) assert np.all(canvas.array[:, :, 0] == 0) assert np.all(canvas.array[:, :, 1] == 255) @@ -199,7 +192,7 @@ def draw_frame(): canvas.physical_size = 120, 140 # Draw 4 - canvas.request_draw(draw_frame) + canvas.force_draw() assert canvas.array.shape == (140, 120, 4) assert np.all(canvas.array[:, :, 0] == 0) assert np.all(canvas.array[:, :, 1] == 255) @@ -208,67 +201,22 @@ def draw_frame(): assert canvas.frame_count == 4 -def test_autogui_mixin(): - c = wgpu.gui.WgpuAutoGui() - - # It's a mixin - assert not isinstance(c, wgpu.gui.WgpuCanvasBase) +def test_canvas_base_events(): + c = rendercanvas.WgpuCanvasBase() - # It's event handling mechanism should be fully functional + # We test events extensively in another test module. This is just + # to make sure that events are working for the base canvas. events = [] def handler(event): events.append(event["value"]) - c.add_event_handler(handler, "foo", "bar") - c.handle_event({"event_type": "foo", "value": 1}) - c.handle_event({"event_type": "bar", "value": 2}) - c.handle_event({"event_type": "spam", "value": 3}) - c.remove_event_handler(handler, "foo") - c.handle_event({"event_type": "foo", "value": 4}) - c.handle_event({"event_type": "bar", "value": 5}) - c.handle_event({"event_type": "spam", "value": 6}) - c.remove_event_handler(handler, "bar") - c.handle_event({"event_type": "foo", "value": 7}) - c.handle_event({"event_type": "bar", "value": 8}) - c.handle_event({"event_type": "spam", "value": 9}) - - assert events == [1, 2, 5] - - -def test_weakbind(): - weakbind = wgpu.gui._gui_utils.weakbind - - xx = [] - - class Foo: - def bar(self): - xx.append(1) - - f1 = Foo() - f2 = Foo() - - b1 = f1.bar - b2 = weakbind(f2.bar) - - assert len(xx) == 0 - b1() - assert len(xx) == 1 - b2() - assert len(xx) == 2 - - del f1 - del f2 - - if is_pypy: - gc.collect() - - assert len(xx) == 2 - b1() - assert len(xx) == 3 # f1 still exists - b2() - assert len(xx) == 3 # f2 is gone! + 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() + assert events == [1, 2] if __name__ == "__main__": diff --git a/tests/test_gui_events.py b/tests/test_gui_events.py new file mode 100644 index 0000000..461f991 --- /dev/null +++ b/tests/test_gui_events.py @@ -0,0 +1,341 @@ +""" +Test the EventEmitter. +""" + +import time + +from rendercanvas._events import EventEmitter, WgpuEventType +from testutils import run_tests +import pytest + + +def test_events_event_types(): + ee = EventEmitter() + + def handler(event): + pass + + # All these are valid + valid_types = list(WgpuEventType) + ee.add_handler(handler, *valid_types) + + # This is not + with pytest.raises(ValueError): + ee.add_handler(handler, "not_a_valid_event_type") + + # This is why we use key events below :) + + +def test_events_basic(): + ee = EventEmitter() + + values = [] + + def handler(event): + values.append(event["value"]) + + ee.add_handler(handler, "key_down") + + ee.submit({"event_type": "key_down", "value": 1}) + ee.submit({"event_type": "key_down", "value": 2}) + assert values == [] + + ee.flush() + ee.submit({"event_type": "key_down", "value": 3}) + assert values == [1, 2] + + ee.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() + assert values == [1, 2, 3] + + +def test_events_handler_arg_position(): + ee = EventEmitter() + + def handler(event): + pass + + with pytest.raises(TypeError): + ee.add_handler("key_down", "key_up", handler) + + with pytest.raises(TypeError): + ee.add_handler("key_down", handler, "key_up") + + +def test_events_handler_decorated(): + ee = EventEmitter() + + values = [] + + @ee.add_handler("key_down", "key_up") + def handler(event): + values.append(event["value"]) + + ee.submit({"event_type": "key_down", "value": 1}) + ee.submit({"event_type": "key_up", "value": 2}) + ee.flush() + assert values == [1, 2] + + +def test_direct_emit_(): + ee = EventEmitter() + + values = [] + + @ee.add_handler("key_down", "key_up") + def handler(event): + values.append(event["value"]) + + ee.submit({"event_type": "key_down", "value": 1}) + ee.flush() + ee.submit({"event_type": "key_up", "value": 2}) + ee.emit({"event_type": "key_up", "value": 3}) # goes before pending events + ee.submit({"event_type": "key_up", "value": 4}) + ee.flush() + ee.submit({"event_type": "key_up", "value": 5}) + + assert values == [1, 3, 2, 4] + + +def test_events_two_types(): + ee = EventEmitter() + + values = [] + + def handler(event): + values.append(event["value"]) + + ee.add_handler(handler, "key_down", "key_up") + + ee.submit({"event_type": "key_down", "value": 1}) + ee.submit({"event_type": "key_up", "value": 2}) + ee.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() + 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() + assert values == [1, 2, 4] + + +def test_events_two_handlers(): + ee = EventEmitter() + + values = [] + + def handler1(event): + values.append(100 + event["value"]) + + def handler2(event): + values.append(200 + event["value"]) + + ee.add_handler(handler1, "key_down") + ee.add_handler(handler2, "key_down") + + ee.submit({"event_type": "key_down", "value": 1}) + ee.flush() + assert values == [101, 201] + + ee.remove_handler(handler1, "key_down") + ee.submit({"event_type": "key_down", "value": 2}) + ee.flush() + assert values == [101, 201, 202] + + ee.remove_handler(handler2, "key_down") + ee.submit({"event_type": "key_down", "value": 3}) + ee.flush() + assert values == [101, 201, 202] + + +def test_events_handler_order(): + ee = EventEmitter() + + values = [] + + def handler1(event): + values.append(100 + event["value"]) + + def handler2(event): + values.append(200 + event["value"]) + + def handler3(event): + values.append(300 + event["value"]) + + # Handlers are called in the order they were added. + # This is what most systems use. Except Vispy (and therefore Napari), + # which causes them a lot of trouble: + # https://github.com/vispy/vispy/blob/af84742/vispy/util/event.py#L263-L264 + # https://github.com/napari/napari/pull/7150 + # https://github.com/napari/napari-animation/pull/234 + ee.add_handler(handler1, "key_down") + ee.add_handler(handler2, "key_down") + ee.add_handler(handler3, "key_down") + + ee.submit({"event_type": "key_down", "value": 1}) + ee.flush() + assert values == [101, 201, 301] + + # Now re-connect with priorities + values.clear() + ee.add_handler(handler1, "key_down", order=0) # default + ee.add_handler(handler2, "key_down", order=2) + ee.add_handler(handler3, "key_down", order=1) + + ee.submit({"event_type": "key_down", "value": 1}) + ee.flush() + assert values == [101, 301, 201] + + # Another run using negative priorities too + values.clear() + ee.add_handler(handler1, "key_down", order=1) # default + ee.add_handler(handler2, "key_down", order=-2) + ee.add_handler(handler3, "key_down", order=-1) + + ee.submit({"event_type": "key_down", "value": 1}) + ee.flush() + assert values == [201, 301, 101] + + # Use floats! + values.clear() + ee.add_handler(handler1, "key_down", order=0.33) # default + ee.add_handler(handler2, "key_down", order=0.22) + ee.add_handler(handler3, "key_down", order=0.11) + + ee.submit({"event_type": "key_down", "value": 1}) + ee.flush() + assert values == [301, 201, 101] + + +def test_events_duplicate_handler(): + ee = EventEmitter() + + values = [] + + def handler(event): + values.append(event["value"]) + + # Registering for the same event_type twice just adds it once + ee.add_handler(handler, "key_down") + ee.add_handler(handler, "key_down") + + ee.submit({"event_type": "key_down", "value": 1}) + ee.flush() + assert values == [1] + + ee.remove_handler(handler, "key_down") + ee.submit({"event_type": "key_down", "value": 2}) + ee.flush() + assert values == [1] + + +def test_events_duplicate_handler_with_lambda(): + ee = EventEmitter() + + values = [] + + def handler(event): + values.append(event["value"]) + + # Cannot discern now, these are two different handlers + ee.add_handler(lambda e: handler(e), "key_down") + ee.add_handler(lambda e: handler(e), "key_down") + + ee.submit({"event_type": "key_down", "value": 1}) + ee.flush() + assert values == [1, 1] + + ee.remove_handler(handler, "key_down") + ee.submit({"event_type": "key_down", "value": 2}) + ee.flush() + assert values == [1, 1, 2, 2] + + +def test_merging_events(): + ee = EventEmitter() + + events = [] + + @ee.add_handler("resize", "wheel", "pointer_move", "key_down") + def handler(event): + events.append(event) + + ee.submit({"event_type": "resize", "width": 100}) + ee.submit({"event_type": "resize", "width": 102}) + ee.submit({"event_type": "resize", "width": 104}) + + ee.submit({"event_type": "wheel", "dx": 1, "dy": 0}) + ee.submit({"event_type": "wheel", "dx": 1, "dy": 0}) + ee.submit({"event_type": "wheel", "dx": 3, "dy": 0}) + + ee.submit({"event_type": "pointer_move", "x": 120, "modifiers": ()}) + ee.submit({"event_type": "pointer_move", "x": 122, "modifiers": ()}) + ee.submit({"event_type": "pointer_move", "x": 123, "modifiers": ()}) + + ee.submit({"event_type": "pointer_move", "x": 125, "modifiers": ("Ctrl")}) + + ee.submit({"event_type": "resize", "width": 106}) + ee.submit({"event_type": "resize", "width": 108}) + + ee.submit({"event_type": "key_down", "value": 1}) + ee.submit({"event_type": "key_down", "value": 2}) + + ee.flush() + + assert len(events) == 7 + + # First three event types are merges + assert events[0]["width"] == 104 + assert events[1]["dx"] == 5 + assert events[2]["x"] == 123 + + # Next one is separate because of different match_keys + assert events[3]["x"] == 125 + + # The second series of resize events are separate because they are + # not consecutive with the previous series + assert events[4]["width"] == 108 + + # Key events are not merged + assert events[5]["value"] == 1 + assert events[6]["value"] == 2 + + +def test_mini_benchmark(): + # Can be used to tweak internals of the EventEmitter and see the + # effect on performance. + + ee = EventEmitter() + + def handler(event): + pass + + t0 = time.perf_counter() + for _ in range(1000): + ee.add_handler(lambda e: handler(e), "key_down", order=1) + ee.add_handler(lambda e: handler(e), "key_down", order=2) + t1 = time.perf_counter() - t0 + + t0 = time.perf_counter() + for _ in range(100): + ee.submit({"event_type": "key_down", "value": 2}) + ee.flush() + t2 = time.perf_counter() - t0 + + print(f"add_handler: {1000*t1:0.0f} ms, emit: {1000*t2:0.0f} ms") + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/tests/test_gui_glfw.py b/tests/test_gui_glfw.py index d894b19..a1aa0b8 100644 --- a/tests/test_gui_glfw.py +++ b/tests/test_gui_glfw.py @@ -3,13 +3,11 @@ like the canvas context and surface texture. """ -import sys import time import weakref import asyncio import gc -import wgpu from pytest import skip from testutils import run_tests, can_use_glfw, can_use_wgpu_lib, is_pypy # from renderutils import render_to_texture, render_to_screen @@ -26,24 +24,24 @@ def setup_module(): def teardown_module(): - from wgpu.gui.glfw import poll_glfw_briefly + from rendercanvas.glfw import poll_glfw_briefly poll_glfw_briefly() pass # Do not glfw.terminate() because other tests may still need glfw -def test_is_autogui(): - from wgpu.gui.glfw import WgpuCanvas +def test_is_canvas_base(): + from rendercanvas import WgpuCanvasBase + from rendercanvas.glfw import WgpuCanvas - assert issubclass(WgpuCanvas, wgpu.gui.WgpuCanvasBase) - assert issubclass(WgpuCanvas, wgpu.gui.WgpuAutoGui) + assert issubclass(WgpuCanvas, WgpuCanvasBase) def test_glfw_canvas_basics(): """Create a window and check some of its behavior. No wgpu calls here.""" import glfw - from wgpu.gui.glfw import WgpuCanvas + from rendercanvas.glfw import WgpuCanvas canvas = WgpuCanvas() @@ -61,34 +59,28 @@ def test_glfw_canvas_basics(): # Close assert not canvas.is_closed() - if sys.platform.startswith("win"): # On Linux we cant do this multiple times - canvas.close() - glfw.poll_events() - assert canvas.is_closed() + canvas.close() + glfw.poll_events() + assert canvas.is_closed() def test_glfw_canvas_del(): - from wgpu.gui.glfw import WgpuCanvas, update_glfw_canvasses - import glfw - - loop = asyncio.get_event_loop() + from rendercanvas.glfw import WgpuCanvas, loop - async def miniloop(): - for i in range(10): - glfw.poll_events() - update_glfw_canvasses() - await asyncio.sleep(0.01) + def run_briefly(): + asyncio_loop = loop._loop + asyncio_loop.run_until_complete(asyncio.sleep(0.5)) + # poll_glfw_briefly() canvas = WgpuCanvas() ref = weakref.ref(canvas) assert ref() is not None - loop.run_until_complete(miniloop()) + run_briefly() assert ref() is not None del canvas if is_pypy: gc.collect() # force garbage collection for pypy - loop.run_until_complete(miniloop()) assert ref() is None @@ -110,14 +102,18 @@ async def miniloop(): def test_glfw_canvas_render(): """Render an orange square ... in a glfw window.""" + import wgpu import glfw - from wgpu.gui.glfw import update_glfw_canvasses, WgpuCanvas + from rendercanvas.glfw import WgpuCanvas, loop - loop = asyncio.get_event_loop() + def run_briefly(): + asyncio_loop = loop._loop + asyncio_loop.run_until_complete(asyncio.sleep(0.5)) + # poll_glfw_briefly() - canvas = WgpuCanvas(max_fps=9999) + canvas = WgpuCanvas(max_fps=9999, update_mode="ondemand") - device = wgpu.utils.get_default_device() + device = wgpu.gpu.request_adapter_sync().request_device_sync() draw_frame1 = _get_draw_function(device, canvas) frame_counter = 0 @@ -129,22 +125,16 @@ def draw_frame2(): canvas.request_draw(draw_frame2) - # Give it a few rounds to start up - async def miniloop(): - for i in range(10): - glfw.poll_events() - update_glfw_canvasses() - await asyncio.sleep(0.01) - - loop.run_until_complete(miniloop()) + run_briefly() # There should have been exactly one draw now + # This assumes ondemand scheduling mode assert frame_counter == 1 # Ask for a lot of draws for i in range(5): canvas.request_draw() # Process evens for a while - loop.run_until_complete(miniloop()) + run_briefly() # We should have had just one draw assert frame_counter == 2 @@ -152,62 +142,16 @@ async def miniloop(): canvas.set_logical_size(300, 200) canvas.set_logical_size(400, 300) # We should have had just one draw - loop.run_until_complete(miniloop()) + run_briefly() assert frame_counter == 3 # canvas.close() glfw.poll_events() -def test_glfw_canvas_render_custom_canvas(): - """Render an orange square ... in a glfw window. But not using WgpuCanvas. - This helps make sure that WgpuCanvasInterface is indeed the minimal - required canvas API. - """ - - import glfw - from wgpu.gui.glfw import get_glfw_present_info - - class CustomCanvas: # implements wgpu.WgpuCanvasInterface - def __init__(self): - glfw.window_hint(glfw.CLIENT_API, glfw.NO_API) - glfw.window_hint(glfw.RESIZABLE, True) - self.window = glfw.create_window(300, 200, "canvas", None, None) - self._present_context = None - - def get_present_info(self): - return get_glfw_present_info(self.window) - - def get_physical_size(self): - psize = glfw.get_framebuffer_size(self.window) - return int(psize[0]), int(psize[1]) - - def get_context(self): - if self._present_context is None: - backend_module = sys.modules["wgpu"].gpu.__module__ - PC = sys.modules[backend_module].GPUCanvasContext # noqa N806 - self._present_context = PC(self) - return self._present_context - - canvas = CustomCanvas() - - # Also pass canvas here, to touch that code somewhere - adapter = wgpu.gpu.request_adapter_sync( - canvas=canvas, power_preference="high-performance" - ) - device = adapter.request_device_sync() - draw_frame = _get_draw_function(device, canvas) - - for i in range(5): - time.sleep(0.01) - glfw.poll_events() - draw_frame() - canvas.get_context().present() # WgpuCanvasBase normally automates this - - glfw.hide_window(canvas.window) - - def _get_draw_function(device, canvas): + import wgpu + # Bindings and layout pipeline_layout = device.create_pipeline_layout(bind_group_layouts=[]) diff --git a/tests/test_gui_offscreen.py b/tests/test_gui_offscreen.py new file mode 100644 index 0000000..0b83d96 --- /dev/null +++ b/tests/test_gui_offscreen.py @@ -0,0 +1,69 @@ +""" +Test the offscreen canvas and some related mechanics. +""" + +import os +import gc +import weakref + +from testutils import is_pypy, run_tests + + +def test_offscreen_selection_using_env_var(): + from rendercanvas.offscreen import WgpuManualOffscreenCanvas + + ori = os.environ.get("WGPU_FORCE_OFFSCREEN", "") + os.environ["WGPU_FORCE_OFFSCREEN"] = "1" + + # We only need the func, but this triggers the auto-import + from rendercanvas.auto import select_backend + + try: + if not os.getenv("CI"): + for value in ["", "0", "false", "False", "wut"]: + os.environ["WGPU_FORCE_OFFSCREEN"] = value + module = select_backend() + assert module.WgpuCanvas is not WgpuManualOffscreenCanvas + + for value in ["1", "true", "True"]: + os.environ["WGPU_FORCE_OFFSCREEN"] = value + module = select_backend() + assert module.WgpuCanvas is WgpuManualOffscreenCanvas + + finally: + os.environ["WGPU_FORCE_OFFSCREEN"] = ori + + +def test_offscreen_event_loop(): + """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 + + def check(): + nonlocal ran + ran = True + + loop.call_later(0, check) + loop.run() + + assert ran + + +def test_offscreen_canvas_del(): + from rendercanvas.offscreen import WgpuCanvas + + canvas = WgpuCanvas() + ref = weakref.ref(canvas) + + assert ref() is not None + del canvas + if is_pypy: + gc.collect() + assert ref() is None + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/tests/test_gui_scheduling.py b/tests/test_gui_scheduling.py new file mode 100644 index 0000000..5938684 --- /dev/null +++ b/tests/test_gui_scheduling.py @@ -0,0 +1,212 @@ +""" +Test scheduling mechanics, by implememting a minimal canvas class to +implement drawing. This tests the basic scheduling mechanics, as well +as the behabior of the different update modes. +""" + +import time +from testutils import run_tests +from rendercanvas import WgpuCanvasBase, WgpuLoop, WgpuTimer + + +class MyTimer(WgpuTimer): + def _start(self): + pass + + def _stop(self): + pass + + +class MyLoop(WgpuLoop): + _TimerClass = MyTimer + + def __init__(self): + super().__init__() + self.__stopped = False + + def process_timers(self): + for timer in list(WgpuTimer._running_timers): + if timer.time_left <= 0: + timer._tick() + + def _run(self): + self.__stopped = False + + def _stop(self): + self.__stopped = True + + +class MyCanvas(WgpuCanvasBase): + _loop = MyLoop() + _gui_draw_requested = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._closed = False + self.draw_count = 0 + self.events_count = 0 + + def _get_loop(self): + return self._loop + + def _process_events(self): + super()._process_events() + self.events_count += 1 + + def _draw_frame_and_present(self): + super()._draw_frame_and_present() + self.draw_count += 1 + + def _request_draw(self): + self._gui_draw_requested = True + + def draw_if_necessary(self): + if self._gui_draw_requested: + self._gui_draw_requested = False + self._draw_frame_and_present() + + def close(self): + self._closed = True + + def is_closed(self): + return self._closed + + def active_sleep(self, delay): + loop = self._get_loop() + etime = time.perf_counter() + delay + while time.perf_counter() < etime: + time.sleep(0.001) + loop.process_timers() + self.draw_if_necessary() + + +def test_gui_scheduling_manual(): + canvas = MyCanvas(min_fps=0.000001, max_fps=100, update_mode="manual") + + # Booting ... + canvas.active_sleep(0.001) + assert canvas.draw_count == 0 + assert canvas.events_count == 0 + + # No draws, even after the 0.1 init time + canvas.active_sleep(0.11) + assert canvas.draw_count == 0 + assert canvas.events_count in range(1, 10) + + # Requesting a draw ... has no effect + canvas.request_draw() + canvas.active_sleep(0.11) + assert canvas.draw_count == 0 + assert canvas.events_count in range(10, 20) + + # Only when we force one + canvas.force_draw() + assert canvas.draw_count == 1 + + +def test_gui_scheduling_ondemand(): + canvas = MyCanvas(min_fps=0.000001, max_fps=100, update_mode="ondemand") + + # There's a small startup time, so no activity at first + canvas.active_sleep(0.001) + assert canvas.draw_count == 0 + assert canvas.events_count == 0 + + # The first draw is scheduled for 0.1 s after initialization + canvas.active_sleep(0.11) + assert canvas.draw_count == 1 + assert canvas.events_count in range(1, 10) + + # No next draw is scheduled until we request one + canvas.active_sleep(0.1) + assert canvas.draw_count == 1 + assert canvas.events_count in range(10, 20) + + # Requesting a draw ... has effect after a few loop ticks + canvas.request_draw() + assert canvas.draw_count == 1 + canvas.active_sleep(0.011) + assert canvas.draw_count == 2 + + # Forcing a draw has direct effect + canvas.draw_count = canvas.events_count = 0 + canvas.force_draw() + assert canvas.draw_count == 1 + assert canvas.events_count == 0 + + +def test_gui_scheduling_ondemand_always_request_draw(): + # Test that using ondemand mode with a request_draw() in the + # draw function, is equivalent to continuous mode. + + canvas = MyCanvas(max_fps=10, update_mode="ondemand") + + @canvas.request_draw + def draw_func(): + canvas.request_draw() + + _test_gui_scheduling_continuous(canvas) + + +def test_gui_scheduling_continuous(): + canvas = MyCanvas(max_fps=10, update_mode="continuous") + _test_gui_scheduling_continuous(canvas) + + +def _test_gui_scheduling_continuous(canvas): + # There's a small startup time, so no activity at first + canvas.active_sleep(0.001) + assert canvas.draw_count == 0 + assert canvas.events_count == 0 + + # The first draw is scheduled for 0.1 s after initialization + canvas.active_sleep(0.11) + assert canvas.draw_count == 1 + assert canvas.events_count == 1 + + # And a second one after 0.1s, with 10 fps. + canvas.active_sleep(0.1) + assert canvas.draw_count == 2 + assert canvas.events_count == 2 + + # And after one second, about 10 more + canvas.draw_count = canvas.events_count = 0 + canvas.active_sleep(1) + assert canvas.draw_count in range(9, 11) + assert canvas.events_count in range(9, 11) + + # Forcing a draw has direct effect + canvas.draw_count = canvas.events_count = 0 + canvas.force_draw() + assert canvas.draw_count == 1 + assert canvas.events_count == 0 + + +def test_gui_scheduling_fastest(): + canvas = MyCanvas(max_fps=10, update_mode="fastest") + + # There's a small startup time, so no activity at first + canvas.active_sleep(0.001) + assert canvas.draw_count == 0 + assert canvas.events_count == 0 + + # The first draw is scheduled for 0.1 s after initialization + canvas.active_sleep(0.11) + assert canvas.draw_count > 1 + assert canvas.events_count == canvas.draw_count + + # And after 0.1 s we have a lot more draws. max_fps is ignored + canvas.draw_count = canvas.events_count = 0 + canvas.active_sleep(0.1) + assert canvas.draw_count > 20 + assert canvas.events_count == canvas.draw_count + + # Forcing a draw has direct effect + canvas.draw_count = canvas.events_count = 0 + canvas.force_draw() + assert canvas.draw_count == 1 + assert canvas.events_count == 0 + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/tests/test_gui_utils.py b/tests/test_gui_utils.py new file mode 100644 index 0000000..3464ab9 --- /dev/null +++ b/tests/test_gui_utils.py @@ -0,0 +1,42 @@ +import gc + +import rendercanvas +from testutils import run_tests, is_pypy + + +def test_weakbind(): + weakbind = rendercanvas._gui_utils.weakbind + + xx = [] + + class Foo: + def bar(self): + xx.append(1) + + f1 = Foo() + f2 = Foo() + + b1 = f1.bar + b2 = weakbind(f2.bar) + + assert len(xx) == 0 + b1() + assert len(xx) == 1 + b2() + assert len(xx) == 2 + + del f1 + del f2 + + if is_pypy: + gc.collect() + + assert len(xx) == 2 + b1() + assert len(xx) == 3 # f1 still exists + b2() + assert len(xx) == 3 # f2 is gone! + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/tests/testutils.py b/tests/testutils.py new file mode 100644 index 0000000..a7f80b0 --- /dev/null +++ b/tests/testutils.py @@ -0,0 +1,78 @@ +import os +import re +import sys +import logging +import subprocess +from io import StringIO + +import wgpu + + +class LogCaptureHandler(logging.StreamHandler): + _ANSI_ESCAPE_SEQ = re.compile(r"\x1b\[[\d;]+m") + + def __init__(self): + super().__init__(StringIO()) + self.records = [] + + def emit(self, record): + record.msg = self._ANSI_ESCAPE_SEQ.sub("", record.msg) + self.records.append(record) + super().emit(record) + + def reset(self): + self.records = [] + self.stream = StringIO() + + @property + def text(self): + f = logging.Formatter() + return "\n".join(f.format(r) for r in self.records) + + +def run_tests(scope): + """Run all test functions in the given scope.""" + caplog = LogCaptureHandler() + for func in list(scope.values()): + if callable(func) and func.__name__.startswith("test_"): + nargs = func.__code__.co_argcount + argnames = [func.__code__.co_varnames[i] for i in range(nargs)] + if not argnames: + print(f"Running {func.__name__} ...") + func() + elif argnames == ["caplog"]: + print(f"Running {func.__name__} ...") + logging.root.addHandler(caplog) + caplog.reset() + func(caplog) + logging.root.removeHandler(caplog) + else: + print(f"SKIPPING {func.__name__} because it needs args") + print("Done") + + +def get_default_adapter_summary(): + """Get description of adapter, or None when no adapter is available.""" + try: + adapter = wgpu.gpu.request_adapter_sync() + except RuntimeError: + return None # lib not available, or no adapter on this system + return adapter.summary + + +def _determine_can_use_glfw(): + code = "import glfw;exit(0) if glfw.init() else exit(1)" + try: + subprocess.check_output([sys.executable, "-c", code]) + except Exception: + return False + else: + return True + + +adapter_summary = get_default_adapter_summary() +can_use_wgpu_lib = bool(adapter_summary) + +can_use_glfw = _determine_can_use_glfw() +is_ci = bool(os.getenv("CI", None)) +is_pypy = sys.implementation.name == "pypy"