From 6442ecc0216ececf69c55807605fd752ccff1eb9 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 14 Oct 2024 11:58:01 +0200 Subject: [PATCH 01/49] Refactor gui for scheduling --- examples/gui_events.py | 8 +- examples/gui_glfw.py | 6 +- examples/gui_qt.py | 7 +- wgpu/gui/__init__.py | 3 +- wgpu/gui/_events.py | 145 ++++++++++++++ wgpu/gui/_loop.py | 53 +++++ wgpu/gui/asyncio.py | 42 ++++ wgpu/gui/auto.py | 2 +- wgpu/gui/base.py | 436 +++++++++++++++++++++-------------------- wgpu/gui/glfw.py | 144 ++++---------- wgpu/gui/qt.py | 201 +++++++++++++------ wgpu/gui/trio.py | 9 + 12 files changed, 663 insertions(+), 393 deletions(-) create mode 100644 wgpu/gui/_events.py create mode 100644 wgpu/gui/_loop.py create mode 100644 wgpu/gui/asyncio.py create mode 100644 wgpu/gui/trio.py diff --git a/examples/gui_events.py b/examples/gui_events.py index 6685ad33..ab6b5921 100644 --- a/examples/gui_events.py +++ b/examples/gui_events.py @@ -2,17 +2,17 @@ A simple example to demonstrate events. """ -from wgpu.gui.auto import WgpuCanvas, run +from wgpu.gui.auto import WgpuCanvas, loop canvas = WgpuCanvas(size=(640, 480), title="wgpu events") -@canvas.add_event_handler("*") +@canvas.events.add_handler("*") def process_event(event): - if event["event_type"] != "pointer_move": + if event["event_type"] not in ["pointer_move", "before_draw"]: print(event) if __name__ == "__main__": - run() + loop.run() diff --git a/examples/gui_glfw.py b/examples/gui_glfw.py index 354a4a7a..c4d9afc3 100644 --- a/examples/gui_glfw.py +++ b/examples/gui_glfw.py @@ -4,7 +4,7 @@ # run_example = false -from wgpu.gui.glfw import WgpuCanvas, run +from wgpu.gui.glfw import WgpuCanvas from triangle import setup_drawing_sync # from cube import setup_drawing_sync @@ -17,8 +17,8 @@ @canvas.request_draw def animate(): draw_frame() - canvas.request_draw() + # canvas.request_draw() if __name__ == "__main__": - run() + canvas.loop.run() diff --git a/examples/gui_qt.py b/examples/gui_qt.py index 59a490fd..9d814c60 100644 --- a/examples/gui_qt.py +++ b/examples/gui_qt.py @@ -22,7 +22,10 @@ app = QtWidgets.QApplication([]) -canvas = WgpuCanvas(title=f"Triangle example on {WgpuCanvas.__name__}") +canvas = WgpuCanvas( + title=f"Triangle example on {WgpuCanvas.__name__}", + # present_method="image" +) draw_frame = setup_drawing_sync(canvas) @@ -30,7 +33,7 @@ @canvas.request_draw def animate(): draw_frame() - canvas.request_draw() + # canvas.request_draw() # Enter Qt event loop (compatible with qt5/qt6) diff --git a/wgpu/gui/__init__.py b/wgpu/gui/__init__.py index cb74d27e..1de44eed 100644 --- a/wgpu/gui/__init__.py +++ b/wgpu/gui/__init__.py @@ -3,10 +3,9 @@ """ from . import _gui_utils # noqa: F401 -from .base import WgpuCanvasInterface, WgpuCanvasBase, WgpuAutoGui +from .base import WgpuCanvasInterface, WgpuCanvasBase __all__ = [ "WgpuCanvasInterface", "WgpuCanvasBase", - "WgpuAutoGui", ] diff --git a/wgpu/gui/_events.py b/wgpu/gui/_events.py new file mode 100644 index 00000000..7e41738b --- /dev/null +++ b/wgpu/gui/_events.py @@ -0,0 +1,145 @@ +import time +from collections import defaultdict, deque + +from ._gui_utils import log_exception + +# todo: create an enum with all possible `event_type` values, and check for it in add_handler and submit. + + +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"}, + }, + } + + def __init__(self): + self._pending_events = deque() + self._event_handlers = defaultdict(list) + + def add_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. + + 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, "*") + + """ + 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}") + + 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 + + if decorating: + return decorator + return decorator(callback) + + 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"] + 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 any(event[key] != last_event[key] for key in match_keys): + # Keys don't match: new event + self._pending_events.append(event) + else: + # Update last event (i.e. merge) + for key in accum_keys: + last_event[key] += event[key] + else: + 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 + # 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) diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py new file mode 100644 index 00000000..1b990235 --- /dev/null +++ b/wgpu/gui/_loop.py @@ -0,0 +1,53 @@ +import time +import asyncio + + +# todo: idea: a global loop proxy object that defers to any of the other loops +# would e.g. allow using glfw with qt together. Probably to weird a use-case for the added complexity. + + +class WgpuLoop: + """Base class for different event-loop classes.""" + + def call_soon(self, callback, *args): + """Arrange for a callback to be called as soon as possible. + + Callbacks are called in the order in which they are registered. + """ + self.call_later(0, callback, *args) + + def call_later(self, delay, callback, *args): + """Arrange for a callback to be called after the given delay (in seconds).""" + raise NotImplementedError() + + def poll(self): + """Poll the underlying GUI toolkit for events. + + Some event loops (e.g. asyncio) are just that and dont have a GUI to update. + """ + pass + + def run(self): + """Enter the main loop.""" + raise NotImplementedError() + + def stop(self): + """Stop the currently running event loop.""" + raise NotImplementedError() + + +class AnimationScheduler: + """ + Some ideas: + + * canvas.events.connect("animate", callback) + * canvas.animate.add_handler(1/30, callback) + """ + + def iter(self): + # Something like this? + for scheduler in all_schedulers: + scheduler._event_emitter.submit_and_dispatch(event) + + +# todo: statistics on time spent doing what diff --git a/wgpu/gui/asyncio.py b/wgpu/gui/asyncio.py new file mode 100644 index 00000000..b9809f69 --- /dev/null +++ b/wgpu/gui/asyncio.py @@ -0,0 +1,42 @@ +"""Implements an asyncio event loop.""" + +import asyncio + +from .base import WgpuLoop + + +class AsyncioWgpuLoop(WgpuLoop): + _the_loop = None + + @property + def _loop(self): + if self._the_loop is None: + self._the_loop = self._get_loop() + return self._the_loop + + def _get_loop(self): + try: + return asyncio.get_running_loop() + except Exception: + pass + try: + return asyncio.get_event_loop() + except RuntimeError: + pass + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + def call_soon(self, callback, *args): + self._loop.call_soon(callback, *args) + + def call_later(self, delay, callback, *args): + self._loop.call_later(delay, callback, *args) + + def run(self): + if self._loop.is_running(): + return # Interactive mode! + self._loop.run_forever() + + def stop(self): + self._loop.stop() diff --git a/wgpu/gui/auto.py b/wgpu/gui/auto.py index 65536ec1..7aef30be 100644 --- a/wgpu/gui/auto.py +++ b/wgpu/gui/auto.py @@ -188,4 +188,4 @@ 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 diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index cc957673..7ed143e0 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -1,23 +1,9 @@ import sys import time -from collections import defaultdict 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) +from ._events import EventEmitter +from ._loop import WgpuLoop, Scheduler class WgpuCanvasInterface: @@ -80,7 +66,13 @@ 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 = 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 + self._canvas_context = CanvasContext(self) return self._canvas_context def present_image(self, image, **kwargs): @@ -110,13 +102,33 @@ class WgpuCanvasBase(WgpuCanvasInterface): also want to set ``vsync`` to False. """ - def __init__(self, *args, max_fps=30, vsync=True, present_method=None, **kwargs): + def __init__( + self, *args, max_fps=30, vsync=True, present_method=None, ticking=True, **kwargs + ): super().__init__(*args, **kwargs) - self._last_draw_time = 0 + self._min_fps = float(1.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 + self._draw_frame = lambda: None + self._events = EventEmitter() + # self._scheduler = Scheduler(self) + + self._draw_requested = True + self._schedule_time = 0 + self._last_draw_time = 0 + self._draw_stats = 0, time.perf_counter() + self._mode = "continuous" + + self._a_tick_is_scheduled = False + + self._animation_time = 0 + self._animation_step = 1 / 20 + + if ticking: + self._schedule_tick() + def __del__(self): # On delete, we call the custom close method. try: @@ -130,13 +142,17 @@ def __del__(self): except Exception: pass - def draw_frame(self): - """The function that gets called at each draw. + @property + def events(self): + return self._events - You can implement this method in a subclass, or set it via a - call to request_draw(). - """ - pass + @property + def scheduler(self): + return self._scheduler + + @property + def loop(self): + return self._get_loop() def request_draw(self, draw_function=None): """Schedule a new draw event. @@ -152,32 +168,201 @@ 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 + + # We don't call self._request_draw() directly but let the scheduler do that based on the policy + # self._scheduler.request_draw() + # todo: maybe have set_draw_function() separately + # todo: maybe requesting a new draw can be done by setting a field in an event? + # todo: can we invoke the draw function via a draw event? + + # We can assume that this function is called when we flush events. + # So we can also maybe replace this by letting downstream code set a flag on the event object. + # In any case, we only really have to do something in ondemand mode; in other modes we draw regardless. + self._draw_requested = True + + def force_draw(self): + self._force_draw() + + def _schedule_tick(self): + # This method 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 call_later calls. It's crucial that the + # loop stays clean and does not 'duplicate', e.g. by an extra draw being + # done behind our back, otherwise the fps might double (but be + # irregular). Taking care of this is surprising tricky. + # + # ________________ __ ________________ __ ________________ __ + # / call_later \ / rd \ / call_later \ / rd \ / \ / \ + # | || || || || || | + # ---------------------------------------------------------------------------------------------> time + # | | | | | + # | | draw | draw + # schedule_tick tick tick + # + # + # In between the calls to _schedule_tick() and tick(), a new + # tick cannot be invoked. In tick() the _request_draw() method is + # called that asks the GUI to schedule a draw. The first thing that the + # draw() method does, is schedule a new draw. In effect, any extra draws + # that are performed do not affect the ticking itself. + # + # ________________ ________________ __ ________________ __ + # / call_later \ / call_later \ / rd \ / \ / \ + # | || || || || | + # ---------------------------------------------------------------------------------------------> time + # | | | | + # | | | draw + # schedule tick tick + + # This method gets called right before/after the draw is performed, from + # _draw_frame_and_present(). In here, we make scheduler that a new draw + # is done (by the undelying GUI), so that _draw_frame_and_present() gets + # called again. We cannot implement a loop-thingy that occasionally + # schedules a draw event, because if the drawing cannot keep up, the + # requests pile up and got out of sync. + + # Prevent recursion. This is important, otherwise an extra call results in duplicate drawing. + if self._a_tick_is_scheduled: + return + self._a_tick_is_scheduled = True + self._schedule_time = time.perf_counter() + + def tick(): + # Determine whether to request a draw or just schedule a new tick + if self._mode == "manual": + # manual: never draw, except when ..... ? + self._flush_events() + request_a_draw = False + 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. + self._flush_events() # may set _draw_requested + its_draw_time = ( + time.perf_counter() - self._last_draw_time > 1 / self._min_fps + ) + request_a_draw = self._draw_requested or its_draw_time + elif self._mode == "continuous": + # continuous: draw continuously, aiming for a steady max framerate. + request_a_draw = True + else: + # fastest: draw continuously as fast as possible, ignoring fps settings. + request_a_draw = True - def _draw_frame_and_present(self): + # Request a draw, or flush events and schedule again. + self._a_tick_is_scheduled = False + if request_a_draw: + self._request_draw() + else: + self._schedule_tick() + + loop = self._get_loop() + if self._mode == "fastest": + # Draw continuously as fast as possible, ignoring fps settings. + loop.call_soon(tick) + else: + # Schedule a new tick + delay = 1 / self._max_fps + delay = 0 if delay < 0 else delay # 0 means cannot keep up + loop.call_later(delay, tick) + + def _process_input(self): + """This should process all GUI events. + + In some GUI systems, like Qt, events are already processed because the + Qt event loop is running, so this can be a no-op. In other cases, like + glfw, this hook allows glfw to do a tick. + """ + raise NotImplementedError() + + def _flush_events(self): + # Get events from the GUI into our event mechanism. + self._get_loop().poll() # todo: maybe self._process_gui_events()? + + # Flush our events, so downstream code can update stuff. + # Maybe that downstream code request a new draw. + self.events.flush() + + # Schedule 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}) + # 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} + ) + + # todo: was _draw_frame_and_present + def _tick_draw(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. """ + # 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. + # So this call must to the complete tick. + + self._draw_requested = False + self._schedule_tick() + + self._flush_events() + + # It could be that the canvas is closed now. When that happens, + # we stop here and do not schedule a new iter. + if self.is_closed(): + return + + self.events.submit({"event_type": "before_draw"}) + self.events.flush() + + # Schedule a new draw right before doing the draw. Important that it happens *after* processing events. self._last_draw_time = time.perf_counter() + + # Update stats + count, last_time = self._draw_stats + if time.perf_counter() - last_time > 1.0: + self._draw_stats = 0, time.perf_counter() + else: + self._draw_stats = count + 1, last_time + + # Stats (uncomment to see fps) + count, last_time = self._draw_stats + fps = count / (time.perf_counter() - last_time) + self.set_title(f"wgpu {fps:0.1f} fps") + # 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. + # todo: maybe move to scheduler with log_exception("Draw error"): - self.draw_frame() + self._draw_frame() with log_exception("Present error"): if self._canvas_context: + time.sleep(0.01) 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 to provided a common API for downstream libraries and end-users + + def _get_loop(self): + """Must return the global loop instance.""" + raise NotImplementedError() + + def _request_draw(self): + """Like requestAnimationFrame in JS. Must schedule a call to self._scheduler.draw() ???""" + raise NotImplementedError() - # Methods that must be overloaded + def _force_draw(self): + """Perform a draw right now.""" + raise NotImplementedError() def get_pixel_ratio(self): """Get the float ratio between logical and physical pixels.""" @@ -206,184 +391,3 @@ def close(self): def is_closed(self): """Get whether the window is closed.""" raise NotImplementedError() - - def _request_draw(self): - """GUI-specific implementation for ``request_draw()``. - - * 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. - """ - raise NotImplementedError() - - -class WgpuAutoGui: - """Mixin class for canvases implementing autogui. - - 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). - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._last_event_time = 0 - self._pending_events = {} - self._event_handlers = defaultdict(list) - - def _get_event_wait_time(self): - """Calculate the time to wait for the next event dispatching. - - Used for rate-limited events. - """ - 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). - - 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. - - Arguments: - event (dict): the event to handle. - """ - # 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. - - 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, "*") - - """ - 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}") - - 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 - - if decorating: - return decorator - return decorator(callback) - - 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 - ] diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index 54d24919..27100648 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/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 @@ -141,13 +141,13 @@ 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 @@ -163,13 +163,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. @@ -198,7 +196,6 @@ def __init__(self, *, size=None, title=None, **kwargs): self._pixel_ratio = -1 self._screen_size_is_logical = False self.set_logical_size(*size) - self._request_draw() # Callbacks to provide a minimal working canvas for wgpu @@ -210,11 +207,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 +221,20 @@ 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._events.submit({"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) # 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 +254,7 @@ def _determine_size(self): "height": self._logical_size[1], "pixel_ratio": self._pixel_ratio, } - self._handle_event_and_flush(ev) + self._events.submit(ev) def _set_logical_size(self, new_logical_size): if self._window is None: @@ -302,6 +294,15 @@ def _set_logical_size(self, new_logical_size): # API + def _get_loop(self): + return loop + + def _request_draw(self): + self._get_loop().call_soon(self._tick_draw) + + def _force_draw(self): + self._tick_draw() + def get_present_info(self): return get_glfw_present_info(self._window) @@ -322,11 +323,6 @@ def set_logical_size(self, width, height): 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 +372,7 @@ def _on_mouse_button(self, window, but, action, mods): } # Emit the current event - self._handle_event_and_flush(ev) + self._events.submit(ev) # Maybe emit a double-click event self._follow_double_click(action, button) @@ -428,7 +424,7 @@ def _follow_double_click(self, action, button): "ntouches": 0, # glfw does not have touch support "touches": {}, } - self._handle_event_and_flush(ev) + self._events.submit(ev) def _on_cursor_pos(self, window, x, y): # Store pointer position in logical coordinates @@ -448,9 +444,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._events.submit(ev) def _on_scroll(self, window, dx, dy): # wheel is 1 or -1 in glfw, in jupyter_rfb this is ~100 @@ -463,9 +457,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._events.submit(ev) def _on_key(self, window, key, scancode, action, mods): modifier = KEY_MAP_MOD.get(key, None) @@ -505,7 +497,7 @@ def _on_key(self, window, key, scancode, action, mods): "key": keyname, "modifiers": tuple(self._key_modifiers), } - self._handle_event_and_flush(ev) + self._events.submit(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 +506,7 @@ def _on_char(self, window, char): "char_str": chr(char), "modifiers": tuple(self._key_modifiers), } - self._handle_event_and_flush(ev) + self._events.submit(ev) def present_image(self, image, **kwargs): raise NotImplementedError() @@ -527,13 +519,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 +532,19 @@ 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 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() + def run(self): + super().run() + poll_glfw_briefly() -app = AppState() - -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() +# todo: loop or app? def poll_glfw_briefly(poll_time=0.1): @@ -610,19 +560,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/wgpu/gui/qt.py b/wgpu/gui/qt.py index 0487391f..25db96dc 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -7,7 +7,7 @@ import ctypes import importlib -from .base import WgpuCanvasBase, WgpuAutoGui +from .base import WgpuCanvasBase, WgpuLoop from ._gui_utils import ( logger, SYSTEM_IS_WAYLAND, @@ -140,7 +140,7 @@ 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): @@ -171,11 +171,7 @@ 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) + self._qt_draw_requested = False def paintEngine(self): # noqa: N802 - this is a Qt method # https://doc.qt.io/qt-5/qt.html#WidgetAttribute-enum WA_PaintOnScreen @@ -184,11 +180,32 @@ def paintEngine(self): # noqa: N802 - this is a Qt method else: return super().paintEngine() + # def update(self): + # pass + def paintEvent(self, event): # noqa: N802 - this is a Qt method - self._draw_frame_and_present() + self._tick_draw() + # if self._qt_draw_requested: + # self._qt_draw_requested = False + # self._tick_draw() + # else: + # event.ignore() # Methods that we add from wgpu (snake_case) + def _request_draw(self): + # Ask Qt to do a paint event + self._qt_draw_requested = True + QtWidgets.QWidget.update(self) + + def _force_draw(self): + # Call the paintEvent right now + self._qt_draw_requested = True + self.repaint() + + def _get_loop(self): + return loop + def _get_surface_ids(self): if sys.platform.startswith("win") or sys.platform.startswith("darwin"): return { @@ -257,16 +274,17 @@ def set_logical_size(self, width, height): def set_title(self, title): self.setWindowTitle(title) - - def _request_draw(self): - if not self._request_draw_timer.isActive(): - self._request_draw_timer.start(int(self._get_draw_wait_time() * 1000)) + if isinstance(self.parent(), QWgpuCanvas): + self.parent().setWindowTitle(title) def close(self): QtWidgets.QWidget.close(self) def is_closed(self): - return not self.isVisible() + try: + return not self.isVisible() + except Exception: + return True # Internal C++ object already deleted # User events to jupyter_rfb events @@ -282,7 +300,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._events.submit(ev) def _char_input_event(self, char_str): ev = { @@ -290,7 +308,7 @@ def _char_input_event(self, char_str): "char_str": char_str, "modifiers": None, } - self._handle_event_and_flush(ev) + self._events.submit(ev) def keyPressEvent(self, event): # noqa: N802 self._key_event("key_down", event) @@ -335,12 +353,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._events.submit(ev) def mousePressEvent(self, event): # noqa: N802 self._mouse_event("pointer_down", event) @@ -377,9 +390,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._events.submit(ev) def resizeEvent(self, event): # noqa: N802 ev = { @@ -388,10 +399,12 @@ def resizeEvent(self, event): # noqa: N802 "height": float(event.size().height()), "pixel_ratio": self.get_pixel_ratio(), } - self._handle_event_and_flush(ev) + self._events.submit(ev) def closeEvent(self, event): # noqa: N802 - self._handle_event_and_flush({"event_type": "close"}) + self._events.submit({"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 @@ -426,7 +439,7 @@ def present_image(self, image_data, **kwargs): # painter.drawText(100, 100, "This is an image") -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. @@ -439,8 +452,8 @@ def __init__( ): # When using Qt, there needs to be an # application before any widget is created - get_app() - super().__init__(**kwargs) + loop.init_qt() + super().__init__(**kwargs, ticking=False) self.setAttribute(WA_DeleteOnClose, True) self.set_logical_size(*(size or (640, 480))) @@ -450,7 +463,10 @@ def __init__( self._subwidget = QWgpuWidget( self, max_fps=max_fps, present_method=present_method ) - self._subwidget.add_event_handler(weakbind(self.handle_event), "*") + + self._events = self._subwidget.events + # self._scheduler._canvas = None + # self._scheduler = self._subwidget.scheduler # Note: At some point we called `self._subwidget.winId()` here. For some # reason this was needed to "activate" the canvas. Otherwise the viz was @@ -466,19 +482,20 @@ def __init__( # Qt methods - def update(self): - super().update() - self._subwidget.update() + # def update(self): + # super().update() + # self._subwidget.update() # 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() + + def _force_draw(self): + self._subwidget._force_draw() - @draw_frame.setter - def draw_frame(self, f): - self._subwidget.draw_frame = f + def _get_loop(self): + return loop def get_present_info(self): return self._subwidget.get_present_info() @@ -498,17 +515,14 @@ def set_logical_size(self, width, height): 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() + self._subwidget.set_title(title) 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 @@ -527,20 +541,87 @@ 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 QtWgpuLoop(WgpuLoop): + def __init__(self): + super().__init__() + self._context_for_timer = None + self._timers = {} + + def init_qt(self): + _ = self._app + + # class CallbackEventHandler(QtCore.QObject): + # + # def __init__(self): + # super().__init__() + # self.queue = dequeu() + # + # def customEvent(self, event): + # while True: + # try: + # callback, args = self.queue.get_nowait() + # except Empty: + # break + # try: + # callback(*args) + # except Exception as why: + # print("callback failed: {}:\n{}".format(callback, why)) + # + # def postEventWithCallback(self, callback, *args): + # self.queue.put((callback, args)) + # QtWidgets.qApp.postEvent(self, QtCore.QEvent(QtCore.QEvent.Type.User)) - -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() - - # 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 call_later(delay, callback, *args): - QtCore.QTimer.singleShot(int(delay * 1000), lambda: callback(*args)) + @property + def _app(self): + """Return global instance of Qt app instance or create one if not created yet.""" + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + if self._context_for_timer is None: + self._context_for_timer = QtCore.QObject() + return app + + def poll(self): + # todo: can check if loop is running .... + pass + # Don't actually do anything, because we assume that when using a Qt canvas, the qt event loop is running. + # If necessary, we can have a different (kind of) call for users and for the scheduler. + # app = self._app + # app.sendPostedEvents() + # app.process_events() + + def call_later(self, delay, callback, *args): + func = callback + + def func(): + # timer.deleteLater() + self._timers.pop(timer_id, None) + callback(*args) + + timer = QtCore.QTimer() + timer.timeout.connect(func) + timer.setSingleShot(True) + timer.setTimerType(PreciseTimer) + timer.start(int(delay * 1000)) + timer_id = id(timer) + self._timers[timer_id] = timer + # print(self._timers) + # self._timer = timer + # self._timers.append(timer) + # self._timers[:-1] = [] + # QtCore.QTimer.singleShot( + # int(delay * 1000), PreciseTimer, self._context_for_timer, func + # ) + + def run(self): + if already_had_app_on_import: + return # Likely in an interactive session or larger application that will start the Qt app. + app = self._app + + # 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 stop(self): + self._app.quit() + + +loop = QtWgpuLoop() diff --git a/wgpu/gui/trio.py b/wgpu/gui/trio.py new file mode 100644 index 00000000..98a011de --- /dev/null +++ b/wgpu/gui/trio.py @@ -0,0 +1,9 @@ +"""A Trio-based event loop.""" + +import trio +from .base import WgpuLoop + + +# todo: this would be nice +class TrioWgpuLoop(WgpuLoop): + pass From b45f41b8f85fe8372d79dce0b3082ffff469f523 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 15 Oct 2024 10:21:41 +0200 Subject: [PATCH 02/49] Move scheduling to separate class --- wgpu/gui/_loop.py | 237 ++++++++++++++++++++++++++++++++++++++++++++++ wgpu/gui/base.py | 214 +++++++---------------------------------- wgpu/gui/glfw.py | 4 +- wgpu/gui/qt.py | 11 +-- 4 files changed, 278 insertions(+), 188 deletions(-) diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 1b990235..2226a566 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -1,6 +1,7 @@ import time import asyncio +from ._gui_utils import log_exception # todo: idea: a global loop proxy object that defers to any of the other loops # would e.g. allow using glfw with qt together. Probably to weird a use-case for the added complexity. @@ -51,3 +52,239 @@ def iter(self): # todo: statistics on time spent doing what + + +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 call_later calls. It's crucial that the loop stays clean + # and does not 'duplicate', e.g. by an extra draw being done behind our + # back, otherwise the fps might double (but be irregular). Taking care of + # this is surprising tricky. + # + # The loop looks a little like this: + # + # ________________ __ ________________ __ + # / call_later \ / rd \ / call_later \ / rd \ + # | || || || | + # --------------------------------------------------------------------> time + # | | | | | + # | | draw_tick | draw_tick + # schedule pseuso_tick pseudo_tick + # + # + # While the loop is waiting - via call_later, in between the calls to + # _schedule_tick() and pseudo_tick() - a new tick cannot be scheduled. In + # pseudo_tick() the _request_draw() method is called that asks the GUI to + # schedule a draw. This happens in a later event-loop iteration, an can + # happen (nearly) directly, or somewhat later. The first thing that the + # draw_tick() method does, is schedule a new draw. Any extra draws that are + # performed still call _schedule_tick(), but this has no effect. + # + # With update modes 'ondemand' and 'manual', the loop ticks at the same rate + # as on 'continuous' mode, but won't draw every tick. The event_tick() is + # then called instead, so that events handlers and animations stay active, + # from which a new draw may be requested. + # + # ________________ ________________ __ + # / call_later \ / call_later \ / rd \ + # | || || | + # --------------------------------------------------------------------> time + # | | | | + # | | | draw_tick + # schedule pseuso_tick pseuso_tick + # + event_tick + + def __init__(self, canvas, *, min_fps=1, max_fps=30): + # Objects related to the canvas + self._canvas = canvas + self._events = canvas._events + + # The draw function + self._draw_frame = lambda: None + + # Lock the scheduling while its waiting + self._waiting_lock = False + + # Scheduling variables + self._mode = "continuous" + self._min_fps = float(min_fps) + self._max_fps = float(max_fps) + self._draw_requested = True + + # Stats + self._last_draw_time = 0 + self._draw_stats = 0, time.perf_counter() + + # Variables for animation + self._animation_time = 0 + self._animation_step = 1 / 20 + + # Start by doing the first scheduling. + # Note that the gui may do a first draw earlier, starting the loop, and that's fine. + canvas._get_loop().call_later(0.1, self._schedule_next_tick) + + def set_draw_func(self, draw_frame): + """Set the callable that must be called to do a draw.""" + self._draw_frame = draw_frame + + 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): + # Scheduling flow: + # + # * _schedule_next_tick(): + # * determine delay + # * use call_later() to have pseudo_tick() called + # + # * pseudo_tick(): + # * decide whether to request a draw + # * a draw is requested: + # * the GUI will call canvas._draw_frame_and_present() + # * wich calls draw_tick() + # * A draw is not requested: + # * call event_tick() + # * call _schedule_next_tick() + # + # * event_tick(): + # * let GUI process events + # * flush events + # * run animations + # + # * draw_tick(): + # * calls _schedule_next_tick() + # * calls event_tick() + # * draw! + + # Notes: + # + # * New ticks must be scheduled from the draw_tick, otherwise + # new draws may get scheduled faster than it can keep up. + # * It's crucial to not have two cycles running at the same time. + # * We must assume that the GUI can do extra draws (i.e. draw_tick gets called) any time, e.g. when resizing. + + # Flag that allows this method to be called at any time, without introducing an extra "loop". + if self._waiting_lock: + return + self._waiting_lock = True + + # 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 + + def pseudo_tick(): + # Since this resets the waiting lock, we really want to avoid accidentally + # calling this function. That's why we define it locally. + + # Enable scheduling again + self._waiting_lock = False + + if self._mode == "fastest": + # fastest: draw continuously as fast as possible, ignoring fps settings. + self._canvas._request_draw() + + elif self._mode == "continuous": + # continuous: draw continuously, aiming for a steady max framerate. + self._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. + self.event_tick() # may set _draw_requested + its_draw_time = ( + time.perf_counter() - self._last_draw_time > 1 / self._min_fps + ) + if self._draw_requested or its_draw_time: + self._canvas._request_draw() + else: + self._schedule_next_tick() + + elif self._mode == "manual": + # manual: never draw, except when ... ? + self.event_tick() + self._schedule_next_tick() + + else: + raise RuntimeError(f"Unexpected scheduling mode: '{self._mode}'") + + self._canvas._get_loop().call_later(delay, pseudo_tick) + + def event_tick(self): + """A lightweight tick that processes evets and animations.""" + # Get events from the GUI into our event mechanism. + self._canvas._get_loop().poll() # todo: maybe self._process_gui_events()? + + # Flush our events, so downstream code can update stuff. + # Maybe that downstream code request a new draw. + self._events.flush() + + # 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}) + # 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} + ) + + def draw_tick(self): + """Perform a full tick: processing events, animations, drawing, and presenting.""" + + # Events and animations + self.event_tick() + + # It could be that the canvas is closed now. When that happens, + # we stop here and do not schedule a new iter. + if self._canvas.is_closed(): + return + + # Keep ticking + self._draw_requested = False + self._schedule_next_tick() + + # Special event for drawing + self._events.submit({"event_type": "before_draw"}) + self._events.flush() + + # Schedule a new draw right before doing the draw. Important that it happens *after* processing events. + self._last_draw_time = time.perf_counter() + + # Update stats + count, last_time = self._draw_stats + if time.perf_counter() - last_time > 1.0: + self._draw_stats = 0, time.perf_counter() + else: + self._draw_stats = count + 1, last_time + + # Stats (uncomment to see fps) + count, last_time = self._draw_stats + fps = count / (time.perf_counter() - last_time) + self._canvas.set_title(f"wgpu {fps:0.1f} fps") + + # 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"): + context = self._canvas._canvas_context + if context: + context.present() + # Note, if vsync is used, this call may wait a little (happens down at the level of the driver or OS) diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 7ed143e0..24ed98b2 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -103,31 +103,24 @@ class WgpuCanvasBase(WgpuCanvasInterface): """ def __init__( - self, *args, max_fps=30, vsync=True, present_method=None, ticking=True, **kwargs + self, + *args, + min_fps=1, + max_fps=30, + vsync=True, + present_method=None, + use_scheduler=True, + **kwargs, ): super().__init__(*args, **kwargs) - self._min_fps = float(1.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 - self._draw_frame = lambda: None self._events = EventEmitter() - # self._scheduler = Scheduler(self) - self._draw_requested = True - self._schedule_time = 0 - self._last_draw_time = 0 - self._draw_stats = 0, time.perf_counter() - self._mode = "continuous" - - self._a_tick_is_scheduled = False - - self._animation_time = 0 - self._animation_step = 1 / 20 - - if ticking: - self._schedule_tick() + self._scheduler = None + if use_scheduler: + self._scheduler = Scheduler(self, min_fps=min_fps, max_fps=max_fps) def __del__(self): # On delete, we call the custom close method. @@ -142,13 +135,22 @@ def __del__(self): except Exception: pass - @property - def events(self): - return self._events + # === Events - @property - def scheduler(self): - return self._scheduler + 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): + return self._event.submit(event) + + add_event_handler.__doc__ = EventEmitter.add_handler.__doc__ + remove_event_handler.__doc__ = EventEmitter.remove_handler.__doc__ + submit_event.__doc__ = EventEmitter.submit.__doc__ + + # === Scheduling @property def loop(self): @@ -158,8 +160,8 @@ 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 + 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. Arguments: @@ -167,8 +169,11 @@ def request_draw(self, draw_function=None): function. If not given or None, the last set draw function is used. """ + if self._scheduler is None: + return if draw_function is not None: - self._draw_frame = draw_function + self._scheduler.set_draw_func(draw_function) + self._scheduler.request_draw() # We don't call self._request_draw() directly but let the scheduler do that based on the policy # self._scheduler.request_draw() @@ -179,92 +184,10 @@ def request_draw(self, draw_function=None): # We can assume that this function is called when we flush events. # So we can also maybe replace this by letting downstream code set a flag on the event object. # In any case, we only really have to do something in ondemand mode; in other modes we draw regardless. - self._draw_requested = True def force_draw(self): self._force_draw() - def _schedule_tick(self): - # This method 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 call_later calls. It's crucial that the - # loop stays clean and does not 'duplicate', e.g. by an extra draw being - # done behind our back, otherwise the fps might double (but be - # irregular). Taking care of this is surprising tricky. - # - # ________________ __ ________________ __ ________________ __ - # / call_later \ / rd \ / call_later \ / rd \ / \ / \ - # | || || || || || | - # ---------------------------------------------------------------------------------------------> time - # | | | | | - # | | draw | draw - # schedule_tick tick tick - # - # - # In between the calls to _schedule_tick() and tick(), a new - # tick cannot be invoked. In tick() the _request_draw() method is - # called that asks the GUI to schedule a draw. The first thing that the - # draw() method does, is schedule a new draw. In effect, any extra draws - # that are performed do not affect the ticking itself. - # - # ________________ ________________ __ ________________ __ - # / call_later \ / call_later \ / rd \ / \ / \ - # | || || || || | - # ---------------------------------------------------------------------------------------------> time - # | | | | - # | | | draw - # schedule tick tick - - # This method gets called right before/after the draw is performed, from - # _draw_frame_and_present(). In here, we make scheduler that a new draw - # is done (by the undelying GUI), so that _draw_frame_and_present() gets - # called again. We cannot implement a loop-thingy that occasionally - # schedules a draw event, because if the drawing cannot keep up, the - # requests pile up and got out of sync. - - # Prevent recursion. This is important, otherwise an extra call results in duplicate drawing. - if self._a_tick_is_scheduled: - return - self._a_tick_is_scheduled = True - self._schedule_time = time.perf_counter() - - def tick(): - # Determine whether to request a draw or just schedule a new tick - if self._mode == "manual": - # manual: never draw, except when ..... ? - self._flush_events() - request_a_draw = False - 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. - self._flush_events() # may set _draw_requested - its_draw_time = ( - time.perf_counter() - self._last_draw_time > 1 / self._min_fps - ) - request_a_draw = self._draw_requested or its_draw_time - elif self._mode == "continuous": - # continuous: draw continuously, aiming for a steady max framerate. - request_a_draw = True - else: - # fastest: draw continuously as fast as possible, ignoring fps settings. - request_a_draw = True - - # Request a draw, or flush events and schedule again. - self._a_tick_is_scheduled = False - if request_a_draw: - self._request_draw() - else: - self._schedule_tick() - - loop = self._get_loop() - if self._mode == "fastest": - # Draw continuously as fast as possible, ignoring fps settings. - loop.call_soon(tick) - else: - # Schedule a new tick - delay = 1 / self._max_fps - delay = 0 if delay < 0 else delay # 0 means cannot keep up - loop.call_later(delay, tick) - def _process_input(self): """This should process all GUI events. @@ -274,35 +197,7 @@ def _process_input(self): """ raise NotImplementedError() - def _flush_events(self): - # Get events from the GUI into our event mechanism. - self._get_loop().poll() # todo: maybe self._process_gui_events()? - - # Flush our events, so downstream code can update stuff. - # Maybe that downstream code request a new draw. - self.events.flush() - - # Schedule 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}) - # 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} - ) - - # todo: was _draw_frame_and_present - def _tick_draw(self): + 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 @@ -310,47 +205,10 @@ def _tick_draw(self): """ # 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. # So this call must to the complete tick. + if self._scheduler is not None: + self._scheduler.draw_tick() - self._draw_requested = False - self._schedule_tick() - - self._flush_events() - - # It could be that the canvas is closed now. When that happens, - # we stop here and do not schedule a new iter. - if self.is_closed(): - return - - self.events.submit({"event_type": "before_draw"}) - self.events.flush() - - # Schedule a new draw right before doing the draw. Important that it happens *after* processing events. - self._last_draw_time = time.perf_counter() - - # Update stats - count, last_time = self._draw_stats - if time.perf_counter() - last_time > 1.0: - self._draw_stats = 0, time.perf_counter() - else: - self._draw_stats = count + 1, last_time - - # Stats (uncomment to see fps) - count, last_time = self._draw_stats - fps = count / (time.perf_counter() - last_time) - self.set_title(f"wgpu {fps:0.1f} fps") - - # 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. - # todo: maybe move to scheduler - with log_exception("Draw error"): - self._draw_frame() - with log_exception("Present error"): - if self._canvas_context: - time.sleep(0.01) - return self._canvas_context.present() - - # Methods that must be overloaded to provided a common API for downstream libraries and end-users + # === Canvas management methods def _get_loop(self): """Must return the global loop instance.""" diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index 27100648..7257d5e9 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -298,10 +298,10 @@ def _get_loop(self): return loop def _request_draw(self): - self._get_loop().call_soon(self._tick_draw) + self._get_loop().call_soon(self._draw_frame_and_present) def _force_draw(self): - self._tick_draw() + self._draw_frame_and_present() def get_present_info(self): return get_glfw_present_info(self._window) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 25db96dc..875145b9 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -184,12 +184,7 @@ def paintEngine(self): # noqa: N802 - this is a Qt method # pass def paintEvent(self, event): # noqa: N802 - this is a Qt method - self._tick_draw() - # if self._qt_draw_requested: - # self._qt_draw_requested = False - # self._tick_draw() - # else: - # event.ignore() + self._draw_frame_and_present() # Methods that we add from wgpu (snake_case) @@ -453,7 +448,7 @@ def __init__( # When using Qt, there needs to be an # application before any widget is created loop.init_qt() - super().__init__(**kwargs, ticking=False) + super().__init__(**kwargs, use_scheduler=False) self.setAttribute(WA_DeleteOnClose, True) self.set_logical_size(*(size or (640, 480))) @@ -464,7 +459,7 @@ def __init__( self, max_fps=max_fps, present_method=present_method ) - self._events = self._subwidget.events + self._events = self._subwidget._events # self._scheduler._canvas = None # self._scheduler = self._subwidget.scheduler From 0be2d24319cd2db00d457caba822aa173ba60283 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 15 Oct 2024 11:10:53 +0200 Subject: [PATCH 03/49] add events enum --- docs/conf.py | 6 +++++- docs/gui.rst | 7 +++++++ examples/gui_events.py | 4 ++-- wgpu/gui/__init__.py | 2 ++ wgpu/gui/_events.py | 34 +++++++++++++++++++++++++++++++++- wgpu/gui/_loop.py | 4 ++-- wgpu/gui/base.py | 4 ---- 7 files changed, 51 insertions(+), 10 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index d1117b29..1d38facd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -109,7 +109,11 @@ def resolve_crossrefs(text): cls.__doc__ = docs or None # Docstring of methods for method in cls.__dict__.values(): - if callable(method) and hasattr(method, "__code__"): + if ( + callable(method) + and hasattr(method, "__code__") + and not method.__name__.startswith("_") + ): docs = resolve_crossrefs(method.__doc__) if ( method.__code__.co_argcount == 1 diff --git a/docs/gui.rst b/docs/gui.rst index 0680992f..7ce65207 100644 --- a/docs/gui.rst +++ b/docs/gui.rst @@ -30,6 +30,13 @@ support for events (interactivity). In the next sections we demonstrates the dif canvas classes that you can use. +Events +------ + +.. autoclass:: WgpuEventType + :members: + + The auto GUI backend -------------------- diff --git a/examples/gui_events.py b/examples/gui_events.py index ab6b5921..89aef81f 100644 --- a/examples/gui_events.py +++ b/examples/gui_events.py @@ -8,9 +8,9 @@ canvas = WgpuCanvas(size=(640, 480), title="wgpu events") -@canvas.events.add_handler("*") +@canvas.add_event_handler("*") def process_event(event): - if event["event_type"] not in ["pointer_move", "before_draw"]: + if event["event_type"] not in ["pointer_move", "before_draw", "animate"]: print(event) diff --git a/wgpu/gui/__init__.py b/wgpu/gui/__init__.py index 1de44eed..bc8bb954 100644 --- a/wgpu/gui/__init__.py +++ b/wgpu/gui/__init__.py @@ -4,8 +4,10 @@ from . import _gui_utils # noqa: F401 from .base import WgpuCanvasInterface, WgpuCanvasBase +from ._events import WgpuEventType __all__ = [ "WgpuCanvasInterface", "WgpuCanvasBase", + "WgpuEventType", ] diff --git a/wgpu/gui/_events.py b/wgpu/gui/_events.py index 7e41738b..e841f82e 100644 --- a/wgpu/gui/_events.py +++ b/wgpu/gui/_events.py @@ -2,8 +2,35 @@ from collections import defaultdict, deque from ._gui_utils import log_exception +from ..enums import Enum -# todo: create an enum with all possible `event_type` values, and check for it in add_handler and submit. + +class WgpuEventType(Enum): + """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'. + + # 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: @@ -76,6 +103,8 @@ def my_handler(event): 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): for type in types: @@ -105,6 +134,9 @@ def submit(self, 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}'") + event.setdefault("time_stamp", time.perf_counter()) event_merge_info = self._EVENTS_THAT_MERGE.get(event_type, None) diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 2226a566..78180689 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -41,7 +41,7 @@ class AnimationScheduler: """ Some ideas: - * canvas.events.connect("animate", callback) + * canvas.add_event_handler("animate", callback) * canvas.animate.add_handler(1/30, callback) """ @@ -232,7 +232,7 @@ def event_tick(self): animation_iters = 0 while self._animation_time > time.perf_counter() - step: self._animation_time += step - self._events.submit({"event_type": "animate", "step": 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 diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 24ed98b2..161e6f27 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -143,12 +143,8 @@ def add_event_handler(self, *args, **kwargs): def remove_event_handler(self, *args, **kwargs): return self._events.remove_handler(*args, **kwargs) - def submit_event(self, event): - return self._event.submit(event) - add_event_handler.__doc__ = EventEmitter.add_handler.__doc__ remove_event_handler.__doc__ = EventEmitter.remove_handler.__doc__ - submit_event.__doc__ = EventEmitter.submit.__doc__ # === Scheduling From 7d195228c5df0fd7cc8fe0de17e2912b1f446d3d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 15 Oct 2024 11:18:56 +0200 Subject: [PATCH 04/49] cleanup / lint --- wgpu/gui/_loop.py | 9 ++++----- wgpu/gui/auto.py | 2 +- wgpu/gui/base.py | 4 +--- wgpu/gui/qt.py | 51 +++++------------------------------------------ wgpu/gui/trio.py | 2 +- 5 files changed, 12 insertions(+), 56 deletions(-) diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 78180689..ed5ac3a9 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -1,5 +1,4 @@ import time -import asyncio from ._gui_utils import log_exception @@ -45,10 +44,10 @@ class AnimationScheduler: * canvas.animate.add_handler(1/30, callback) """ - def iter(self): - # Something like this? - for scheduler in all_schedulers: - scheduler._event_emitter.submit_and_dispatch(event) + # def iter(self): + # # Something like this? + # for scheduler in all_schedulers: + # scheduler._event_emitter.submit_and_dispatch(event) # todo: statistics on time spent doing what diff --git a/wgpu/gui/auto.py b/wgpu/gui/auto.py index 7aef30be..af2de762 100644 --- a/wgpu/gui/auto.py +++ b/wgpu/gui/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"] import os import sys diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 161e6f27..7324fd92 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -1,9 +1,7 @@ import sys -import time -from ._gui_utils import log_exception from ._events import EventEmitter -from ._loop import WgpuLoop, Scheduler +from ._loop import Scheduler, WgpuLoop # noqa: F401 class WgpuCanvasInterface: diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 875145b9..f755dadc 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -13,7 +13,6 @@ SYSTEM_IS_WAYLAND, get_alt_x11_display, get_alt_wayland_display, - weakbind, get_imported_qt_lib, ) @@ -540,39 +539,14 @@ class QtWgpuLoop(WgpuLoop): def __init__(self): super().__init__() self._context_for_timer = None - self._timers = {} def init_qt(self): _ = self._app - # class CallbackEventHandler(QtCore.QObject): - # - # def __init__(self): - # super().__init__() - # self.queue = dequeu() - # - # def customEvent(self, event): - # while True: - # try: - # callback, args = self.queue.get_nowait() - # except Empty: - # break - # try: - # callback(*args) - # except Exception as why: - # print("callback failed: {}:\n{}".format(callback, why)) - # - # def postEventWithCallback(self, callback, *args): - # self.queue.put((callback, args)) - # QtWidgets.qApp.postEvent(self, QtCore.QEvent(QtCore.QEvent.Type.User)) - @property def _app(self): """Return global instance of Qt app instance or create one if not created yet.""" - app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) - if self._context_for_timer is None: - self._context_for_timer = QtCore.QObject() - return app + return QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) def poll(self): # todo: can check if loop is running .... @@ -585,26 +559,11 @@ def poll(self): def call_later(self, delay, callback, *args): func = callback + if args: + func = lambda: callback(*args) - def func(): - # timer.deleteLater() - self._timers.pop(timer_id, None) - callback(*args) - - timer = QtCore.QTimer() - timer.timeout.connect(func) - timer.setSingleShot(True) - timer.setTimerType(PreciseTimer) - timer.start(int(delay * 1000)) - timer_id = id(timer) - self._timers[timer_id] = timer - # print(self._timers) - # self._timer = timer - # self._timers.append(timer) - # self._timers[:-1] = [] - # QtCore.QTimer.singleShot( - # int(delay * 1000), PreciseTimer, self._context_for_timer, func - # ) + # Would like to use the PreciseTimer flagm but there's no signature that allows that, plus a simple callback func. + QtCore.QTimer.singleShot(int(delay * 1000), func) def run(self): if already_had_app_on_import: diff --git a/wgpu/gui/trio.py b/wgpu/gui/trio.py index 98a011de..3e2ab183 100644 --- a/wgpu/gui/trio.py +++ b/wgpu/gui/trio.py @@ -1,6 +1,6 @@ """A Trio-based event loop.""" -import trio +import trio # noqa from .base import WgpuLoop From 798a9d1792b9b7135641433810622d9009dea551 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 15 Oct 2024 12:07:09 +0200 Subject: [PATCH 05/49] cleanup --- wgpu/gui/_loop.py | 12 +++++------- wgpu/gui/asyncio.py | 4 ++++ wgpu/gui/base.py | 27 ++++++--------------------- wgpu/gui/glfw.py | 1 - wgpu/gui/qt.py | 21 +++++++++++---------- wgpu/gui/trio.py | 9 --------- 6 files changed, 26 insertions(+), 48 deletions(-) delete mode 100644 wgpu/gui/trio.py diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index ed5ac3a9..99a95a66 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -2,8 +2,8 @@ from ._gui_utils import log_exception -# todo: idea: a global loop proxy object that defers to any of the other loops -# would e.g. allow using glfw with qt together. Probably to weird a use-case for the added complexity. +# 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 to too weird use-case for the added complexity. class WgpuLoop: @@ -21,7 +21,7 @@ def call_later(self, delay, callback, *args): raise NotImplementedError() def poll(self): - """Poll the underlying GUI toolkit for events. + """Poll the underlying GUI toolkit for window events. Some event loops (e.g. asyncio) are just that and dont have a GUI to update. """ @@ -50,9 +50,6 @@ class AnimationScheduler: # scheduler._event_emitter.submit_and_dispatch(event) -# todo: statistics on time spent doing what - - class Scheduler: """Helper class to schedule event processing and drawing.""" @@ -218,8 +215,9 @@ def pseudo_tick(): def event_tick(self): """A lightweight tick that processes evets and animations.""" + # Get events from the GUI into our event mechanism. - self._canvas._get_loop().poll() # todo: maybe self._process_gui_events()? + self._canvas._get_loop().poll() # Flush our events, so downstream code can update stuff. # Maybe that downstream code request a new draw. diff --git a/wgpu/gui/asyncio.py b/wgpu/gui/asyncio.py index b9809f69..0a02e549 100644 --- a/wgpu/gui/asyncio.py +++ b/wgpu/gui/asyncio.py @@ -1,5 +1,9 @@ """Implements an asyncio event loop.""" +# This is used for GUI backends that don't have an event loop by themselves, lik 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 diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 7324fd92..85e00c69 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -153,10 +153,11 @@ def loop(self): def request_draw(self, draw_function=None): """Schedule a new draw event. - 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. + 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 @@ -169,28 +170,12 @@ def request_draw(self, draw_function=None): self._scheduler.set_draw_func(draw_function) self._scheduler.request_draw() - # We don't call self._request_draw() directly but let the scheduler do that based on the policy - # self._scheduler.request_draw() - # todo: maybe have set_draw_function() separately # todo: maybe requesting a new draw can be done by setting a field in an event? - # todo: can we invoke the draw function via a draw event? - - # We can assume that this function is called when we flush events. - # So we can also maybe replace this by letting downstream code set a flag on the event object. - # In any case, we only really have to do something in ondemand mode; in other modes we draw regardless. + # todo: can just make the draw_function a handler for the draw event? def force_draw(self): self._force_draw() - def _process_input(self): - """This should process all GUI events. - - In some GUI systems, like Qt, events are already processed because the - Qt event loop is running, so this can be a no-op. In other cases, like - glfw, this hook allows glfw to do a tick. - """ - raise NotImplementedError() - def _draw_frame_and_present(self): """Draw the frame and present the result. diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index 7257d5e9..dfd20dac 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -544,7 +544,6 @@ def run(self): loop = GlfwAsyncioWgpuLoop() -# todo: loop or app? def poll_glfw_briefly(poll_time=0.1): diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index f755dadc..65bb3fcc 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -549,13 +549,7 @@ def _app(self): return QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) def poll(self): - # todo: can check if loop is running .... - pass - # Don't actually do anything, because we assume that when using a Qt canvas, the qt event loop is running. - # If necessary, we can have a different (kind of) call for users and for the scheduler. - # app = self._app - # app.sendPostedEvents() - # app.process_events() + self._app.process_events() def call_later(self, delay, callback, *args): func = callback @@ -566,12 +560,19 @@ def call_later(self, delay, callback, *args): QtCore.QTimer.singleShot(int(delay * 1000), 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 - # 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 = self._app app.exec() if hasattr(app, "exec") else app.exec_() def stop(self): diff --git a/wgpu/gui/trio.py b/wgpu/gui/trio.py deleted file mode 100644 index 3e2ab183..00000000 --- a/wgpu/gui/trio.py +++ /dev/null @@ -1,9 +0,0 @@ -"""A Trio-based event loop.""" - -import trio # noqa -from .base import WgpuLoop - - -# todo: this would be nice -class TrioWgpuLoop(WgpuLoop): - pass From 9ee8cf14a9aab2866cca44a3c83c1f2fa3b3fd89 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 15 Oct 2024 12:21:31 +0200 Subject: [PATCH 06/49] More cleaning --- README.md | 2 +- docs/gui.rst | 23 ++++++++--------------- docs/guide.rst | 2 +- tests/test_gui_auto_offscreen.py | 7 +++---- tests/test_gui_base.py | 10 +++++----- tests/test_gui_glfw.py | 3 +-- wgpu/gui/auto.py | 2 +- 7 files changed, 20 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 5f4f68f0..2d31416b 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ To render to the screen you can use a variety of GUI toolkits: ```py # The auto backend selects either the glfw, qt or jupyter backend -from wgpu.gui.auto import WgpuCanvas, run, call_later +from wgpu.gui.auto import WgpuCanvas, loop # Visualizations can be embedded as a widget in a Qt application. # Import PySide6, PyQt6, PySide2 or PyQt5 before running the line below. diff --git a/docs/gui.rst b/docs/gui.rst index 7ce65207..0159eaae 100644 --- a/docs/gui.rst +++ b/docs/gui.rst @@ -20,19 +20,18 @@ The Canvas base classes ~WgpuCanvasInterface ~WgpuCanvasBase - ~WgpuAutoGui For each supported GUI toolkit there is a module that implements a ``WgpuCanvas`` class, which inherits from :class:`WgpuCanvasBase`, providing a common API. -The GLFW, Qt, and Jupyter backends also inherit from :class:`WgpuAutoGui` to include -support for events (interactivity). In the next sections we demonstrates the different -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: @@ -46,22 +45,16 @@ across different machines and environments. Using ``wgpu.gui.auto`` selects a suitable backend depending on the environment and more. See :ref:`interactive_use` for details. -To implement interaction, the ``canvas`` has a :func:`WgpuAutoGui.handle_event()` method -that can be overloaded. Alternatively you can use it's :func:`WgpuAutoGui.add_event_handler()` -method. See the `event spec `_ -for details about the event objects. - -Also see the `triangle auto `_ -and `cube `_ examples that demonstrate the auto gui. +Also see the e.g. the `gui_auto.py `_ example. .. code-block:: py - from wgpu.gui.auto import WgpuCanvas, run, call_later + from wgpu.gui.auto import WgpuCanvas, loop canvas = WgpuCanvas(title="Example") canvas.request_draw(your_draw_function) - run() + loop.run() Support for GLFW @@ -73,12 +66,12 @@ but you can replace ``from wgpu.gui.auto`` with ``from wgpu.gui.glfw`` to force .. code-block:: py - from wgpu.gui.glfw import WgpuCanvas, run, call_later + from wgpu.gui.glfw import WgpuCanvas, loop canvas = WgpuCanvas(title="Example") canvas.request_draw(your_draw_function) - run() + loop() Support for Qt diff --git a/docs/guide.rst b/docs/guide.rst index 5f03221b..f3585e3b 100644 --- a/docs/guide.rst +++ b/docs/guide.rst @@ -19,7 +19,7 @@ GUI toolkits are supported, see the :doc:`gui`. In general, it's easiest to let .. code-block:: py - from wgpu.gui.auto import WgpuCanvas, run + from wgpu.gui.auto import WgpuCanvas canvas = WgpuCanvas(title="a wgpu example") diff --git a/tests/test_gui_auto_offscreen.py b/tests/test_gui_auto_offscreen.py index e01ef8a4..e34c02d0 100644 --- a/tests/test_gui_auto_offscreen.py +++ b/tests/test_gui_auto_offscreen.py @@ -32,14 +32,13 @@ def test_canvas_class(): 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 + from wgpu.gui.auto import loop ran = False @@ -47,8 +46,8 @@ def check(): nonlocal ran ran = True - call_later(0, check) - run() + loop.call_later(0, check) + loop.run() assert ran diff --git a/tests/test_gui_base.py b/tests/test_gui_base.py index 7174ebac..862cba39 100644 --- a/tests/test_gui_base.py +++ b/tests/test_gui_base.py @@ -117,11 +117,11 @@ def test_run_bare_canvas(): # This is (more or less) the equivalent of: # - # from wgpu.gui.auto import WgpuCanvas, run + # from wgpu.gui.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() @@ -208,8 +208,8 @@ def draw_frame(): assert canvas.frame_count == 4 -def test_autogui_mixin(): - c = wgpu.gui.WgpuAutoGui() +def test_canvas_base_events(): + c = wgpu.gui.WgpuCanvasBase() # It's a mixin assert not isinstance(c, wgpu.gui.WgpuCanvasBase) diff --git a/tests/test_gui_glfw.py b/tests/test_gui_glfw.py index d894b197..01c3939e 100644 --- a/tests/test_gui_glfw.py +++ b/tests/test_gui_glfw.py @@ -32,11 +32,10 @@ def teardown_module(): pass # Do not glfw.terminate() because other tests may still need glfw -def test_is_autogui(): +def test_is_canvas_base(): from wgpu.gui.glfw import WgpuCanvas assert issubclass(WgpuCanvas, wgpu.gui.WgpuCanvasBase) - assert issubclass(WgpuCanvas, wgpu.gui.WgpuAutoGui) def test_glfw_canvas_basics(): diff --git a/wgpu/gui/auto.py b/wgpu/gui/auto.py index af2de762..a53db5a7 100644 --- a/wgpu/gui/auto.py +++ b/wgpu/gui/auto.py @@ -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"] From baf4785ea99b7881da4a8f5521509296bf9507c6 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 15 Oct 2024 13:47:32 +0200 Subject: [PATCH 07/49] fix canvas cleanup --- tests/renderutils.py | 6 ++--- tests/test_gui_glfw.py | 45 ++++++++++++++------------------- tests_mem/test_gui_glfw.py | 3 ++- wgpu/gui/_loop.py | 51 ++++++++++++++++++++++---------------- wgpu/gui/base.py | 11 +++++--- wgpu/gui/qt.py | 3 ++- 6 files changed, 62 insertions(+), 57 deletions(-) diff --git a/tests/renderutils.py b/tests/renderutils.py index 7f8ad4d3..afe01f13 100644 --- a/tests/renderutils.py +++ b/tests/renderutils.py @@ -233,7 +233,7 @@ def render_to_screen( ): """Render to a window on screen, for debugging purposes.""" import glfw - from wgpu.gui.glfw import WgpuCanvas, update_glfw_canvasses + from wgpu.gui.glfw import WgpuCanvas, loop vbos = vbos or [] vbo_views = vbo_views or [] @@ -327,6 +327,4 @@ def draw_frame(): canvas.request_draw(draw_frame) # Enter main loop - while update_glfw_canvasses(): - glfw.poll_events() - glfw.terminate() + loop.run() diff --git a/tests/test_gui_glfw.py b/tests/test_gui_glfw.py index 01c3939e..7b14275c 100644 --- a/tests/test_gui_glfw.py +++ b/tests/test_gui_glfw.py @@ -60,34 +60,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 + from wgpu.gui.glfw import WgpuCanvas, loop - loop = asyncio.get_event_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,9 +104,12 @@ def test_glfw_canvas_render(): """Render an orange square ... in a glfw window.""" import glfw - from wgpu.gui.glfw import update_glfw_canvasses, WgpuCanvas + from wgpu.gui.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) @@ -128,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 @@ -151,7 +142,7 @@ 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() diff --git a/tests_mem/test_gui_glfw.py b/tests_mem/test_gui_glfw.py index 7754dff4..901ce0a8 100644 --- a/tests_mem/test_gui_glfw.py +++ b/tests_mem/test_gui_glfw.py @@ -18,7 +18,7 @@ if not can_use_glfw: pytest.skip("Need glfw for this test", allow_module_level=True) -loop = asyncio.get_event_loop_policy().get_event_loop() +loop = asyncio.get_event_loop() if loop.is_running(): pytest.skip("Asyncio loop is running", allow_module_level=True) @@ -54,6 +54,7 @@ def test_release_canvas_context(n): yield c.get_context() # Need some shakes to get all canvas refs gone. + # Note that the draw function closure holds a ref to the canvas. del c loop.run_until_complete(stub_event_loop()) gc.collect() diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 99a95a66..6d0b2fc1 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -1,4 +1,5 @@ import time +import weakref from ._gui_utils import log_exception @@ -93,19 +94,21 @@ class Scheduler: # schedule pseuso_tick pseuso_tick # + event_tick - def __init__(self, canvas, *, min_fps=1, max_fps=30): - # Objects related to the canvas - self._canvas = canvas + def __init__(self, canvas, *, mode="ondemand", min_fps=1, max_fps=30): + # Objects related to the canvas. + # 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 - - # The draw function - self._draw_frame = lambda: None + self._loop = canvas._get_loop() + # ... = canvas.get_context() -> No, context creation should be lazy! # Lock the scheduling while its waiting self._waiting_lock = False # Scheduling variables - self._mode = "continuous" + self._mode = mode self._min_fps = float(min_fps) self._max_fps = float(max_fps) self._draw_requested = True @@ -120,11 +123,12 @@ def __init__(self, canvas, *, min_fps=1, max_fps=30): # Start by doing the first scheduling. # Note that the gui may do a first draw earlier, starting the loop, and that's fine. - canvas._get_loop().call_later(0.1, self._schedule_next_tick) + self._loop.call_later(0.1, self._schedule_next_tick) - def set_draw_func(self, draw_frame): - """Set the callable that must be called to do a draw.""" - self._draw_frame = draw_frame + def _get_canvas(self): + canvas = self._canvas_ref() + if not (canvas is None or canvas.is_closed()): + return canvas def request_draw(self): """Request a new draw to be done. Only affects the 'ondemand' mode.""" @@ -183,13 +187,17 @@ def pseudo_tick(): # Enable scheduling again self._waiting_lock = False + # Get canvas or stop + if (canvas := self._get_canvas()) is None: + return + if self._mode == "fastest": # fastest: draw continuously as fast as possible, ignoring fps settings. - self._canvas._request_draw() + canvas._request_draw() elif self._mode == "continuous": # continuous: draw continuously, aiming for a steady max framerate. - self._canvas._request_draw() + canvas._request_draw() elif self._mode == "ondemand": # ondemand: draw when needed (detected by calls to request_draw). @@ -199,7 +207,7 @@ def pseudo_tick(): time.perf_counter() - self._last_draw_time > 1 / self._min_fps ) if self._draw_requested or its_draw_time: - self._canvas._request_draw() + canvas._request_draw() else: self._schedule_next_tick() @@ -211,13 +219,13 @@ def pseudo_tick(): else: raise RuntimeError(f"Unexpected scheduling mode: '{self._mode}'") - self._canvas._get_loop().call_later(delay, pseudo_tick) + self._loop.call_later(delay, pseudo_tick) def event_tick(self): """A lightweight tick that processes evets and animations.""" # Get events from the GUI into our event mechanism. - self._canvas._get_loop().poll() + self._loop.poll() # Flush our events, so downstream code can update stuff. # Maybe that downstream code request a new draw. @@ -250,7 +258,7 @@ def draw_tick(self): # It could be that the canvas is closed now. When that happens, # we stop here and do not schedule a new iter. - if self._canvas.is_closed(): + if (canvas := self._get_canvas()) is None: return # Keep ticking @@ -274,14 +282,15 @@ def draw_tick(self): # Stats (uncomment to see fps) count, last_time = self._draw_stats fps = count / (time.perf_counter() - last_time) - self._canvas.set_title(f"wgpu {fps:0.1f} fps") + canvas.set_title(f"wgpu {fps:0.1f} fps") # 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() + canvas._draw_frame() with log_exception("Present error"): - context = self._canvas._canvas_context + # 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 = canvas._canvas_context if context: context.present() - # Note, if vsync is used, this call may wait a little (happens down at the level of the driver or OS) diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 85e00c69..6aa86c0b 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -103,7 +103,6 @@ class WgpuCanvasBase(WgpuCanvasInterface): def __init__( self, *args, - min_fps=1, max_fps=30, vsync=True, present_method=None, @@ -114,11 +113,13 @@ def __init__( self._vsync = bool(vsync) present_method # noqa - We just catch the arg here in case a backend does implement support it + self._draw_frame = lambda: None + self._events = EventEmitter() self._scheduler = None if use_scheduler: - self._scheduler = Scheduler(self, min_fps=min_fps, max_fps=max_fps) + self._scheduler = Scheduler(self, max_fps=max_fps) def __del__(self): # On delete, we call the custom close method. @@ -167,11 +168,15 @@ def request_draw(self, draw_function=None): if self._scheduler is None: return if draw_function is not None: - self._scheduler.set_draw_func(draw_function) + self._draw_frame = draw_function self._scheduler.request_draw() # todo: maybe requesting a new draw can be done by setting a field in an event? # todo: can just make the draw_function a handler for the draw event? + # -> Note that the draw func is likely to hold a ref to the canvas. By storing it + # here, the circular ref can be broken. This fails if we'd store _draw_frame on the + # scheduler! So with a draw event, we should provide the context and more info so + # that a draw funcion does not need the canvas object. def force_draw(self): self._force_draw() diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 65bb3fcc..89174cbe 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -549,7 +549,8 @@ def _app(self): return QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) def poll(self): - self._app.process_events() + # todo: make this a private method with a wgpu prefix. + pass # we assume the Qt event loop is running. Calling processEvents() will cause recursive repaints. def call_later(self, delay, callback, *args): func = callback From 2d3d9266d0ddd00d4370c5d822efff2cd4b58947 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 15 Oct 2024 14:33:06 +0200 Subject: [PATCH 08/49] Cleanup --- docs/gui.rst | 2 +- wgpu/gui/glfw.py | 2 +- wgpu/gui/qt.py | 14 -------------- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/docs/gui.rst b/docs/gui.rst index 0159eaae..ed6d4eb1 100644 --- a/docs/gui.rst +++ b/docs/gui.rst @@ -71,7 +71,7 @@ but you can replace ``from wgpu.gui.auto`` with ``from wgpu.gui.glfw`` to force canvas = WgpuCanvas(title="Example") canvas.request_draw(your_draw_function) - loop() + loop.run() Support for Qt diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index dfd20dac..4bd330af 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -298,7 +298,7 @@ def _get_loop(self): return loop def _request_draw(self): - self._get_loop().call_soon(self._draw_frame_and_present) + loop.call_soon(self._draw_frame_and_present) def _force_draw(self): self._draw_frame_and_present() diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 89174cbe..b9d6666b 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -170,8 +170,6 @@ def __init__(self, *args, present_method=None, **kwargs): self.setMouseTracking(True) self.setFocusPolicy(FocusPolicy.StrongFocus) - self._qt_draw_requested = False - 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: @@ -179,9 +177,6 @@ def paintEngine(self): # noqa: N802 - this is a Qt method else: return super().paintEngine() - # def update(self): - # pass - def paintEvent(self, event): # noqa: N802 - this is a Qt method self._draw_frame_and_present() @@ -189,12 +184,10 @@ def paintEvent(self, event): # noqa: N802 - this is a Qt method def _request_draw(self): # Ask Qt to do a paint event - self._qt_draw_requested = True QtWidgets.QWidget.update(self) def _force_draw(self): # Call the paintEvent right now - self._qt_draw_requested = True self.repaint() def _get_loop(self): @@ -457,10 +450,7 @@ def __init__( self._subwidget = QWgpuWidget( self, max_fps=max_fps, present_method=present_method ) - self._events = self._subwidget._events - # self._scheduler._canvas = None - # self._scheduler = self._subwidget.scheduler # Note: At some point we called `self._subwidget.winId()` here. For some # reason this was needed to "activate" the canvas. Otherwise the viz was @@ -476,10 +466,6 @@ def __init__( # Qt methods - # def update(self): - # super().update() - # self._subwidget.update() - # Methods that we add from wgpu (snake_case) def _request_draw(self): From fad1f68d07fb5a5c96cc94b4a2cf668f8f71951e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 15 Oct 2024 14:46:05 +0200 Subject: [PATCH 09/49] Implement offscreen --- wgpu/gui/offscreen.py | 75 +++++++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/wgpu/gui/offscreen.py b/wgpu/gui/offscreen.py index b9ce8983..a6b975e1 100644 --- a/wgpu/gui/offscreen.py +++ b/wgpu/gui/offscreen.py @@ -1,9 +1,9 @@ import time -from .base import WgpuCanvasBase, WgpuAutoGui +from .base import WgpuCanvasBase, WgpuLoop -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. @@ -49,10 +49,16 @@ def close(self): def is_closed(self): return self._closed + def _get_loop(self): + return loop + def _request_draw(self): # Deliberately a no-op, because people use .draw() instead. pass + def _force_draw(self): + self._draw_frame_and_present() + def draw(self): """Perform a draw and get the resulting image. @@ -60,40 +66,47 @@ def draw(self): This object can be converted to a numpy array (without copying data) using ``np.asarray(arr)``. """ - self._draw_frame_and_present() + self._force_draw() return self._last_image 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 = [] - - -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 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. + + def __init__(self): + super().__init__() + self._pending_calls = [] + + def call_later(self, delay, callback, *args): + # Note that this module never calls call_later() itself; request_draw() is a no-op. + etime = time.time() + delay + self._pending_calls.append((etime, callback, args)) + + def run(self): + # Process pending calls + for etime, callback, args in self._pending_calls.copy(): + if time.time() >= etime: + callback(*args) + + # Clear any leftover scheduled calls, to avoid lingering refs. + self._pending_calls.clear() + + 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() From ed6f81bde5d5e08e36606827eb13f85884ba17f5 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 15 Oct 2024 15:02:57 +0200 Subject: [PATCH 10/49] Start on wx adjustments. Need to finish on machine with wx :) --- wgpu/gui/qt.py | 4 --- wgpu/gui/wx.py | 85 +++++++++++++++++++++++++++++--------------------- 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index b9d6666b..f5953062 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -522,10 +522,6 @@ def present_image(self, image, **kwargs): class QtWgpuLoop(WgpuLoop): - def __init__(self): - super().__init__() - self._context_for_timer = None - def init_qt(self): _ = self._app diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index f314d244..1a45b365 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -14,9 +14,8 @@ SYSTEM_IS_WAYLAND, get_alt_x11_display, get_alt_wayland_display, - weakbind, ) -from .base import WgpuCanvasBase, WgpuAutoGui +from .base import WgpuCanvasBase, WgpuLoop BUTTON_MAP = { @@ -132,7 +131,7 @@ def Notify(self, *args): # noqa: N802 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): @@ -170,6 +169,9 @@ 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) + def _get_loop(self): + return loop + def on_paint(self, event): dc = wx.PaintDC(self) # needed for wx if not self._draw_lock: @@ -189,7 +191,7 @@ def _on_resize(self, event: wx.SizeEvent): "height": float(size.GetHeight()), "pixel_ratio": self.get_pixel_ratio(), } - self._handle_event_and_flush(ev) + self._events.submit(ev) def _on_resize_done(self, *args): self._draw_lock = False @@ -220,7 +222,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._events.submit(ev) def _char_input_event(self, char_str: Optional[str]): if char_str is None: @@ -231,7 +233,7 @@ def _char_input_event(self, char_str: Optional[str]): "char_str": char_str, "modifiers": None, } - self._handle_event_and_flush(ev) + self._events.submit(ev) @staticmethod def _get_char_from_event(event: wx.KeyEvent) -> Optional[str]: @@ -300,19 +302,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._events.submit(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._hand_event.submit(ev) else: - self._handle_event_and_flush(ev) + self._events.submit(ev) def _on_mouse_events(self, event: wx.MouseEvent): event_type = event.GetEventType() @@ -419,7 +413,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. @@ -434,8 +428,8 @@ def __init__( present_method=None, **kwargs, ): - get_app() - super().__init__(parent, **kwargs) + loop.init_wx() + super().__init__(parent, use_scheduler=False, **kwargs) self.set_logical_size(*(size or (640, 480))) self.SetTitle(title or "wx wgpu canvas") @@ -443,18 +437,20 @@ def __init__( self._subwidget = WxWgpuWindow( parent=self, max_fps=max_fps, present_method=present_method ) - self._subwidget.add_event_handler(weakbind(self.handle_event), "*") + self._events = self._subwidget._events self.Bind(wx.EVT_CLOSE, lambda e: self.Destroy()) self.Show() # wx methods - def Refresh(self): # noqa: N802 - super().Refresh() - self._subwidget.Refresh() + # def Refresh(self) + # super().Refresh() + # self._subwidget.Refresh() # Methods that we add from wgpu + def _get_loop(self): + return loop def get_present_info(self): return self._subwidget.get_present_info() @@ -480,7 +476,7 @@ def _request_draw(self): return self._subwidget._request_draw() def close(self): - self._handle_event_and_flush({"event_type": "close"}) + self._events.submit({"event_type": "close"}) super().close() def is_closed(self): @@ -502,18 +498,35 @@ def present_image(self, image, **kwargs): WgpuWidget = WxWgpuWindow WgpuCanvas = WxWgpuCanvas -_the_app = None +class WxWgpuLoop(WgpuLoop): + def __init__(self): + super.__init__() + self._the_app = None + + def init_wx(self): + _ = self._app + + @property + def _app(self): + app = wx.App.GetInstance() + if app is None: + self._the_app = app = wx.App() + wx.App.SetInstance(app) + return app + + def poll(self): + pass # We can assume the wx loop is running. + + def call_later(self, delay, callback, *args): + wx.CallLater(int(delay * 1000), callback, args) + # todo: does this work, or do we need to keep a ref to the result? + + def run(self): + self._app.MainLoop() -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 stop(self): + pass # Possible with wx? -def run(): - get_app().MainLoop() +loop = WxWgpuLoop() From ebf3286aace430e6715a5e55ccdbf7f9e44d1212 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 15 Oct 2024 15:21:06 +0200 Subject: [PATCH 11/49] Implement jupyter canvas - untested --- wgpu/gui/jupyter.py | 51 +++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/wgpu/gui/jupyter.py b/wgpu/gui/jupyter.py index 082223ae..1eed7384 100644 --- a/wgpu/gui/jupyter.py +++ b/wgpu/gui/jupyter.py @@ -4,19 +4,16 @@ """ 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 +24,9 @@ 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 # 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: @@ -51,7 +47,6 @@ def handle_event(self, event): super().handle_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 @@ -61,6 +56,9 @@ def get_frame(self): # Implementation needed for WgpuCanvasBase + def _get_loop(self): + return loop + def get_pixel_ratio(self): return self._pixel_ratio @@ -86,9 +84,10 @@ 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) + RemoteFrameBuffer.request_draw(self) + + def _force_draw(self): + raise NotImplementedError() # todo: how? # Implementation needed for WgpuCanvasInterface @@ -111,16 +110,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 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() From dbd105f2daba1f3fd74dbcecb3b854e38229f71a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 16 Oct 2024 21:59:10 +0200 Subject: [PATCH 12/49] small simplification to CanvasInterface --- wgpu/gui/base.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 6aa86c0b..72dc598d 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -13,10 +13,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. From bcce81ab02d9676a08dc5599bb593aa0f2c42850 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 17 Oct 2024 12:24:58 +0200 Subject: [PATCH 13/49] work --- tests/test_gui_base.py | 24 +- tests/test_gui_events.py | 242 ++++++++++++++++++ ...nt.py => test_wgpu_native_set_constant.py} | 0 ...de.py => test_wgpu_native_set_override.py} | 0 wgpu/gui/_events.py | 12 +- wgpu/gui/_loop.py | 17 +- wgpu/gui/base.py | 65 +++-- wgpu/gui/glfw.py | 16 +- wgpu/gui/qt.py | 12 +- wgpu/gui/wx.py | 12 +- 10 files changed, 327 insertions(+), 73 deletions(-) create mode 100644 tests/test_gui_events.py rename tests/{test_set_constant.py => test_wgpu_native_set_constant.py} (100%) rename tests/{test_set_override.py => test_wgpu_native_set_override.py} (100%) diff --git a/tests/test_gui_base.py b/tests/test_gui_base.py index 862cba39..9fdcb638 100644 --- a/tests/test_gui_base.py +++ b/tests/test_gui_base.py @@ -211,9 +211,6 @@ def draw_frame(): def test_canvas_base_events(): c = wgpu.gui.WgpuCanvasBase() - # It's a mixin - assert not isinstance(c, wgpu.gui.WgpuCanvasBase) - # It's event handling mechanism should be fully functional events = [] @@ -221,20 +218,13 @@ def test_canvas_base_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] + c.add_event_handler(handler, "resize") + c.submit_event({"event_type": "resize", "value": 1}) + c.submit_event({"event_type": "resize", "value": 2}) + c.remove_event_handler(handler) + c.submit_event({"event_type": "resize", "value": 3}) + + assert events == [1, 2] def test_weakbind(): diff --git a/tests/test_gui_events.py b/tests/test_gui_events.py new file mode 100644 index 00000000..3c6a9386 --- /dev/null +++ b/tests/test_gui_events.py @@ -0,0 +1,242 @@ +""" +Test the EventEmitter. +""" + +import time + +from wgpu.gui._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 resize and close events below :) + + +def test_events_basic(): + + ee = EventEmitter() + + values = [] + def handler(event): + values.append(event["value"]) + + ee.add_handler(handler, "resize") + + ee.submit({"event_type": "resize", "value": 1}) + ee.submit({"event_type": "resize", "value": 2}) + assert values == [] + + ee.flush() + ee.submit({"event_type": "resize", "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": "resize", "value": 4}) + ee.remove_handler(handler, "resize") + ee.submit({"event_type": "resize", "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("resize", "close", handler) + + with pytest.raises(TypeError): + ee.add_handler("resize", handler, "close") + + +def test_events_handler_decorated(): + + ee = EventEmitter() + + values = [] + + @ee.add_handler("resize", "close") + def handler(event): + values.append(event["value"]) + + ee.submit({"event_type": "resize", "value": 1}) + ee.submit({"event_type": "close", "value": 2}) + ee.flush() + assert values == [1, 2] + + +def test_events_two_types(): + + ee = EventEmitter() + + values = [] + def handler(event): + values.append(event["value"]) + + ee.add_handler(handler, "resize", "close") + + ee.submit({"event_type": "resize", "value": 1}) + ee.submit({"event_type": "close", "value": 2}) + ee.flush() + assert values == [1, 2] + + ee.remove_handler(handler, "resize") + ee.submit({"event_type": "resize", "value": 3}) + ee.submit({"event_type": "close", "value": 4}) + ee.flush() + assert values == [1, 2, 4] + + ee.remove_handler(handler, "close") + ee.submit({"event_type": "resize", "value": 5}) + ee.submit({"event_type": "close", "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, "resize") + ee.add_handler(handler2, "resize") + + ee.submit({"event_type": "resize", "value": 1}) + ee.flush() + assert values == [101, 201] + + ee.remove_handler(handler1, "resize") + ee.submit({"event_type": "resize", "value": 2}) + ee.flush() + assert values == [101, 201, 202] + + ee.remove_handler(handler2, "resize") + ee.submit({"event_type": "resize", "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"]) + + # handler3 goes first, the other two maintain order + ee.add_handler(handler1, "resize") + ee.add_handler(handler2, "resize") + ee.add_handler(handler3, "resize", order=-1) + + ee.submit({"event_type": "resize", "value": 1}) + ee.flush() + assert values == [301, 101, 201] + + +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, "resize") + ee.add_handler(handler, "resize") + + ee.submit({"event_type": "resize", "value": 1}) + ee.flush() + assert values == [1] + + ee.remove_handler(handler, "resize") + ee.submit({"event_type": "resize", "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), "resize") + ee.add_handler(lambda e:handler(e), "resize") + + ee.submit({"event_type": "resize", "value": 1}) + ee.flush() + assert values == [1, 1] + + ee.remove_handler(handler, "resize") + ee.submit({"event_type": "resize", "value": 2}) + ee.flush() + assert values == [1, 1, 2, 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), "resize", order=1) + ee.add_handler(lambda e:handler(e), "resize", order=2) + t1 = time.perf_counter() - t0 + + t0 = time.perf_counter() + for _ in range(100): + ee.submit({"event_type": "resize", "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_set_constant.py b/tests/test_wgpu_native_set_constant.py similarity index 100% rename from tests/test_set_constant.py rename to tests/test_wgpu_native_set_constant.py diff --git a/tests/test_set_override.py b/tests/test_wgpu_native_set_override.py similarity index 100% rename from tests/test_set_override.py rename to tests/test_wgpu_native_set_override.py diff --git a/wgpu/gui/_events.py b/wgpu/gui/_events.py index e841f82e..ea8bc95a 100644 --- a/wgpu/gui/_events.py +++ b/wgpu/gui/_events.py @@ -107,15 +107,21 @@ def my_handler(event): raise ValueError(f"Adding handler with invalid event_type: '{type}'") def decorator(_callback): - for type in types: - self._event_handlers[type].append((order, _callback)) - self._event_handlers[type].sort(key=lambda x: x[0]) + 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. diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 6d0b2fc1..99b26e0f 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -21,7 +21,7 @@ def call_later(self, delay, callback, *args): """Arrange for a callback to be called after the given delay (in seconds).""" raise NotImplementedError() - def poll(self): + def _wgpu_gui_poll(self): """Poll the underlying GUI toolkit for window events. Some event loops (e.g. asyncio) are just that and dont have a GUI to update. @@ -94,16 +94,19 @@ class Scheduler: # schedule pseuso_tick pseuso_tick # + event_tick - def __init__(self, canvas, *, mode="ondemand", min_fps=1, max_fps=30): + def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30): # Objects related to the canvas. # 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 - self._loop = canvas._get_loop() # ... = canvas.get_context() -> No, context creation should be lazy! + # We need to call_later and process gui events. The loop object abstracts these. + self._loop = loop + assert loop is not None + # Lock the scheduling while its waiting self._waiting_lock = False @@ -225,7 +228,7 @@ def event_tick(self): """A lightweight tick that processes evets and animations.""" # Get events from the GUI into our event mechanism. - self._loop.poll() + self._loop._wgpu_gui_poll() # Flush our events, so downstream code can update stuff. # Maybe that downstream code request a new draw. @@ -237,7 +240,7 @@ def event_tick(self): 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}) + self._submit_event({"event_type": "animate", "step": step, "catch_up": 0}) # Do the animations. This costs time. self._events.flush() # Abort when we cannot keep up @@ -246,7 +249,7 @@ def event_tick(self): if animation_iters > 20: n = (time.perf_counter() - self._animation_time) // step self._animation_time += step * n - self._events.submit( + self._submit_event( {"event_type": "animate", "step": step * n, "catch_up": n} ) @@ -266,7 +269,7 @@ def draw_tick(self): self._schedule_next_tick() # Special event for drawing - self._events.submit({"event_type": "before_draw"}) + self._submit_event({"event_type": "before_draw"}) self._events.flush() # Schedule a new draw right before doing the draw. Important that it happens *after* processing events. diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 72dc598d..57a61cd7 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -108,15 +108,16 @@ def __init__( ): super().__init__(*args, **kwargs) 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 self._draw_frame = lambda: None self._events = EventEmitter() self._scheduler = None - if use_scheduler: - self._scheduler = Scheduler(self, max_fps=max_fps) + loop = self._get_loop() + if loop and use_scheduler: + self._scheduler = Scheduler(self, loop, max_fps=max_fps) def __del__(self): # On delete, we call the custom close method. @@ -139,15 +140,18 @@ def add_event_handler(self, *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__ # === Scheduling - @property - def loop(self): - return self._get_loop() - def request_draw(self, draw_function=None): """Schedule a new draw event. @@ -176,6 +180,7 @@ def request_draw(self, draw_function=None): # that a draw funcion does not need the canvas object. def force_draw(self): + """Perform a draw right now.""" self._force_draw() def _draw_frame_and_present(self): @@ -184,43 +189,38 @@ def _draw_frame_and_present(self): Errors are logged to the "wgpu" logger. Should be called by the subclass at an appropriate time. """ - # 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. - # So this call must to the complete tick. + # 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. So this + # call must to the complete tick. if self._scheduler is not None: self._scheduler.draw_tick() - # === Canvas management methods - def _get_loop(self): - """Must return the global loop instance.""" - raise NotImplementedError() + """Must return the global loop instance (WgpuLoop) for the canvas subclass, or None for a non-interactive canvas.""" + return None def _request_draw(self): - """Like requestAnimationFrame in JS. Must schedule a call to self._scheduler.draw() ???""" + """Request the GUI layer to perform a draw. Like requestAnimationFrame in JS. + The draw must be performed by calling self._draw_frame_and_present() + """ raise NotImplementedError() def _force_draw(self): - """Perform a draw right now.""" - raise NotImplementedError() - - def get_pixel_ratio(self): - """Get the float ratio between logical and physical pixels.""" + """Perform a synchronous draw. When it returns, the draw must have been done.""" raise NotImplementedError() - def get_logical_size(self): - """Get the logical size in float pixels.""" - raise NotImplementedError() + # === Primary canvas management methods 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).""" + def get_logical_size(self): + """Get the logical size in float pixels.""" raise NotImplementedError() - def set_title(self, title): - """Set the window title.""" + def get_pixel_ratio(self): + """Get the float ratio between logical and physical pixels.""" raise NotImplementedError() def close(self): @@ -230,3 +230,16 @@ def close(self): def is_closed(self): """Get whether the window is closed.""" raise NotImplementedError() + + # === Secondary canvas management methods + + # These methods provide extra control over the canvas. Subclasses should + # implement the methods they can, but these features are likely not critical. + + def set_logical_size(self, width, height): + """Set the window size (in logical pixels).""" + pass + + def set_title(self, title): + """Set the window title.""" + pass diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index 4bd330af..5ff67c7b 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -225,7 +225,7 @@ def _on_close(self, *args): if self._window is not None: glfw.destroy_window(self._window) # not just glfw.hide_window self._window = None - self._events.submit({"event_type": "close"}) + self.submit_event({"event_type": "close"}) def _on_window_dirty(self, *args): self.request_draw() @@ -254,7 +254,7 @@ def _determine_size(self): "height": self._logical_size[1], "pixel_ratio": self._pixel_ratio, } - self._events.submit(ev) + self.submit_event(ev) def _set_logical_size(self, new_logical_size): if self._window is None: @@ -372,7 +372,7 @@ def _on_mouse_button(self, window, but, action, mods): } # Emit the current event - self._events.submit(ev) + self.submit_event(ev) # Maybe emit a double-click event self._follow_double_click(action, button) @@ -424,7 +424,7 @@ def _follow_double_click(self, action, button): "ntouches": 0, # glfw does not have touch support "touches": {}, } - self._events.submit(ev) + self.submit_event(ev) def _on_cursor_pos(self, window, x, y): # Store pointer position in logical coordinates @@ -444,7 +444,7 @@ def _on_cursor_pos(self, window, x, y): "touches": {}, } - self._events.submit(ev) + self.submit_event(ev) def _on_scroll(self, window, dx, dy): # wheel is 1 or -1 in glfw, in jupyter_rfb this is ~100 @@ -457,7 +457,7 @@ def _on_scroll(self, window, dx, dy): "buttons": tuple(self._pointer_buttons), "modifiers": tuple(self._key_modifiers), } - self._events.submit(ev) + self.submit_event(ev) def _on_key(self, window, key, scancode, action, mods): modifier = KEY_MAP_MOD.get(key, None) @@ -497,7 +497,7 @@ def _on_key(self, window, key, scancode, action, mods): "key": keyname, "modifiers": tuple(self._key_modifiers), } - self._events.submit(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 @@ -506,7 +506,7 @@ def _on_char(self, window, char): "char_str": chr(char), "modifiers": tuple(self._key_modifiers), } - self._events.submit(ev) + self.submit_event(ev) def present_image(self, image, **kwargs): raise NotImplementedError() diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index f5953062..d7d403e8 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -287,7 +287,7 @@ def _key_event(self, event_type, event): "key": KEY_MAP.get(event.key(), event.text()), "modifiers": modifiers, } - self._events.submit(ev) + self.submit_event(ev) def _char_input_event(self, char_str): ev = { @@ -295,7 +295,7 @@ def _char_input_event(self, char_str): "char_str": char_str, "modifiers": None, } - self._events.submit(ev) + self.submit_event(ev) def keyPressEvent(self, event): # noqa: N802 self._key_event("key_down", event) @@ -340,7 +340,7 @@ def _mouse_event(self, event_type, event, touches=True): } ) - self._events.submit(ev) + self.submit_event(ev) def mousePressEvent(self, event): # noqa: N802 self._mouse_event("pointer_down", event) @@ -377,7 +377,7 @@ def wheelEvent(self, event): # noqa: N802 "buttons": buttons, "modifiers": modifiers, } - self._events.submit(ev) + self.submit_event(ev) def resizeEvent(self, event): # noqa: N802 ev = { @@ -386,10 +386,10 @@ def resizeEvent(self, event): # noqa: N802 "height": float(event.size().height()), "pixel_ratio": self.get_pixel_ratio(), } - self._events.submit(ev) + self.submit_event(ev) def closeEvent(self, event): # noqa: N802 - self._events.submit({"event_type": "close"}) + self.submit_event({"event_type": "close"}) # Methods related to presentation of resulting image data diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index 1a45b365..a6683f14 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -191,7 +191,7 @@ def _on_resize(self, event: wx.SizeEvent): "height": float(size.GetHeight()), "pixel_ratio": self.get_pixel_ratio(), } - self._events.submit(ev) + self.submit_event(ev) def _on_resize_done(self, *args): self._draw_lock = False @@ -222,7 +222,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._events.submit(ev) + self.submit_event(ev) def _char_input_event(self, char_str: Optional[str]): if char_str is None: @@ -233,7 +233,7 @@ def _char_input_event(self, char_str: Optional[str]): "char_str": char_str, "modifiers": None, } - self._events.submit(ev) + self.submit_event(ev) @staticmethod def _get_char_from_event(event: wx.KeyEvent) -> Optional[str]: @@ -302,11 +302,11 @@ def _mouse_event(self, event_type: str, event: wx.MouseEvent, touches: bool = Tr ev.update({"dx": -dx, "dy": -dy}) - self._events.submit(ev) + self.submit_event(ev) elif event_type == "pointer_move": self._hand_event.submit(ev) else: - self._events.submit(ev) + self.submit_event(ev) def _on_mouse_events(self, event: wx.MouseEvent): event_type = event.GetEventType() @@ -476,7 +476,7 @@ def _request_draw(self): return self._subwidget._request_draw() def close(self): - self._events.submit({"event_type": "close"}) + self.submit_event({"event_type": "close"}) super().close() def is_closed(self): From dfed135d1014dd8d113ae91bde9089abb38c136a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 17 Oct 2024 14:23:42 +0200 Subject: [PATCH 14/49] test event merging --- examples/gui_auto.py | 4 +- tests/test_gui_events.py | 152 +++++++++++++++++++++++++-------------- wgpu/gui/_events.py | 22 +++--- wgpu/gui/_loop.py | 6 +- wgpu/gui/glfw.py | 2 +- wgpu/gui/jupyter.py | 2 +- wgpu/gui/qt.py | 2 +- wgpu/gui/wx.py | 2 +- 8 files changed, 121 insertions(+), 71 deletions(-) diff --git a/examples/gui_auto.py b/examples/gui_auto.py index 9571f71d..5c9e0820 100644 --- a/examples/gui_auto.py +++ b/examples/gui_auto.py @@ -4,7 +4,7 @@ # test_example = true -from wgpu.gui.auto import WgpuCanvas, run +from wgpu.gui.auto import WgpuCanvas, loop from triangle import setup_drawing_sync # from cube import setup_drawing_sync @@ -21,4 +21,4 @@ def animate(): if __name__ == "__main__": - run() + loop.run() diff --git a/tests/test_gui_events.py b/tests/test_gui_events.py index 3c6a9386..a1c8b712 100644 --- a/tests/test_gui_events.py +++ b/tests/test_gui_events.py @@ -23,98 +23,95 @@ def handler(event): with pytest.raises(ValueError): ee.add_handler(handler, "not_a_valid_event_type") - # This is why we use resize and close events below :) + # 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, "resize") + ee.add_handler(handler, "key_down") - ee.submit({"event_type": "resize", "value": 1}) - ee.submit({"event_type": "resize", "value": 2}) + ee.submit({"event_type": "key_down", "value": 1}) + ee.submit({"event_type": "key_down", "value": 2}) assert values == [] ee.flush() - ee.submit({"event_type": "resize", "value": 3}) + 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": "resize", "value": 4}) - ee.remove_handler(handler, "resize") - ee.submit({"event_type": "resize", "value": 5}) + 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("resize", "close", handler) + ee.add_handler("key_down", "key_up", handler) with pytest.raises(TypeError): - ee.add_handler("resize", handler, "close") + ee.add_handler("key_down", handler, "key_up") def test_events_handler_decorated(): - ee = EventEmitter() values = [] - @ee.add_handler("resize", "close") + @ee.add_handler("key_down", "key_up") def handler(event): values.append(event["value"]) - ee.submit({"event_type": "resize", "value": 1}) - ee.submit({"event_type": "close", "value": 2}) + ee.submit({"event_type": "key_down", "value": 1}) + ee.submit({"event_type": "key_up", "value": 2}) ee.flush() assert values == [1, 2] def test_events_two_types(): - ee = EventEmitter() values = [] + def handler(event): values.append(event["value"]) - ee.add_handler(handler, "resize", "close") + ee.add_handler(handler, "key_down", "key_up") - ee.submit({"event_type": "resize", "value": 1}) - ee.submit({"event_type": "close", "value": 2}) + 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, "resize") - ee.submit({"event_type": "resize", "value": 3}) - ee.submit({"event_type": "close", "value": 4}) + 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, "close") - ee.submit({"event_type": "resize", "value": 5}) - ee.submit({"event_type": "close", "value": 6}) + 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 = [] @@ -125,26 +122,25 @@ def handler1(event): def handler2(event): values.append(200 + event["value"]) - ee.add_handler(handler1, "resize") - ee.add_handler(handler2, "resize") + ee.add_handler(handler1, "key_down") + ee.add_handler(handler2, "key_down") - ee.submit({"event_type": "resize", "value": 1}) + ee.submit({"event_type": "key_down", "value": 1}) ee.flush() assert values == [101, 201] - ee.remove_handler(handler1, "resize") - ee.submit({"event_type": "resize", "value": 2}) + 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, "resize") - ee.submit({"event_type": "resize", "value": 3}) + 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 = [] @@ -159,17 +155,16 @@ def handler3(event): values.append(300 + event["value"]) # handler3 goes first, the other two maintain order - ee.add_handler(handler1, "resize") - ee.add_handler(handler2, "resize") - ee.add_handler(handler3, "resize", order=-1) + ee.add_handler(handler1, "key_down") + ee.add_handler(handler2, "key_down") + ee.add_handler(handler3, "key_down", order=-1) - ee.submit({"event_type": "resize", "value": 1}) + ee.submit({"event_type": "key_down", "value": 1}) ee.flush() assert values == [301, 101, 201] def test_events_duplicate_handler(): - ee = EventEmitter() values = [] @@ -178,21 +173,20 @@ def handler(event): values.append(event["value"]) # Registering for the same event_type twice just adds it once - ee.add_handler(handler, "resize") - ee.add_handler(handler, "resize") + ee.add_handler(handler, "key_down") + ee.add_handler(handler, "key_down") - ee.submit({"event_type": "resize", "value": 1}) + ee.submit({"event_type": "key_down", "value": 1}) ee.flush() assert values == [1] - ee.remove_handler(handler, "resize") - ee.submit({"event_type": "resize", "value": 2}) + 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 = [] @@ -201,19 +195,69 @@ def handler(event): values.append(event["value"]) # Cannot discern now, these are two different handlers - ee.add_handler(lambda e:handler(e), "resize") - ee.add_handler(lambda e:handler(e), "resize") + ee.add_handler(lambda e: handler(e), "key_down") + ee.add_handler(lambda e: handler(e), "key_down") - ee.submit({"event_type": "resize", "value": 1}) + ee.submit({"event_type": "key_down", "value": 1}) ee.flush() assert values == [1, 1] - ee.remove_handler(handler, "resize") - ee.submit({"event_type": "resize", "value": 2}) + 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. @@ -225,13 +269,13 @@ def handler(event): t0 = time.perf_counter() for _ in range(1000): - ee.add_handler(lambda e:handler(e), "resize", order=1) - ee.add_handler(lambda e:handler(e), "resize", order=2) + 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": "resize", "value": 2}) + ee.submit({"event_type": "key_down", "value": 2}) ee.flush() t2 = time.perf_counter() - t0 diff --git a/wgpu/gui/_events.py b/wgpu/gui/_events.py index ea8bc95a..05a78e5f 100644 --- a/wgpu/gui/_events.py +++ b/wgpu/gui/_events.py @@ -48,6 +48,10 @@ class EventEmitter: "match_keys": {"modifiers"}, "accum_keys": {"dx", "dy"}, }, + "resize": { + "match_keys": {}, + "accum_keys": {}, + }, } def __init__(self): @@ -152,15 +156,17 @@ def submit(self, event): if last_event["event_type"] == event_type: match_keys = event_merge_info["match_keys"] accum_keys = event_merge_info["accum_keys"] - if any(event[key] != last_event[key] for key in match_keys): - # Keys don't match: new event - self._pending_events.append(event) - else: - # Update last event (i.e. merge) + 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: - last_event[key] += event[key] - else: - self._pending_events.append(event) + event[key] += last_event[key] + + self._pending_events.append(event) def flush(self): """Dispatch all pending events. diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 99b26e0f..01ba272c 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -240,7 +240,7 @@ def event_tick(self): animation_iters = 0 while self._animation_time > time.perf_counter() - step: self._animation_time += step - self._submit_event({"event_type": "animate", "step": step, "catch_up": 0}) + 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 @@ -249,7 +249,7 @@ def event_tick(self): if animation_iters > 20: n = (time.perf_counter() - self._animation_time) // step self._animation_time += step * n - self._submit_event( + self._events.submit( {"event_type": "animate", "step": step * n, "catch_up": n} ) @@ -269,7 +269,7 @@ def draw_tick(self): self._schedule_next_tick() # Special event for drawing - self._submit_event({"event_type": "before_draw"}) + self._events.submit({"event_type": "before_draw"}) self._events.flush() # Schedule a new draw right before doing the draw. Important that it happens *after* processing events. diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index 5ff67c7b..1bad9f80 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -532,7 +532,7 @@ def init_glfw(self): self._glfw_initialized = True atexit.register(glfw.terminate) - def poll(self): + 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): diff --git a/wgpu/gui/jupyter.py b/wgpu/gui/jupyter.py index 1eed7384..3fde8f9f 100644 --- a/wgpu/gui/jupyter.py +++ b/wgpu/gui/jupyter.py @@ -115,7 +115,7 @@ def __init__(self): super().__init__() self._pending_jupyter_canvases = [] - def poll(self): + def _wgpu_gui_poll(self): pass # Jupyter is running in a separate process :) def run(self): diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index d7d403e8..f531521e 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -530,7 +530,7 @@ 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 poll(self): + def _wgpu_gui_poll(self): # todo: make this a private method with a wgpu prefix. pass # we assume the Qt event loop is running. Calling processEvents() will cause recursive repaints. diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index a6683f14..d50e082f 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -515,7 +515,7 @@ def _app(self): wx.App.SetInstance(app) return app - def poll(self): + def _wgpu_gui_poll(self): pass # We can assume the wx loop is running. def call_later(self, delay, callback, *args): From 69cb3104965f8abaeb52c38a45e55493a831c340 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 17 Oct 2024 16:15:55 +0200 Subject: [PATCH 15/49] improve close event being emitted consistently --- examples/gui_events.py | 11 +++++++++++ wgpu/gui/_events.py | 13 +++++++++++++ wgpu/gui/_loop.py | 5 ++++- wgpu/gui/qt.py | 19 +++++++++++++++++-- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/examples/gui_events.py b/examples/gui_events.py index 89aef81f..ec9a6472 100644 --- a/examples/gui_events.py +++ b/examples/gui_events.py @@ -1,5 +1,7 @@ """ A simple example to demonstrate events. + +Also serves as a test-app for the canvas backends. """ from wgpu.gui.auto import WgpuCanvas, loop @@ -13,6 +15,15 @@ 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["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/wgpu/gui/_events.py b/wgpu/gui/_events.py index 05a78e5f..92b1ff22 100644 --- a/wgpu/gui/_events.py +++ b/wgpu/gui/_events.py @@ -25,6 +25,9 @@ class WgpuEventType(Enum): 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 = ( @@ -57,6 +60,7 @@ class EventEmitter: def __init__(self): self._pending_events = deque() self._event_handlers = defaultdict(list) + self._closed = False def add_handler(self, *args, order=0): """Register an event handler to receive events. @@ -146,6 +150,8 @@ def submit(self, event): 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) @@ -187,3 +193,10 @@ def flush(self): 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/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 01ba272c..4db2c2ba 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -130,7 +130,10 @@ def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30): def _get_canvas(self): canvas = self._canvas_ref() - if not (canvas is None or canvas.is_closed()): + if canvas is None or canvas.is_closed(): + self._events._wgpu_close() + return None + else: return canvas def request_draw(self): diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index f531521e..fec2ca4c 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -4,6 +4,7 @@ """ import sys +import time import ctypes import importlib @@ -466,6 +467,9 @@ def __init__( # Qt methods + def closeEvent(self, event): # noqa: N802 + self.submit_event({"event_type": "close"}) + # Methods that we add from wgpu (snake_case) def _request_draw(self): @@ -475,7 +479,7 @@ def _force_draw(self): self._subwidget._force_draw() def _get_loop(self): - return loop + return None # This means this outer widget won't have a scheduler def get_present_info(self): return self._subwidget.get_present_info() @@ -498,7 +502,6 @@ def set_title(self, title): self._subwidget.set_title(title) def close(self): - self._subwidget.close() QtWidgets.QWidget.close(self) def is_closed(self): @@ -524,6 +527,7 @@ def present_image(self, image, **kwargs): class QtWgpuLoop(WgpuLoop): def init_qt(self): _ = self._app + self._latest_timeout = 0 @property def _app(self): @@ -542,6 +546,9 @@ def call_later(self, delay, callback, *args): # Would like to use the PreciseTimer flagm but there's no signature that allows that, plus a simple callback func. QtCore.QTimer.singleShot(int(delay * 1000), func) + # Store timeout + self._latest_timeout = max(self._latest_timeout, time.perf_counter() + delay) + 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 @@ -558,6 +565,14 @@ def run(self): app = self._app app.exec() if hasattr(app, "exec") else app.exec_() + # When the loop ends because the last window is closed, the close event may not + # be processed yet. Give it some time, so we get consistent close events. + # Note that this only works when the user code called this run() method. + end_time = min(time.perf_counter() + 2, self._latest_timeout) + end_time = max(time.perf_counter(), end_time) + 0.1 + while time.perf_counter() < end_time: + app.processEvents() + def stop(self): self._app.quit() From 77b7b68d4b9c67cb0daca5bdc3d292ff1f8f8a3d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 17 Oct 2024 16:46:03 +0200 Subject: [PATCH 16/49] Make/check force_draw work for qt and glfw --- examples/gui_events.py | 15 ++++++++++++++- wgpu/gui/qt.py | 10 +++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/examples/gui_events.py b/examples/gui_events.py index ec9a6472..b390ca6f 100644 --- a/examples/gui_events.py +++ b/examples/gui_events.py @@ -4,10 +4,16 @@ Also serves as a test-app for the canvas backends. """ +import time + from wgpu.gui.auto import WgpuCanvas, loop +from cube import setup_drawing_sync + -canvas = WgpuCanvas(size=(640, 480), title="wgpu events") +canvas = WgpuCanvas(size=(640, 480), title="wgpu events", max_fps=10) +draw_frame = setup_drawing_sync(canvas) +canvas.request_draw(lambda: (draw_frame(), canvas.request_draw())) @canvas.add_event_handler("*") @@ -18,6 +24,13 @@ def process_event(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. diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index fec2ca4c..6e0f963a 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -188,8 +188,16 @@ def _request_draw(self): QtWidgets.QWidget.update(self) def _force_draw(self): - # Call the paintEvent right now + # Call the paintEvent right now. + # * When drawing to the screen, directly calling _draw_frame_and_present() + # actually works, but let's play as nice as we can be. + # * When drawing via the image, calling repaint() is not enough, we also need to + # call processEvents(). Note that this may also process our scheduler's + # call_later(), and process more of our events, and maybe even another call to + # this method, if the user was not careful. self.repaint() + if not self._present_to_screen: + loop._app.processEvents() def _get_loop(self): return loop From 8ca78004990023bdb56bedda4ec827325e22f347 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 21 Oct 2024 14:11:55 +0200 Subject: [PATCH 17/49] Use a timer --- wgpu/gui/_loop.py | 460 ++++++++++++++++++++++++++++++-------------- wgpu/gui/asyncio.py | 51 +++-- wgpu/gui/base.py | 4 +- wgpu/gui/glfw.py | 7 +- wgpu/gui/qt.py | 55 +++--- 5 files changed, 389 insertions(+), 188 deletions(-) diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 4db2c2ba..1e58e69f 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -1,3 +1,7 @@ +""" +Implemens loop mechanics: The base timer, base loop, and scheduler. +""" + import time import weakref @@ -7,35 +11,220 @@ # That would e.g. allow using glfw with qt together. Probably to 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 = 0.0 + 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.is_running: + self._stop() + WgpuTimer._running_timers.add(self) + self._interval = 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 _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 different event-loop classes.""" + """Base class for event-loop objects.""" + + _TimerClass = None # subclases must set this + + def __init__(self): + self._schedulers = set() + self._stop_when_no_canvases = False + self._gui_timer = self._TimerClass(self, self._gui_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 _gui_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. - Callbacks are called in the order in which they are registered. + 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_later(0, callback, *args) + 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).""" - raise NotImplementedError() + """Arrange for a callback to be called after the given delay (in seconds). - def _wgpu_gui_poll(self): - """Poll the underlying GUI toolkit for window events. + 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. - Some event loops (e.g. asyncio) are just that and dont have a GUI to update. + 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. """ - pass + timer = self._TimerClass(self, callback, *args, one_shot=True) + timer.start(delay) + return timer - def run(self): - """Enter the main loop.""" - raise NotImplementedError() + 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 AnimationScheduler: """ @@ -56,45 +245,57 @@ class Scheduler: # 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 call_later calls. It's crucial that the loop stays clean - # and does not 'duplicate', e.g. by an extra draw being done behind our - # back, otherwise the fps might double (but be irregular). Taking care of - # this is surprising tricky. + # implemented with a timer. # # The loop looks a little like this: # - # ________________ __ ________________ __ - # / call_later \ / rd \ / call_later \ / rd \ + # ________________ __ ________________ __ rd = request_draw + # / wait \ / rd \ / wait \ / rd \ # | || || || | # --------------------------------------------------------------------> time # | | | | | - # | | draw_tick | draw_tick - # schedule pseuso_tick pseudo_tick - # - # - # While the loop is waiting - via call_later, in between the calls to - # _schedule_tick() and pseudo_tick() - a new tick cannot be scheduled. In - # pseudo_tick() the _request_draw() method is called that asks the GUI to - # schedule a draw. This happens in a later event-loop iteration, an can - # happen (nearly) directly, or somewhat later. The first thing that the - # draw_tick() method does, is schedule a new draw. Any extra draws that are - # performed still call _schedule_tick(), but this has no effect. + # | | draw | draw + # schedule tick tick # # With update modes 'ondemand' and 'manual', the loop ticks at the same rate - # as on 'continuous' mode, but won't draw every tick. The event_tick() is - # then called instead, so that events handlers and animations stay active, - # from which a new draw may be requested. + # as on 'continuous' mode, but won't draw every tick: # # ________________ ________________ __ - # / call_later \ / call_later \ / rd \ + # / wait \ / wait \ / rd \ # | || || | # --------------------------------------------------------------------> time # | | | | - # | | | draw_tick - # schedule pseuso_tick pseuso_tick - # + event_tick + # | | | draw + # schedule tick tick + # + # 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 event loop. + # + # 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, events are also + # processed 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): + def __init__(self, canvas, loop, *, mode="continuous", min_fps=1, max_fps=30): # Objects related to the canvas. # 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 @@ -106,9 +307,7 @@ def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30): # We need to call_later and process gui events. The loop object abstracts these. self._loop = loop assert loop is not None - - # Lock the scheduling while its waiting - self._waiting_lock = False + loop._register_scheduler(self) # Scheduling variables self._mode = mode @@ -124,9 +323,10 @@ def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30): self._animation_time = 0 self._animation_step = 1 / 20 - # Start by doing the first scheduling. - # Note that the gui may do a first draw earlier, starting the loop, and that's fine. - self._loop.call_later(0.1, self._schedule_next_tick) + # Initialise the 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) def _get_canvas(self): canvas = self._canvas_ref() @@ -142,42 +342,10 @@ def request_draw(self): self._draw_requested = True def _schedule_next_tick(self): - # Scheduling flow: - # - # * _schedule_next_tick(): - # * determine delay - # * use call_later() to have pseudo_tick() called - # - # * pseudo_tick(): - # * decide whether to request a draw - # * a draw is requested: - # * the GUI will call canvas._draw_frame_and_present() - # * wich calls draw_tick() - # * A draw is not requested: - # * call event_tick() - # * call _schedule_next_tick() - # - # * event_tick(): - # * let GUI process events - # * flush events - # * run animations - # - # * draw_tick(): - # * calls _schedule_next_tick() - # * calls event_tick() - # * draw! - - # Notes: - # - # * New ticks must be scheduled from the draw_tick, otherwise - # new draws may get scheduled faster than it can keep up. - # * It's crucial to not have two cycles running at the same time. - # * We must assume that the GUI can do extra draws (i.e. draw_tick gets called) any time, e.g. when resizing. - - # Flag that allows this method to be called at any time, without introducing an extra "loop". - if self._waiting_lock: + """Schedule _tick() to be called via our timer.""" + + if self._timer.is_running: return - self._waiting_lock = True # Determine delay if self._mode == "fastest": @@ -186,49 +354,56 @@ def _schedule_next_tick(self): delay = 1 / self._max_fps delay = 0 if delay < 0 else delay # 0 means cannot keep up - def pseudo_tick(): - # Since this resets the waiting lock, we really want to avoid accidentally - # calling this function. That's why we define it locally. + # 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) - # Enable scheduling again - self._waiting_lock = False + # Go! + self._timer.start(delay) - # Get canvas or stop - if (canvas := self._get_canvas()) is None: - return + def _tick(self): + """Process event and schedule a new draw or tick.""" - if self._mode == "fastest": - # fastest: draw continuously as fast as possible, ignoring fps settings. - canvas._request_draw() + self._last_tick_time = time.perf_counter() - elif self._mode == "continuous": - # continuous: draw continuously, aiming for a steady max framerate. - canvas._request_draw() + # Get canvas or stop + if (canvas := self._get_canvas()) is None: + return - 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. - self.event_tick() # may set _draw_requested - its_draw_time = ( - time.perf_counter() - self._last_draw_time > 1 / self._min_fps - ) - if self._draw_requested or its_draw_time: - canvas._request_draw() - else: - self._schedule_next_tick() - - elif self._mode == "manual": - # manual: never draw, except when ... ? - self.event_tick() - self._schedule_next_tick() + # Process events, may set _draw_requested + self.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. + its_draw_time = ( + time.perf_counter() - self._last_draw_time > 1 / self._min_fps + ) + if self._draw_requested or its_draw_time: + canvas._request_draw() else: - raise RuntimeError(f"Unexpected scheduling mode: '{self._mode}'") + self._schedule_next_tick() + + elif self._mode == "manual": + # manual: never draw, except when ... ? + self._schedule_next_tick() - self._loop.call_later(delay, pseudo_tick) + else: + raise RuntimeError(f"Unexpected scheduling mode: '{self._mode}'") - def event_tick(self): - """A lightweight tick that processes evets and animations.""" + def process_events(self): + """Process events and animations.""" # Get events from the GUI into our event mechanism. self._loop._wgpu_gui_poll() @@ -237,47 +412,39 @@ def event_tick(self): # 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} - ) - - def draw_tick(self): - """Perform a full tick: processing events, animations, drawing, and presenting.""" - - # Events and animations - self.event_tick() + # 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} + # ) + + def draw_frame_and_present(self): + """Perform a draw, and present the result.""" # It could be that the canvas is closed now. When that happens, # we stop here and do not schedule a new iter. if (canvas := self._get_canvas()) is None: return - # Keep ticking - self._draw_requested = False - self._schedule_next_tick() - - # Special event for drawing + # Events and animations + self.process_events() self._events.submit({"event_type": "before_draw"}) self._events.flush() - # Schedule a new draw right before doing the draw. Important that it happens *after* processing events. - self._last_draw_time = time.perf_counter() - # Update stats count, last_time = self._draw_stats if time.perf_counter() - last_time > 1.0: @@ -290,6 +457,10 @@ def draw_tick(self): fps = count / (time.perf_counter() - last_time) canvas.set_title(f"wgpu {fps:0.1f} fps") + # Bookkeeping + self._last_draw_time = time.perf_counter() + self._draw_requested = False + # 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"): @@ -300,3 +471,6 @@ def draw_tick(self): context = canvas._canvas_context if context: context.present() + + # Keep ticking + self._schedule_next_tick() diff --git a/wgpu/gui/asyncio.py b/wgpu/gui/asyncio.py index 0a02e549..808040fc 100644 --- a/wgpu/gui/asyncio.py +++ b/wgpu/gui/asyncio.py @@ -1,16 +1,39 @@ """Implements an asyncio event loop.""" -# This is used for GUI backends that don't have an event loop by themselves, lik glfw. +# 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 +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): @@ -23,6 +46,8 @@ def _get_loop(self): 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: @@ -31,16 +56,16 @@ def _get_loop(self): asyncio.set_event_loop(loop) return loop - def call_soon(self, callback, *args): - self._loop.call_soon(callback, *args) - - def call_later(self, delay, callback, *args): - self._loop.call_later(delay, callback, *args) - - def run(self): + def _run(self): if self._loop.is_running(): - return # Interactive mode! - self._loop.run_forever() + self._is_interactive = True + else: + self._is_interactive = False + self._loop.run_forever() - def stop(self): - self._loop.stop() + 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/wgpu/gui/base.py b/wgpu/gui/base.py index 57a61cd7..e5b6e8d8 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -1,7 +1,7 @@ import sys from ._events import EventEmitter -from ._loop import Scheduler, WgpuLoop # noqa: F401 +from ._loop import Scheduler, WgpuLoop, WgpuTimer # noqa: F401 class WgpuCanvasInterface: @@ -193,7 +193,7 @@ def _draw_frame_and_present(self): # "draw event" that we requested, or as part of a forced draw. So this # call must to the complete tick. if self._scheduler is not None: - self._scheduler.draw_tick() + self._scheduler.draw_frame_and_present() def _get_loop(self): """Must return the global loop instance (WgpuLoop) for the canvas subclass, or None for a non-interactive canvas.""" diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index 1bad9f80..86e0e17f 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -538,9 +538,10 @@ def _wgpu_gui_poll(self): if self.stop_if_no_more_canvases and not tuple(self.all_glfw_canvases): self.stop() - def run(self): - super().run() - poll_glfw_briefly() + def _run(self): + super()._run() + if not self._is_interactive: + poll_glfw_briefly() loop = GlfwAsyncioWgpuLoop() diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 6e0f963a..5d73ce1d 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -4,11 +4,10 @@ """ import sys -import time import ctypes import importlib -from .base import WgpuCanvasBase, WgpuLoop +from .base import WgpuCanvasBase, WgpuLoop, WgpuTimer from ._gui_utils import ( logger, SYSTEM_IS_WAYLAND, @@ -532,7 +531,26 @@ def present_image(self, image, **kwargs): WgpuCanvas = QWgpuCanvas +class QtWgpuTimer(WgpuTimer): + """Wgpu timer basef on Qt.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + 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 @@ -542,22 +560,7 @@ 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 _wgpu_gui_poll(self): - # todo: make this a private method with a wgpu prefix. - pass # we assume the Qt event loop is running. Calling processEvents() will cause recursive repaints. - - def call_later(self, delay, callback, *args): - func = callback - if args: - func = lambda: callback(*args) - - # Would like to use the PreciseTimer flagm but there's no signature that allows that, plus a simple callback func. - QtCore.QTimer.singleShot(int(delay * 1000), func) - - # Store timeout - self._latest_timeout = max(self._latest_timeout, time.perf_counter() + delay) - - def run(self): + 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. @@ -571,18 +574,16 @@ def run(self): 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_() - # When the loop ends because the last window is closed, the close event may not - # be processed yet. Give it some time, so we get consistent close events. - # Note that this only works when the user code called this run() method. - end_time = min(time.perf_counter() + 2, self._latest_timeout) - end_time = max(time.perf_counter(), end_time) + 0.1 - while time.perf_counter() < end_time: - app.processEvents() + def _stop(self): + if not already_had_app_on_import: + self._app.quit() - def stop(self): - self._app.quit() + def _wgpu_gui_poll(self): + # todo: make this a private method with a wgpu prefix. + pass # we assume the Qt event loop is running. Calling processEvents() will cause recursive repaints. loop = QtWgpuLoop() From c2a29bd5d38bb6c485eb27c477c09f0d3019575b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 21 Oct 2024 14:18:51 +0200 Subject: [PATCH 18/49] glfw not draw when minimized --- wgpu/gui/glfw.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index 86e0e17f..8ab03401 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -232,6 +232,8 @@ def _on_window_dirty(self, *args): def _on_iconify(self, window, iconified): self._is_minimized = bool(iconified) + if not self._is_minimized: + self._request_draw() # helpers @@ -298,7 +300,8 @@ def _get_loop(self): return loop def _request_draw(self): - loop.call_soon(self._draw_frame_and_present) + if not self._is_minimized: + loop.call_soon(self._draw_frame_and_present) def _force_draw(self): self._draw_frame_and_present() From f483e7153394430f6ad0d0aa7404bc7f5f4b1d22 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 21 Oct 2024 14:39:36 +0200 Subject: [PATCH 19/49] little cleanup --- wgpu/gui/_loop.py | 4 ++-- wgpu/gui/base.py | 21 +++++++++++++-------- wgpu/gui/qt.py | 2 +- wgpu/gui/wx.py | 2 +- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 1e58e69f..91c09cbf 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -117,14 +117,14 @@ class WgpuLoop: def __init__(self): self._schedulers = set() self._stop_when_no_canvases = False - self._gui_timer = self._TimerClass(self, self._gui_tick, one_shot=False) + 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 _gui_tick(self): + def _tick(self): # Keep the GUI alive on every tick self._wgpu_gui_poll() diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index e5b6e8d8..c573cf46 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -103,7 +103,6 @@ def __init__( max_fps=30, vsync=True, present_method=None, - use_scheduler=True, **kwargs, ): super().__init__(*args, **kwargs) @@ -111,12 +110,10 @@ def __init__( present_method # noqa - We just catch the arg here in case a backend does implement it self._draw_frame = lambda: None - self._events = EventEmitter() - self._scheduler = None loop = self._get_loop() - if loop and use_scheduler: + if loop: self._scheduler = Scheduler(self, loop, max_fps=max_fps) def __del__(self): @@ -187,7 +184,7 @@ 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. """ # 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. So this @@ -196,17 +193,25 @@ def _draw_frame_and_present(self): self._scheduler.draw_frame_and_present() def _get_loop(self): - """Must return the global loop instance (WgpuLoop) for the canvas subclass, or None for a non-interactive canvas.""" + """For the subclass to implement: + + Must return the global loop instance (WgpuLoop) for the canvas subclass, or None for a non-interactive canvas. + """ return None def _request_draw(self): - """Request the GUI layer to perform a draw. Like requestAnimationFrame in JS. + """For the subclass to implement: + + Request the GUI layer to perform a draw. Like requestAnimationFrame in JS. The draw must be performed by calling self._draw_frame_and_present() """ raise NotImplementedError() def _force_draw(self): - """Perform a synchronous draw. When it returns, the draw must have been done.""" + """For the subclass to implement: + + Perform a synchronous draw. When it returns, the draw must have been done. + """ raise NotImplementedError() # === Primary canvas management methods diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 5d73ce1d..5dfd8f73 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -448,7 +448,7 @@ def __init__( # When using Qt, there needs to be an # application before any widget is created loop.init_qt() - super().__init__(**kwargs, use_scheduler=False) + super().__init__(**kwargs) self.setAttribute(WA_DeleteOnClose, True) self.set_logical_size(*(size or (640, 480))) diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index d50e082f..1f4aa9bb 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -429,7 +429,7 @@ def __init__( **kwargs, ): loop.init_wx() - super().__init__(parent, use_scheduler=False, **kwargs) + super().__init__(parent, **kwargs) self.set_logical_size(*(size or (640, 480))) self.SetTitle(title or "wx wgpu canvas") From d0f5d5f6bcd7670edfbbdd7603aafb0d1bf79cdf Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 21 Oct 2024 14:48:45 +0200 Subject: [PATCH 20/49] Add example for multiple canvases --- examples/gui_multiple.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 examples/gui_multiple.py diff --git a/examples/gui_multiple.py b/examples/gui_multiple.py new file mode 100644 index 00000000..00f7c802 --- /dev/null +++ b/examples/gui_multiple.py @@ -0,0 +1,23 @@ +""" +Run triangle and cube examples two canvases. +""" + +# test_example = true + +from wgpu.gui.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() From 6e1633976090eab8ae7624691ed1cd58240e6637 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 21 Oct 2024 15:36:48 +0200 Subject: [PATCH 21/49] Update offscreen.py --- wgpu/gui/offscreen.py | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/wgpu/gui/offscreen.py b/wgpu/gui/offscreen.py index a6b975e1..f00499fd 100644 --- a/wgpu/gui/offscreen.py +++ b/wgpu/gui/offscreen.py @@ -1,6 +1,4 @@ -import time - -from .base import WgpuCanvasBase, WgpuLoop +from .base import WgpuCanvasBase, WgpuLoop, WgpuTimer class WgpuManualOffscreenCanvas(WgpuCanvasBase): @@ -50,7 +48,7 @@ def is_closed(self): return self._closed def _get_loop(self): - return loop + return None # No scheduling for this canvas def _request_draw(self): # Deliberately a no-op, because people use .draw() instead. @@ -73,6 +71,14 @@ def draw(self): WgpuCanvas = WgpuManualOffscreenCanvas +class StubWgpuTimer(WgpuTimer): + def _start(self): + pass + + def _stop(self): + pass + + class StubLoop(WgpuLoop): # If we consider the use-cases for using this offscreen canvas: # @@ -87,25 +93,15 @@ class StubLoop(WgpuLoop): # In summary, we provide a call_later() and run() that behave pretty # well for the first case. - def __init__(self): - super().__init__() - self._pending_calls = [] - - def call_later(self, delay, callback, *args): - # Note that this module never calls call_later() itself; request_draw() is a no-op. - etime = time.time() + delay - self._pending_calls.append((etime, callback, args)) - - def run(self): - # Process pending calls - for etime, callback, args in self._pending_calls.copy(): - if time.time() >= etime: - callback(*args) + _TimerClass = StubWgpuTimer # subclases must set this - # Clear any leftover scheduled calls, to avoid lingering refs. - self._pending_calls.clear() + def _run(self): + # Running this loop processes any timers + for timer in WgpuTimer._running_timers: + if timer.time_left <= 0: + timer._tick() - def stop(self): + def _stop(self): pass From 6e713ff09b900b0b9f11727f64f624af62c6d801 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 21 Oct 2024 15:39:58 +0200 Subject: [PATCH 22/49] Add threading example --- examples/gui_threading.py | 52 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 examples/gui_threading.py diff --git a/examples/gui_threading.py b/examples/gui_threading.py new file mode 100644 index 00000000..b2fa3d11 --- /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 wgpu.gui.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() From e75143802ed5d4fc8ebbc4fe0f79cf8f833e112e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 21 Oct 2024 16:04:52 +0200 Subject: [PATCH 23/49] fix --- wgpu/gui/offscreen.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/wgpu/gui/offscreen.py b/wgpu/gui/offscreen.py index f00499fd..d974dff6 100644 --- a/wgpu/gui/offscreen.py +++ b/wgpu/gui/offscreen.py @@ -48,7 +48,7 @@ def is_closed(self): return self._closed def _get_loop(self): - return None # No scheduling for this canvas + return loop def _request_draw(self): # Deliberately a no-op, because people use .draw() instead. @@ -64,6 +64,7 @@ def draw(self): This object can be converted to a numpy array (without copying data) using ``np.asarray(arr)``. """ + loop._process_events() # Little trick to keep the event loop going self._force_draw() return self._last_image @@ -95,12 +96,15 @@ class StubLoop(WgpuLoop): _TimerClass = StubWgpuTimer # subclases must set this - def _run(self): + def _process_events(self): # Running this loop processes any timers - for timer in WgpuTimer._running_timers: + for timer in list(WgpuTimer._running_timers): if timer.time_left <= 0: timer._tick() + def _run(self): + self._process_events() + def _stop(self): pass From 19fce1662e10a3ca76f5aac34b1fd630ad30f3b5 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 21 Oct 2024 22:13:33 +0200 Subject: [PATCH 24/49] fix more --- tests/test_gui_base.py | 4 ++- wgpu/gui/_loop.py | 53 ++----------------------------- wgpu/gui/base.py | 72 ++++++++++++++++++++++++++++++++++++------ wgpu/gui/offscreen.py | 13 ++++---- 4 files changed, 76 insertions(+), 66 deletions(-) diff --git a/tests/test_gui_base.py b/tests/test_gui_base.py index 9fdcb638..4583f968 100644 --- a/tests/test_gui_base.py +++ b/tests/test_gui_base.py @@ -17,7 +17,9 @@ def __init__(self): super().__init__() self._count = 0 - def draw_frame(self): + def _get_loop(self): + + def _draw_frame(self): self._count += 1 if self._count <= 4: self.foo_method() diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 91c09cbf..3680e04c 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -305,7 +305,6 @@ def __init__(self, canvas, loop, *, mode="continuous", min_fps=1, max_fps=30): # ... = canvas.get_context() -> No, context creation should be lazy! # We need to call_later and process gui events. The loop object abstracts these. - self._loop = loop assert loop is not None loop._register_scheduler(self) @@ -372,7 +371,7 @@ def _tick(self): return # Process events, may set _draw_requested - self.process_events() + canvas._process_events() # Determine what to do next ... @@ -402,49 +401,14 @@ def _tick(self): else: raise RuntimeError(f"Unexpected scheduling mode: '{self._mode}'") - def process_events(self): - """Process events and animations.""" - - # Get events from the GUI into our event mechanism. - self._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} - # ) - - def draw_frame_and_present(self): - """Perform a draw, and present the result.""" + def on_draw(self): + """Called from canvas._draw_frame_and_present().""" # It could be that the canvas is closed now. When that happens, # we stop here and do not schedule a new iter. if (canvas := self._get_canvas()) is None: return - # Events and animations - self.process_events() - self._events.submit({"event_type": "before_draw"}) - self._events.flush() - # Update stats count, last_time = self._draw_stats if time.perf_counter() - last_time > 1.0: @@ -461,16 +425,5 @@ def draw_frame_and_present(self): self._last_draw_time = time.perf_counter() self._draw_requested = False - # 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"): - canvas._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 = canvas._canvas_context - if context: - context.present() - # Keep ticking self._schedule_next_tick() diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index c573cf46..9342afee 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -2,6 +2,7 @@ from ._events import EventEmitter from ._loop import Scheduler, WgpuLoop, WgpuTimer # noqa: F401 +from ._gui_utils import log_exception class WgpuCanvasInterface: @@ -163,11 +164,10 @@ def request_draw(self, draw_function=None): function. If not given or None, the last set draw function is used. """ - if self._scheduler is None: - return if draw_function is not None: self._draw_frame = draw_function - self._scheduler.request_draw() + if self._scheduler is not None: + self._scheduler.request_draw() # todo: maybe requesting a new draw can be done by setting a field in an event? # todo: can just make the draw_function a handler for the draw event? @@ -180,6 +180,40 @@ def force_draw(self): """Perform a draw right now.""" self._force_draw() + def _process_events(self): + """Process events and animations.""" + + loop = self._get_loop() + if loop is None: + return + + # Get events from the GUI into our event mechanism. + 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} + # ) + def _draw_frame_and_present(self): """Draw the frame and present the result. @@ -187,15 +221,33 @@ def _draw_frame_and_present(self): subclass at its draw event. """ # 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. So this - # call must to the complete tick. + # "draw event" that we requested, or as part of a forced draw. + + # Process events + self._process_events() + self._events.submit({"event_type": "before_draw"}) + self._events.flush() + + # Notify the scheduler if self._scheduler is not None: - self._scheduler.draw_frame_and_present() + self._scheduler.on_draw() + + # 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() def _get_loop(self): """For the subclass to implement: - Must return the global loop instance (WgpuLoop) for the canvas subclass, or None for a non-interactive canvas. + Must return the global loop instance (WgpuLoop) for the canvas subclass, + or None for an interactive canvas without scheduled draws. """ return None @@ -203,7 +255,8 @@ def _request_draw(self): """For the subclass to implement: Request the GUI layer to perform a draw. Like requestAnimationFrame in JS. - The draw must be performed by calling self._draw_frame_and_present() + 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 (eventually). """ raise NotImplementedError() @@ -211,8 +264,9 @@ def _force_draw(self): """For the subclass to implement: Perform a synchronous draw. When it returns, the draw must have been done. + The default implementation just calls _draw_frame_and_present(). """ - raise NotImplementedError() + self._draw_frame_and_present() # === Primary canvas management methods diff --git a/wgpu/gui/offscreen.py b/wgpu/gui/offscreen.py index d974dff6..0d5bf07f 100644 --- a/wgpu/gui/offscreen.py +++ b/wgpu/gui/offscreen.py @@ -48,10 +48,11 @@ def is_closed(self): return self._closed def _get_loop(self): - return loop + 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): @@ -64,8 +65,8 @@ def draw(self): This object can be converted to a numpy array (without copying data) using ``np.asarray(arr)``. """ - loop._process_events() # Little trick to keep the event loop going - self._force_draw() + loop._process_timers() # Little trick to keep the event loop going + self._draw_frame_and_present() return self._last_image @@ -96,14 +97,14 @@ class StubLoop(WgpuLoop): _TimerClass = StubWgpuTimer # subclases must set this - def _process_events(self): + 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_events() + self._process_timers() def _stop(self): pass From 7c738b58f45118b9964d42ed068cc7d30a30289e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 22 Oct 2024 11:53:33 +0200 Subject: [PATCH 25/49] More tweaks. Tests pass now --- tests/test_gui_auto_offscreen.py | 65 --------------- tests/test_gui_base.py | 131 +++++++++++------------------- tests/test_gui_glfw.py | 2 +- tests/test_gui_offscreen.py | 64 +++++++++++++++ tests/test_gui_utils.py | 42 ++++++++++ wgpu/backends/wgpu_native/_api.py | 2 + wgpu/gui/_loop.py | 2 +- wgpu/gui/base.py | 93 +++++++++++---------- 8 files changed, 209 insertions(+), 192 deletions(-) delete mode 100644 tests/test_gui_auto_offscreen.py create mode 100644 tests/test_gui_offscreen.py create mode 100644 tests/test_gui_utils.py diff --git a/tests/test_gui_auto_offscreen.py b/tests/test_gui_auto_offscreen.py deleted file mode 100644 index e34c02d0..00000000 --- a/tests/test_gui_auto_offscreen.py +++ /dev/null @@ -1,65 +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) - - -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 loop - - ran = False - - def check(): - nonlocal ran - ran = True - - loop.call_later(0, check) - loop.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 4583f968..2a6affc5 100644 --- a/tests/test_gui_base.py +++ b/tests/test_gui_base.py @@ -1,24 +1,52 @@ """ -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 testutils import run_tests, can_use_wgpu_lib from pytest import mark, raises -class TheTestCanvas(wgpu.gui.WgpuCanvasBase): +def test_base_canvas_context(): + assert not issubclass(wgpu.gui.WgpuCanvasInterface, wgpu.GPUCanvasContext) + assert hasattr(wgpu.gui.WgpuCanvasInterface, "get_context") + + +def test_base_canvas_cannot_use_context(): + canvas = wgpu.gui.WgpuCanvasInterface() + with raises(NotImplementedError): + wgpu.GPUCanvasContext(canvas) + + canvas = wgpu.gui.WgpuCanvasBase() + with raises(NotImplementedError): + canvas.get_context() + + +def test_canvas_get_context_needs_backend_to_be_selected(): + 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() + + +class CanvasThatRaisesErrorsDuringDrawing(wgpu.gui.WgpuCanvasBase): def __init__(self): super().__init__() self._count = 0 - def _get_loop(self): - def _draw_frame(self): self._count += 1 if self._count <= 4: @@ -37,22 +65,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 ... @@ -108,10 +127,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(): @@ -129,25 +144,8 @@ def test_run_bare_canvas(): 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_simpple_offscreen_canvas(): canvas = MyOffscreenCanvas() device = wgpu.utils.get_default_device() present_context = canvas.get_context() @@ -176,14 +174,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) @@ -192,7 +192,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) @@ -201,7 +201,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) @@ -213,55 +213,20 @@ def draw_frame(): def test_canvas_base_events(): c = wgpu.gui.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, "resize") - c.submit_event({"event_type": "resize", "value": 1}) - c.submit_event({"event_type": "resize", "value": 2}) - c.remove_event_handler(handler) - c.submit_event({"event_type": "resize", "value": 3}) - + 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] -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! - - if __name__ == "__main__": run_tests(globals()) diff --git a/tests/test_gui_glfw.py b/tests/test_gui_glfw.py index 7b14275c..53f5b7e4 100644 --- a/tests/test_gui_glfw.py +++ b/tests/test_gui_glfw.py @@ -111,7 +111,7 @@ def run_briefly(): 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() draw_frame1 = _get_draw_function(device, canvas) diff --git a/tests/test_gui_offscreen.py b/tests/test_gui_offscreen.py new file mode 100644 index 00000000..0d19aa4c --- /dev/null +++ b/tests/test_gui_offscreen.py @@ -0,0 +1,64 @@ +""" +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 wgpu.gui.offscreen import WgpuManualOffscreenCanvas + from wgpu.gui.auto import select_backend + + ori = os.environ.get("WGPU_FORCE_OFFSCREEN", "") + try: + 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 wgpu.gui.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 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 + + +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 00000000..52b6ebd0 --- /dev/null +++ b/tests/test_gui_utils.py @@ -0,0 +1,42 @@ +import gc + +import wgpu.gui +from testutils import run_tests, is_pypy + + +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! + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/wgpu/backends/wgpu_native/_api.py b/wgpu/backends/wgpu_native/_api.py index d3dde5af..c98f6b4d 100644 --- a/wgpu/backends/wgpu_native/_api.py +++ b/wgpu/backends/wgpu_native/_api.py @@ -519,6 +519,8 @@ class GPUCanvasContext(classes.GPUCanvasContext): # we can give meaningful errors/warnings on invalid use, rather than # the more cryptic Rust panics. + _surface_id = ffi.NULL + def __init__(self, canvas): super().__init__(canvas) diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 3680e04c..1392b00d 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -295,7 +295,7 @@ class Scheduler: # Note that any extra draws, e.g. via force_draw() or due to window resizes, # don't affect the scheduling loop; they are just extra draws. - def __init__(self, canvas, loop, *, mode="continuous", min_fps=1, max_fps=30): + def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30): # Objects related to the canvas. # 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 diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 9342afee..ee0b9c80 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -104,18 +104,18 @@ def __init__( max_fps=30, vsync=True, present_method=None, + update_mode="ondemand", **kwargs, ): super().__init__(*args, **kwargs) self._vsync = bool(vsync) present_method # noqa - We just catch the arg here in case a backend does implement it - self._draw_frame = lambda: None self._events = EventEmitter() self._scheduler = None loop = self._get_loop() if loop: - self._scheduler = Scheduler(self, loop, max_fps=max_fps) + self._scheduler = Scheduler(self, loop, max_fps=max_fps, mode=update_mode) def __del__(self): # On delete, we call the custom close method. @@ -148,7 +148,46 @@ def submit_event(self, event): remove_event_handler.__doc__ = EventEmitter.remove_handler.__doc__ submit_event.__doc__ = EventEmitter.submit.__doc__ - # === Scheduling + def _process_events(self): + """Process events and animations. To be called right before a draw, and from the scheduler.""" + + # 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. @@ -180,40 +219,6 @@ def force_draw(self): """Perform a draw right now.""" self._force_draw() - def _process_events(self): - """Process events and animations.""" - - loop = self._get_loop() - if loop is None: - return - - # Get events from the GUI into our event mechanism. - 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} - # ) - def _draw_frame_and_present(self): """Draw the frame and present the result. @@ -247,18 +252,20 @@ def _get_loop(self): """For the subclass to implement: Must return the global loop instance (WgpuLoop) for the canvas subclass, - or None for an interactive canvas without scheduled draws. + or None for a canvas without scheduled draws. """ return None def _request_draw(self): """For the subclass to implement: - 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 (eventually). + 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 (eventually). By default does nothing, which is equivalent to + waiting for a forced draw or a draw invoked by the GUI system. """ - raise NotImplementedError() + pass def _force_draw(self): """For the subclass to implement: @@ -270,6 +277,8 @@ def _force_draw(self): # === Primary canvas management methods + # todo: we require subclasses to implement public methods, while everywhere else the implementable-methods are private. + def get_physical_size(self): """Get the physical size in integer pixels.""" raise NotImplementedError() From 2a0b0be930ae195b226338ae48708afc40e7ba5c Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 22 Oct 2024 13:09:26 +0200 Subject: [PATCH 26/49] Implement tests for scheduling. --- wgpu/gui/__init__.py | 4 +++- wgpu/gui/_loop.py | 13 +++++++------ wgpu/gui/base.py | 9 ++++++++- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/wgpu/gui/__init__.py b/wgpu/gui/__init__.py index bc8bb954..a490d4b0 100644 --- a/wgpu/gui/__init__.py +++ b/wgpu/gui/__init__.py @@ -3,11 +3,13 @@ """ from . import _gui_utils # noqa: F401 -from .base import WgpuCanvasInterface, WgpuCanvasBase +from .base import WgpuCanvasInterface, WgpuCanvasBase, WgpuLoop, WgpuTimer from ._events import WgpuEventType __all__ = [ "WgpuCanvasInterface", "WgpuCanvasBase", "WgpuEventType", + "WgpuLoop", + "WgpuTimer", ] diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 1392b00d..7e0ae734 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -312,7 +312,7 @@ def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30): self._mode = mode self._min_fps = float(min_fps) self._max_fps = float(max_fps) - self._draw_requested = True + self._draw_requested = True # Start with a draw in ondemand mode # Stats self._last_draw_time = 0 @@ -330,6 +330,7 @@ def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30): 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: @@ -370,9 +371,6 @@ def _tick(self): if (canvas := self._get_canvas()) is None: return - # Process events, may set _draw_requested - canvas._process_events() - # Determine what to do next ... if self._mode == "fastest": @@ -386,16 +384,19 @@ def _tick(self): 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. - its_draw_time = ( + its_time_to_draw = ( time.perf_counter() - self._last_draw_time > 1 / self._min_fps ) - if self._draw_requested or its_draw_time: + if not self._draw_requested: + canvas._process_events() # handlers may request a draw + if self._draw_requested or its_time_to_draw: canvas._request_draw() else: self._schedule_next_tick() elif self._mode == "manual": # manual: never draw, except when ... ? + canvas._process_events() self._schedule_next_tick() else: diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index ee0b9c80..54a6e8b4 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -101,6 +101,7 @@ class WgpuCanvasBase(WgpuCanvasInterface): def __init__( self, *args, + min_fps=1, max_fps=30, vsync=True, present_method=None, @@ -115,7 +116,9 @@ def __init__( self._scheduler = None loop = self._get_loop() if loop: - self._scheduler = Scheduler(self, loop, max_fps=max_fps, mode=update_mode) + 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. @@ -151,6 +154,10 @@ def submit_event(self, event): def _process_events(self): """Process events and animations. To be called right before a draw, and 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: From 85d6a274bec611faae7498b175f41b445b16582b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 22 Oct 2024 13:20:47 +0200 Subject: [PATCH 27/49] enum for update modes --- wgpu/gui/_loop.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 7e0ae734..4aa02ffa 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -6,6 +6,7 @@ import weakref from ._gui_utils import log_exception +from ..enums import Enum # 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 to too weird use-case for the added complexity. @@ -240,6 +241,15 @@ class AnimationScheduler: # scheduler._event_emitter.submit_and_dispatch(event) +class UpdateMode(Enum): + """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.""" @@ -309,6 +319,10 @@ def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30): loop._register_scheduler(self) # 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) From d1989559eed8934c9e992d651e3a8cfd45ff1e11 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 22 Oct 2024 15:22:28 +0200 Subject: [PATCH 28/49] More tests and docs on event order --- tests/test_gui_events.py | 39 +++++++++++++++++++++++++++++++++++++-- wgpu/gui/_events.py | 17 +++++++++++------ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/tests/test_gui_events.py b/tests/test_gui_events.py index a1c8b712..a247e6e0 100644 --- a/tests/test_gui_events.py +++ b/tests/test_gui_events.py @@ -154,14 +154,49 @@ def handler2(event): def handler3(event): values.append(300 + event["value"]) - # handler3 goes first, the other two maintain order + # 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 == [301, 101, 201] + 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(): diff --git a/wgpu/gui/_events.py b/wgpu/gui/_events.py index 92b1ff22..a6b5d98c 100644 --- a/wgpu/gui/_events.py +++ b/wgpu/gui/_events.py @@ -62,17 +62,21 @@ def __init__(self): self._event_handlers = defaultdict(list) self._closed = False - def add_handler(self, *args, order=0): + 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 (int): The order in which the handler is called. Lower numbers are called first. Default is 0. + 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 @@ -91,8 +95,8 @@ def my_handler(event): .. code-block:: py - @canvas.add_event_handler("pointer_up", "pointer_down") - def my_handler(event): + @canvas.add_event_handler("pointer_up", "pointer_down") def + my_handler(event): print(event) Catch 'm all: @@ -102,6 +106,7 @@ def my_handler(event): 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:] From 23e1a627275e86aca76bdce1c05ba93b5deac792 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 22 Oct 2024 15:32:56 +0200 Subject: [PATCH 29/49] forgot to add new test --- tests/test_gui_scheduling.py | 210 +++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 tests/test_gui_scheduling.py diff --git a/tests/test_gui_scheduling.py b/tests/test_gui_scheduling.py new file mode 100644 index 00000000..62837c0a --- /dev/null +++ b/tests/test_gui_scheduling.py @@ -0,0 +1,210 @@ +""" +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 wgpu.gui 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.force_draw() + assert canvas.draw_count == 3 + + +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 == 1 + + +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 == 1 + + +if __name__ == "__main__": + run_tests(globals()) From 4e9b483eb9164bf0e93441dbc9294161b5e4e3d9 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 22 Oct 2024 15:33:25 +0200 Subject: [PATCH 30/49] Change qt behavior for set_title --- wgpu/gui/qt.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 5dfd8f73..51259033 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -268,9 +268,9 @@ def set_logical_size(self, width, height): self.resize(width, height) # See comment on pixel ratio def set_title(self, title): - self.setWindowTitle(title) - if isinstance(self.parent(), QWgpuCanvas): - self.parent().setWindowTitle(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. + pass def close(self): QtWidgets.QWidget.close(self) @@ -506,7 +506,7 @@ def set_logical_size(self, width, height): self.resize(width, height) # See comment on pixel ratio def set_title(self, title): - self._subwidget.set_title(title) + self.setWindowTitle(title) def close(self): QtWidgets.QWidget.close(self) From 42ae8b745e515ad836eef240c20a8340429fc57c Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 24 Oct 2024 10:17:36 +0200 Subject: [PATCH 31/49] Improvements --- wgpu/gui/_loop.py | 24 ++++++++++++------------ wgpu/gui/base.py | 27 ++++++++++++++++++++++----- wgpu/gui/jupyter.py | 11 +++++++++++ wgpu/gui/qt.py | 39 +++++++++++++++++++++++---------------- 4 files changed, 68 insertions(+), 33 deletions(-) diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 4aa02ffa..63c460c6 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -264,8 +264,7 @@ class Scheduler: # | || || || | # --------------------------------------------------------------------> time # | | | | | - # | | draw | draw - # schedule tick tick + # 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: @@ -275,8 +274,7 @@ class Scheduler: # | || || | # --------------------------------------------------------------------> time # | | | | - # | | | draw - # schedule tick tick + # 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() @@ -287,7 +285,7 @@ class Scheduler: # 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 event loop. + # 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 @@ -385,6 +383,9 @@ def _tick(self): 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": @@ -398,19 +399,18 @@ def _tick(self): 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. - its_time_to_draw = ( - time.perf_counter() - self._last_draw_time > 1 / self._min_fps - ) - if not self._draw_requested: - canvas._process_events() # handlers may request a draw - if self._draw_requested or its_time_to_draw: + 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() # time to do a draw else: self._schedule_next_tick() elif self._mode == "manual": # manual: never draw, except when ... ? - canvas._process_events() self._schedule_next_tick() else: diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 54a6e8b4..ec4978f1 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -152,7 +152,7 @@ def submit_event(self, event): submit_event.__doc__ = EventEmitter.submit.__doc__ def _process_events(self): - """Process events and animations. To be called right before a draw, and from the scheduler.""" + """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 @@ -235,8 +235,7 @@ def _draw_frame_and_present(self): # 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. - # Process events - self._process_events() + # Process special events self._events.submit({"event_type": "before_draw"}) self._events.flush() @@ -269,8 +268,15 @@ def _request_draw(self): 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 (eventually). By default does nothing, which is equivalent to - waiting for a forced draw or a draw invoked by the GUI system. + is made as soon as possible. + + Canvases that have a limit on how fast they can 'consume' frames, like + remote frame buffers, do good to call self._process_events() when the + draw had to wait a little. That way the user interaction will lag as + little as possible. + + The default implementation does nothing, which is equivalent to waiting + for a forced draw or a draw invoked by the GUI system. """ pass @@ -318,3 +324,14 @@ def set_logical_size(self, width, height): def set_title(self, title): """Set the window title.""" pass + + +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/wgpu/gui/jupyter.py b/wgpu/gui/jupyter.py index 3fde8f9f..6b5f1499 100644 --- a/wgpu/gui/jupyter.py +++ b/wgpu/gui/jupyter.py @@ -3,6 +3,7 @@ can be used as cell output, or embedded in an ipywidgets gui. """ +import time import weakref from .base import WgpuCanvasBase @@ -24,6 +25,7 @@ def __init__(self, *, size=None, title=None, **kwargs): self._pixel_ratio = 1 self._logical_size = 0, 0 self._is_closed = False + self._draw_request_time = 0 # Register so this can be display'ed when run() is called loop._pending_jupyter_canvases.append(weakref.ref(self)) @@ -51,6 +53,14 @@ def get_frame(self): # present_context.present(), which calls our present() method. # The result is either a numpy array or None, and this matches # with what this method is expected to return. + + # When we had to wait relatively long for the drawn to be made, + # we do another round processing events, to minimize the perceived lag. + # We only do this when the delay is significant, so that under good + # circumstances, the scheduling behaves the same as for other canvases. + if time.perf_counter() - self._draw_request_time > 0.02: + self._process_events() + self._draw_frame_and_present() return self._last_image @@ -84,6 +94,7 @@ def is_closed(self): return self._is_closed def _request_draw(self): + self._draw_request_time = time.perf_counter() RemoteFrameBuffer.request_draw(self) def _force_draw(self): diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 51259033..f64c6cff 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -7,7 +7,7 @@ import ctypes import importlib -from .base import WgpuCanvasBase, WgpuLoop, WgpuTimer +from .base import WgpuCanvasBase, WgpuLoop, WgpuTimer, pop_kwargs_for_base_canvas from ._gui_utils import ( logger, SYSTEM_IS_WAYLAND, @@ -163,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) @@ -195,8 +197,7 @@ def _force_draw(self): # call_later(), and process more of our events, and maybe even another call to # this method, if the user was not careful. self.repaint() - if not self._present_to_screen: - loop._app.processEvents() + loop._app.processEvents() def _get_loop(self): return loop @@ -270,16 +271,15 @@ def set_logical_size(self, width, height): 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. - pass + parent = self.parent() + if isinstance(parent, QWgpuCanvas): + parent.setWindowTitle(title) def close(self): QtWidgets.QWidget.close(self) def is_closed(self): - try: - return not self.isVisible() - except Exception: - return True # Internal C++ object already deleted + return self._is_closed # User events to jupyter_rfb events @@ -397,14 +397,20 @@ def resizeEvent(self, event): # noqa: N802 self.submit_event(ev) def closeEvent(self, event): # noqa: N802 + 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). @@ -424,8 +430,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 @@ -433,6 +437,10 @@ 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(WgpuCanvasBase, QtWidgets.QWidget): """A toplevel Qt widget providing a wgpu canvas.""" @@ -442,12 +450,12 @@ class QWgpuCanvas(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 loop.init_qt() + + sub_kwargs = pop_kwargs_for_base_canvas(kwargs) super().__init__(**kwargs) self.setAttribute(WA_DeleteOnClose, True) @@ -455,9 +463,7 @@ def __init__( self.setWindowTitle(title or "qt wgpu canvas") self.setMouseTracking(True) - self._subwidget = QWgpuWidget( - self, max_fps=max_fps, present_method=present_method - ) + self._subwidget = QWgpuWidget(self, **sub_kwargs) self._events = self._subwidget._events # Note: At some point we called `self._subwidget.winId()` here. For some @@ -475,6 +481,7 @@ def __init__( # Qt methods 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) From 2e0a20625eeac2574f41142f44bd3d18d635b74b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 24 Oct 2024 10:53:49 +0200 Subject: [PATCH 32/49] Fix jupyter --- examples/wgpu-examples.ipynb | 83 ++++++++++++++++++++++-------------- wgpu/gui/jupyter.py | 12 ++++-- 2 files changed, 60 insertions(+), 35 deletions(-) diff --git a/examples/wgpu-examples.ipynb b/examples/wgpu-examples.ipynb index bb580c7f..d7bf123d 100644 --- a/examples/wgpu-examples.ipynb +++ b/examples/wgpu-examples.ipynb @@ -27,7 +27,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "5ed60fb173574ec4be1cf2000ffb5fc3", + "model_id": "d5d59c9602ba4f11bb8c1da217bdd92f", "version_major": 2, "version_minor": 0 }, @@ -41,12 +41,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "b434f9aabf374f3caf167f0f7ed48822", + "model_id": "77136d6c48df4bfaac68ea38f883d2f4", "version_major": 2, "version_minor": 0 }, "text/html": [ - "
snapshot
" + "
snapshot
" ], "text/plain": [ "JupyterWgpuCanvas(css_height='480px', css_width='640px')" @@ -59,11 +59,13 @@ ], "source": [ "from wgpu.gui.auto import WgpuCanvas\n", - "import triangle\n", + "from triangle import setup_drawing_sync\n", "\n", - "canvas = WgpuCanvas(size=(640, 480), title=\"wgpu triangle with GLFW\")\n", + "canvas = WgpuCanvas(size=(640, 480))\n", + "\n", + "draw_frame = setup_drawing_sync(canvas)\n", + "canvas.request_draw(draw_frame)\n", "\n", - "triangle.main(canvas)\n", "canvas" ] }, @@ -77,24 +79,24 @@ "An interactive example this time." ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "edfbd892-b0a6-473c-9176-254b6719a5cb", + "metadata": {}, + "outputs": [], + "source": [] + }, { "cell_type": "code", "execution_count": 2, "id": "e4f9f67d", "metadata": {}, "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Available adapters on this system:\n", - "Apple M1 Pro (IntegratedGPU) via Metal\n" - ] - }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "871cd2fc00334b1b8c7f82e2676916a3", + "model_id": "80518d535dc948ba9921ca84b1379ec1", "version_major": 2, "version_minor": 0 }, @@ -108,12 +110,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "f6aa0a0596cc47a2a5c63e6ecaa32991", + "model_id": "7d1d761e0a424e5dadfe5293978880cd", "version_major": 2, "version_minor": 0 }, "text/html": [ - "
snapshot
" + "
snapshot
" ], "text/plain": [ "JupyterWgpuCanvas(css_height='480px', css_width='640px')" @@ -125,7 +127,12 @@ } ], "source": [ - "from cube import canvas\n", + "from cube import setup_drawing_sync\n", + "\n", + "canvas = WgpuCanvas(size=(640, 480), max_fps= 10, update_mode='continuous')\n", + "\n", + "draw_frame = setup_drawing_sync(canvas)\n", + "canvas.request_draw(draw_frame)\n", "\n", "canvas" ] @@ -141,13 +148,13 @@ { "cell_type": "code", "execution_count": 3, - "id": "6d0e64b7-a208-4be6-99eb-9f666ab8c2ae", + "id": "17773a3a-aae1-4307-9bdb-220b14802a68", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "a670ad10911d4335bd54a71d2585deda", + "model_id": "c00cba8b9207416ca243b045af7d7010", "version_major": 2, "version_minor": 0 }, @@ -161,27 +168,41 @@ } ], "source": [ + "import time\n", "import ipywidgets\n", "\n", "out = ipywidgets.Textarea(rows=10)\n", - "\n", - "\n", - "@canvas.add_event_handler(\"*\")\n", - "def show_events(event):\n", - " if event[\"event_type\"] != \"pointer_move\":\n", - " out.value = str(event)\n", - "\n", - "\n", "out" ] }, { "cell_type": "code", - "execution_count": null, - "id": "17773a3a-aae1-4307-9bdb-220b14802a68", + "execution_count": 4, + "id": "8834d488-982a-45fb-9725-d3f3d34cdde2", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "@canvas.add_event_handler(\"*\")\n", + "def process_event(event):\n", + " if event[\"event_type\"] not in [\"pointer_move\", \"before_draw\", \"animate\"]:\n", + " out.value = str(event)\n", + "\n", + " if event[\"event_type\"] == \"key_down\":\n", + " if event[\"key\"] == \"Escape\":\n", + " canvas.close()\n", + " elif event[\"key\"] == \" \":\n", + " etime = time.time() + 2\n", + " i = 0\n", + " while time.time() < etime:\n", + " i += 1\n", + " canvas.force_draw()\n", + " print(f\"force-drawed {i} frames in 2s.\")\n", + " elif event[\"event_type\"] == \"close\":\n", + " # Should see this exactly once, either when pressing escape, or\n", + " # when pressing the window close button.\n", + " print(\"Close detected!\")\n", + " assert canvas.is_closed()" + ] } ], "metadata": { diff --git a/wgpu/gui/jupyter.py b/wgpu/gui/jupyter.py index 6b5f1499..7c6b2ac4 100644 --- a/wgpu/gui/jupyter.py +++ b/wgpu/gui/jupyter.py @@ -44,9 +44,7 @@ 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): # The _draw_frame_and_present() does the drawing and then calls @@ -98,7 +96,13 @@ def _request_draw(self): RemoteFrameBuffer.request_draw(self) def _force_draw(self): - raise NotImplementedError() # todo: how? + # A bit hacky to use the internals of jupyter_rfb this way. + # This pushes frames to the browser. The only thing holding + # this back it the websocket buffer. It works! + # But a better way would be `await canvas.wait_draw()`. + array = self.get_frame() + if array is not None: + self._rfb_send_frame(array) # Implementation needed for WgpuCanvasInterface From c7e32dff7797268a69e921d2684b981efd9e6d36 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 24 Oct 2024 11:18:22 +0200 Subject: [PATCH 33/49] implement wx more (untested) --- wgpu/gui/wx.py | 54 ++++++++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index 1f4aa9bb..fcaa0b91 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -15,7 +15,7 @@ get_alt_x11_display, get_alt_wayland_display, ) -from .base import WgpuCanvasBase, WgpuLoop +from .base import WgpuCanvasBase, WgpuLoop, WgpuTimer, pop_kwargs_for_base_canvas BUTTON_MAP = { @@ -381,7 +381,10 @@ def set_logical_size(self, width, height): self.SetSize(width, height) def set_title(self, title): - pass # only on frames + # Set title only on frame + parent = self.parent() + if isinstance(parent, WxWgpuCanvas): + parent.setWindowTitle(title) def _request_draw(self): # Despite the FPS limiting the delayed call to refresh solves @@ -424,19 +427,16 @@ def __init__( parent=None, size=None, title=None, - max_fps=30, - present_method=None, **kwargs, ): 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") - self._subwidget = WxWgpuWindow( - parent=self, max_fps=max_fps, present_method=present_method - ) + self._subwidget = WxWgpuWindow(parent=self, **sub_kwargs) self._events = self._subwidget._events self.Bind(wx.EVT_CLOSE, lambda e: self.Destroy()) @@ -444,13 +444,9 @@ def __init__( # wx methods - # def Refresh(self) - # super().Refresh() - # self._subwidget.Refresh() - # Methods that we add from wgpu def _get_loop(self): - return loop + return None # wrapper widget does not have scheduling def get_present_info(self): return self._subwidget.get_present_info() @@ -476,7 +472,6 @@ def _request_draw(self): return self._subwidget._request_draw() def close(self): - self.submit_event({"event_type": "close"}) super().close() def is_closed(self): @@ -499,10 +494,22 @@ def present_image(self, image, **kwargs): WgpuCanvas = WxWgpuCanvas +class WxWgpuTimer(WgpuTimer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._wx_timer = wx.Timer() + self._wx_timer.Notify = self._tick + + def _run(self): + self._wx_timer.StartOnce(int(self._interval * 1000)) + + def _stop(self): + self._wx_timer.Stop() + + class WxWgpuLoop(WgpuLoop): - def __init__(self): - super.__init__() - self._the_app = None + _TimerClass = WxWgpuTimer + _the_app = None def init_wx(self): _ = self._app @@ -515,18 +522,17 @@ def _app(self): wx.App.SetInstance(app) return app - def _wgpu_gui_poll(self): - pass # We can assume the wx loop is running. - - def call_later(self, delay, callback, *args): - wx.CallLater(int(delay * 1000), callback, args) - # todo: does this work, or do we need to keep a ref to the result? + def _call_soon(self, delay, callback, *args): + wx.CallSoon(callback, args) - def run(self): + def _run(self): self._app.MainLoop() - def stop(self): + def _stop(self): pass # Possible with wx? + def _wgpu_gui_poll(self): + pass # We can assume the wx loop is running. + loop = WxWgpuLoop() From 31cf8d413f73521b7558994bf52bba862549f76f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 24 Oct 2024 11:32:11 +0200 Subject: [PATCH 34/49] Fix for glfw on linux --- wgpu/gui/base.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index ec4978f1..72f734c0 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -232,10 +232,17 @@ def _draw_frame_and_present(self): Errors are logged to the "wgpu" logger. Should be called by the subclass at its draw event. """ + # 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.submit({"event_type": "before_draw"}) self._events.flush() From 8463a54723aa95fd2c3232ee2b62eedff8ea184f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 24 Oct 2024 10:27:49 +0200 Subject: [PATCH 35/49] qt specialized call_soon, and no need to process events on windows --- wgpu/gui/qt.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index f64c6cff..f2c32adf 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -197,7 +197,7 @@ def _force_draw(self): # call_later(), and process more of our events, and maybe even another call to # this method, if the user was not careful. self.repaint() - loop._app.processEvents() + # loop._app.processEvents() def _get_loop(self): return loop @@ -567,6 +567,12 @@ 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 From d4f6f898b413bf433572c35b217e6a59e077a2dc Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 24 Oct 2024 10:32:14 +0200 Subject: [PATCH 36/49] Prevent/detect drawing while drawing --- wgpu/gui/base.py | 61 +++++++++++++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 72f734c0..3e533d55 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -112,6 +112,7 @@ def __init__( self._vsync = bool(vsync) present_method # noqa - We just catch the arg here in case a backend does implement it + self.__is_drawing = False self._events = EventEmitter() self._scheduler = None loop = self._get_loop() @@ -224,6 +225,8 @@ def request_draw(self, draw_function=None): def force_draw(self): """Perform 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): @@ -233,33 +236,43 @@ def _draw_frame_and_present(self): subclass at its draw event. """ - # 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(): + # Re-entrent drawing is problematic. Let's actively prevent it. + if self.__is_drawing: return + self.__is_drawing = True - # 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.submit({"event_type": "before_draw"}) - self._events.flush() + try: - # Notify the scheduler - if self._scheduler is not None: - self._scheduler.on_draw() - - # 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() + # 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.submit({"event_type": "before_draw"}) + self._events.flush() + + # Notify the scheduler + if self._scheduler is not None: + self._scheduler.on_draw() + + # 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: From 4493b49c44d7f8da8372cd629d7f7e333ea8b3e2 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 24 Oct 2024 10:33:38 +0200 Subject: [PATCH 37/49] fix event processing issue leading to re-entrent drawing --- tests/test_gui_events.py | 20 ++++++++++++++++++++ wgpu/gui/_events.py | 26 +++++++++++++++++--------- wgpu/gui/base.py | 3 +-- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/tests/test_gui_events.py b/tests/test_gui_events.py index a247e6e0..1a9fba1b 100644 --- a/tests/test_gui_events.py +++ b/tests/test_gui_events.py @@ -83,6 +83,26 @@ def handler(event): 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() diff --git a/wgpu/gui/_events.py b/wgpu/gui/_events.py index a6b5d98c..a3c25f42 100644 --- a/wgpu/gui/_events.py +++ b/wgpu/gui/_events.py @@ -189,15 +189,23 @@ def flush(self): event = self._pending_events.popleft() except IndexError: break - # 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) + 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.""" diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 3e533d55..ecc09019 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -253,8 +253,7 @@ def _draw_frame_and_present(self): # Process special events # Note that we must not process normal events here, since these can do stuff # with the canvas (resize/close/etc) and most GUI systems don't like that. - self._events.submit({"event_type": "before_draw"}) - self._events.flush() + self._events.emit({"event_type": "before_draw"}) # Notify the scheduler if self._scheduler is not None: From 1a17427e65198a9406cb7fe2ac1283aa10e02ca2 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 24 Oct 2024 10:35:24 +0200 Subject: [PATCH 38/49] tweak example and ruff --- examples/gui_events.py | 8 +++++++- examples/wgpu-examples.ipynb | 2 +- wgpu/gui/base.py | 1 - 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/gui_events.py b/examples/gui_events.py index b390ca6f..9bdff075 100644 --- a/examples/gui_events.py +++ b/examples/gui_events.py @@ -11,7 +11,13 @@ from cube import setup_drawing_sync -canvas = WgpuCanvas(size=(640, 480), title="wgpu events", max_fps=10) +canvas = WgpuCanvas( + size=(640, 480), + title="wgpu events", + max_fps=10, + update_mode="continuous", + present_method="screen", +) draw_frame = setup_drawing_sync(canvas) canvas.request_draw(lambda: (draw_frame(), canvas.request_draw())) diff --git a/examples/wgpu-examples.ipynb b/examples/wgpu-examples.ipynb index d7bf123d..6a663887 100644 --- a/examples/wgpu-examples.ipynb +++ b/examples/wgpu-examples.ipynb @@ -129,7 +129,7 @@ "source": [ "from cube import setup_drawing_sync\n", "\n", - "canvas = WgpuCanvas(size=(640, 480), max_fps= 10, update_mode='continuous')\n", + "canvas = WgpuCanvas(size=(640, 480), max_fps=10, update_mode=\"continuous\")\n", "\n", "draw_frame = setup_drawing_sync(canvas)\n", "canvas.request_draw(draw_frame)\n", diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index ecc09019..31199dbc 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -242,7 +242,6 @@ def _draw_frame_and_present(self): self.__is_drawing = True 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. From a7b024c269cc4dce927a225b7d37570f63d667a6 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 24 Oct 2024 14:48:34 +0200 Subject: [PATCH 39/49] Fix wx, and small tweaks to qt --- examples/gui_events.py | 6 ++- wgpu/gui/_loop.py | 14 +++++- wgpu/gui/glfw.py | 2 +- wgpu/gui/qt.py | 9 ++-- wgpu/gui/wx.py | 104 +++++++++++++++++++++++++++-------------- 5 files changed, 91 insertions(+), 44 deletions(-) diff --git a/examples/gui_events.py b/examples/gui_events.py index 9bdff075..f1af4ac3 100644 --- a/examples/gui_events.py +++ b/examples/gui_events.py @@ -6,7 +6,7 @@ import time -from wgpu.gui.auto import WgpuCanvas, loop +from wgpu.gui.wx import WgpuCanvas, loop from cube import setup_drawing_sync @@ -16,8 +16,10 @@ title="wgpu events", max_fps=10, update_mode="continuous", - present_method="screen", + present_method="", ) + + draw_frame = setup_drawing_sync(canvas) canvas.request_draw(lambda: (draw_frame(), canvas.request_draw())) diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index 63c460c6..f4928767 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -27,7 +27,7 @@ def __init__(self, loop, callback, *args, one_shot=False): self._args = args # Internal variables self._one_shot = bool(one_shot) - self._interval = 0.0 + self._interval = None self._expect_tick_at = None def start(self, interval): @@ -39,10 +39,12 @@ def start(self, interval): 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 = float(interval) + self._interval = max(0.0, float(interval)) self._expect_tick_at = time.perf_counter() + self._interval self._start() @@ -92,6 +94,14 @@ 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: diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index 8ab03401..9e5dc999 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -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): diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index f2c32adf..cdefa456 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -146,7 +146,7 @@ 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: @@ -221,9 +221,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) @@ -541,8 +545,7 @@ def present_image(self, image, **kwargs): class QtWgpuTimer(WgpuTimer): """Wgpu timer basef on Qt.""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def _init(self): self._qt_timer = QtCore.QTimer() self._qt_timer.timeout.connect(self._tick) self._qt_timer.setSingleShot(True) diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index fcaa0b91..5a7788b2 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/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 @@ -119,18 +120,6 @@ 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(WgpuCanvasBase, wx.Window): """A wx Window representing a wgpu canvas that can be embedded in a wx application.""" @@ -138,7 +127,7 @@ 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: @@ -151,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). @@ -169,6 +157,8 @@ 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 @@ -195,7 +185,7 @@ def _on_resize(self, event: wx.SizeEvent): def _on_resize_done(self, *args): self._draw_lock = False - self._request_draw() + self.Refresh() # Methods for input events @@ -304,7 +294,7 @@ def _mouse_event(self, event_type: str, event: wx.MouseEvent, touches: bool = Tr self.submit_event(ev) elif event_type == "pointer_move": - self._hand_event.submit(ev) + self.submit_event(ev) else: self.submit_event(ev) @@ -342,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"} @@ -382,23 +374,28 @@ def set_logical_size(self, width, height): def set_title(self, title): # Set title only on frame - parent = self.parent() + parent = self.Parent if isinstance(parent, WxWgpuCanvas): - parent.setWindowTitle(title) + 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): @@ -442,8 +439,18 @@ def __init__( 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() + # wx methods + 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 @@ -471,11 +478,15 @@ def set_title(self, title): def _request_draw(self): return self._subwidget._request_draw() + def _force_draw(self): + return self._subwidget._force_draw() + def close(self): - 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 @@ -494,13 +505,23 @@ def present_image(self, image, **kwargs): WgpuCanvas = WxWgpuCanvas +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, *args, **kwargs): - super().__init__(*args, **kwargs) - self._wx_timer = wx.Timer() - self._wx_timer.Notify = self._tick + def _init(self): + self._wx_timer = TimerWithCallback(self._tick) - def _run(self): + def _start(self): self._wx_timer.StartOnce(int(self._interval * 1000)) def _stop(self): @@ -510,6 +531,7 @@ def _stop(self): class WxWgpuLoop(WgpuLoop): _TimerClass = WxWgpuTimer _the_app = None + _frame_to_keep_loop_alive = None def init_wx(self): _ = self._app @@ -526,13 +548,23 @@ 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): - pass # Possible with wx? + 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 process_wx_events(self): + old = wx.GUIEventLoop.GetActive() + new = wx.GUIEventLoop() + wx.GUIEventLoop.SetActive(new) + while new.Pending(): + new.Dispatch() + wx.GUIEventLoop.SetActive(old) + loop = WxWgpuLoop() From fe9f63ee77d2b846b0da5a3f7da21809996e4b17 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 24 Oct 2024 14:54:20 +0200 Subject: [PATCH 40/49] Add comment --- examples/gui_events.py | 2 +- wgpu/gui/qt.py | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/examples/gui_events.py b/examples/gui_events.py index f1af4ac3..03706635 100644 --- a/examples/gui_events.py +++ b/examples/gui_events.py @@ -6,7 +6,7 @@ import time -from wgpu.gui.wx import WgpuCanvas, loop +from wgpu.gui.auto import WgpuCanvas, loop from cube import setup_drawing_sync diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index cdefa456..ec225de2 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -190,14 +190,10 @@ def _request_draw(self): def _force_draw(self): # Call the paintEvent right now. - # * When drawing to the screen, directly calling _draw_frame_and_present() - # actually works, but let's play as nice as we can be. - # * When drawing via the image, calling repaint() is not enough, we also need to - # call processEvents(). Note that this may also process our scheduler's - # call_later(), and process more of our events, and maybe even another call to - # this method, if the user was not careful. + # 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() - # loop._app.processEvents() def _get_loop(self): return loop From edf57065df063c7ab8f0022cec0a246735f5ac92 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 25 Oct 2024 15:15:06 +0200 Subject: [PATCH 41/49] expose loop.run for backwards compat --- wgpu/gui/auto.py | 3 ++- wgpu/gui/glfw.py | 1 + wgpu/gui/qt.py | 1 + wgpu/gui/wx.py | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/wgpu/gui/auto.py b/wgpu/gui/auto.py index a53db5a7..dfb107fd 100644 --- a/wgpu/gui/auto.py +++ b/wgpu/gui/auto.py @@ -5,7 +5,7 @@ for e.g. wx later. Or we might decide to stick with these three. """ -__all__ = ["WgpuCanvas", "loop"] +__all__ = ["WgpuCanvas", "loop", "run"] import os import sys @@ -189,3 +189,4 @@ def backends_by_trying_in_order(): # Load! module = select_backend() WgpuCanvas, loop = module.WgpuCanvas, module.loop +run = loop.run # backwards compat diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index 9e5dc999..6774fc4e 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -548,6 +548,7 @@ def _run(self): loop = GlfwAsyncioWgpuLoop() +run = loop.run # backwards compat def poll_glfw_briefly(poll_time=0.1): diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index ec225de2..ebf69642 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -599,3 +599,4 @@ def _wgpu_gui_poll(self): loop = QtWgpuLoop() +run = loop.run # backwards compat diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index 5a7788b2..0e35c142 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -568,3 +568,4 @@ def process_wx_events(self): loop = WxWgpuLoop() +run = loop.run # backwards compat From b1096ef592ba75029e9177d873b83917ae257eb7 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 28 Oct 2024 10:55:43 +0100 Subject: [PATCH 42/49] self review, and title logic --- examples/gui_demo.py | 55 +++++++++++++++++++++++++++++ examples/gui_events.py | 28 ++------------- wgpu/gui/_loop.py | 78 +++++++++++++++++------------------------- wgpu/gui/base.py | 30 ++++++++++++---- wgpu/gui/glfw.py | 8 +++-- wgpu/gui/jupyter.py | 7 ++-- wgpu/gui/offscreen.py | 3 +- wgpu/gui/qt.py | 25 +++++++++----- wgpu/gui/wx.py | 20 ++++++++--- 9 files changed, 155 insertions(+), 99 deletions(-) create mode 100644 examples/gui_demo.py diff --git a/examples/gui_demo.py b/examples/gui_demo.py new file mode 100644 index 00000000..8389758a --- /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 wgpu.gui.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_events.py b/examples/gui_events.py index 03706635..0fcea37f 100644 --- a/examples/gui_events.py +++ b/examples/gui_events.py @@ -1,27 +1,19 @@ """ A simple example to demonstrate events. - -Also serves as a test-app for the canvas backends. """ -import time - from wgpu.gui.auto import WgpuCanvas, loop from cube import setup_drawing_sync canvas = WgpuCanvas( - size=(640, 480), - title="wgpu events", - max_fps=10, - update_mode="continuous", - present_method="", + size=(640, 480), title="Canvas events on $backend", update_mode="continuous" ) draw_frame = setup_drawing_sync(canvas) -canvas.request_draw(lambda: (draw_frame(), canvas.request_draw())) +canvas.request_draw(draw_frame) @canvas.add_event_handler("*") @@ -29,22 +21,6 @@ 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/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index f4928767..ccb17cfe 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -9,7 +9,7 @@ from ..enums import Enum # 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 to too weird use-case for the added complexity. +# That would e.g. allow using glfw with qt together. Probably a too weird use-case for the added complexity. class WgpuTimer: @@ -128,6 +128,14 @@ class WgpuLoop: 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): @@ -237,20 +245,6 @@ def _wgpu_gui_poll(self): pass -class AnimationScheduler: - """ - Some ideas: - - * canvas.add_event_handler("animate", callback) - * canvas.animate.add_handler(1/30, callback) - """ - - # def iter(self): - # # Something like this? - # for scheduler in all_schedulers: - # scheduler._event_emitter.submit_and_dispatch(event) - - class UpdateMode(Enum): """The different modes to schedule draws for the canvas.""" @@ -299,8 +293,8 @@ class Scheduler: # # 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, events are also - # processed right before doing the draw. + # 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 @@ -314,7 +308,6 @@ class Scheduler: # don't affect the scheduling loop; they are just extra draws. def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30): - # Objects related to the canvas. # 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! @@ -322,10 +315,6 @@ def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30): self._events = canvas._events # ... = canvas.get_context() -> No, context creation should be lazy! - # We need to call_later and process gui events. The loop object abstracts these. - assert loop is not None - loop._register_scheduler(self) - # Scheduling variables if mode not in UpdateMode: raise ValueError( @@ -335,20 +324,21 @@ def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30): self._min_fps = float(min_fps) self._max_fps = float(max_fps) self._draw_requested = True # Start with a draw in ondemand mode - - # Stats self._last_draw_time = 0 + + # Keep track of fps self._draw_stats = 0, time.perf_counter() - # Variables for animation - self._animation_time = 0 - self._animation_step = 1 / 20 + assert loop is not None - # Initialise the scheduling loop. Note that the gui may do a first draw - # earlier, starting the loop, and that's fine. + # 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(): @@ -415,7 +405,7 @@ def _tick(self): self._min_fps > 0 and time.perf_counter() - self._last_draw_time > 1 / self._min_fps ): - canvas._request_draw() # time to do a draw + canvas._request_draw() else: self._schedule_next_tick() @@ -429,26 +419,22 @@ def _tick(self): def on_draw(self): """Called from canvas._draw_frame_and_present().""" - # It could be that the canvas is closed now. When that happens, - # we stop here and do not schedule a new iter. - if (canvas := self._get_canvas()) is None: - return + # 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: - self._draw_stats = count + 1, last_time + fps = None + self._draw_stats = count, last_time - # Stats (uncomment to see fps) - count, last_time = self._draw_stats - fps = count / (time.perf_counter() - last_time) - canvas.set_title(f"wgpu {fps:0.1f} fps") - - # Bookkeeping - self._last_draw_time = time.perf_counter() - self._draw_requested = False - - # Keep ticking - self._schedule_next_tick() + # Return fps or None. Will change with better stats at some point + return fps diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 31199dbc..a44c464d 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -112,6 +112,13 @@ def __init__( self._vsync = bool(vsync) 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 @@ -216,12 +223,9 @@ def request_draw(self, draw_function=None): if self._scheduler is not None: self._scheduler.request_draw() - # todo: maybe requesting a new draw can be done by setting a field in an event? - # todo: can just make the draw_function a handler for the draw event? - # -> Note that the draw func is likely to hold a ref to the canvas. By storing it - # here, the circular ref can be broken. This fails if we'd store _draw_frame on the - # scheduler! So with a draw event, we should provide the context and more info so - # that a draw funcion does not need the canvas object. + # -> 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.""" @@ -256,7 +260,13 @@ def _draw_frame_and_present(self): # Notify the scheduler if self._scheduler is not None: - self._scheduler.on_draw() + 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. @@ -341,6 +351,12 @@ def set_logical_size(self, width, height): 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) + + def _set_title(self, title): pass diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index 6774fc4e..d116c513 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -151,9 +151,10 @@ def __init__(self, *, size=None, title=None, **kwargs): 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) @@ -195,7 +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.set_title(title) # Callbacks to provide a minimal working canvas for wgpu @@ -323,7 +327,7 @@ 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 close(self): diff --git a/wgpu/gui/jupyter.py b/wgpu/gui/jupyter.py index 7c6b2ac4..5f227627 100644 --- a/wgpu/gui/jupyter.py +++ b/wgpu/gui/jupyter.py @@ -82,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): @@ -97,9 +97,10 @@ def _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. The only thing holding - # this back it the websocket buffer. It works! + # 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) diff --git a/wgpu/gui/offscreen.py b/wgpu/gui/offscreen.py index 0d5bf07f..2a9c8ee9 100644 --- a/wgpu/gui/offscreen.py +++ b/wgpu/gui/offscreen.py @@ -11,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 @@ -38,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): diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index ebf69642..046c29c6 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -266,9 +266,13 @@ 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 + parent = self.parent() + if isinstance(parent, QWgpuCanvas): + parent.resize(width, height) + else: + self.resize(width, height) # See comment on pixel ratio - def set_title(self, title): + 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() @@ -458,9 +462,13 @@ def __init__(self, *, size=None, title=None, **kwargs): 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, **sub_kwargs) @@ -476,6 +484,8 @@ def __init__(self, *, size=None, title=None, **kwargs): self.setLayout(layout) layout.addWidget(self._subwidget) + self.set_logical_size(*size) + self.set_title(title) self.show() # Qt methods @@ -512,9 +522,6 @@ 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 close(self): QtWidgets.QWidget.close(self) @@ -523,6 +530,9 @@ def is_closed(self): # 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) @@ -594,7 +604,6 @@ def _stop(self): self._app.quit() def _wgpu_gui_poll(self): - # todo: make this a private method with a wgpu prefix. pass # we assume the Qt event loop is running. Calling processEvents() will cause recursive repaints. diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index 0e35c142..091222b6 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -370,9 +370,13 @@ 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): + def _set_title(self, title): # Set title only on frame parent = self.Parent if isinstance(parent, WxWgpuCanvas): @@ -430,8 +434,11 @@ def __init__( 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, **sub_kwargs) self._events = self._subwidget._events @@ -445,6 +452,9 @@ def __init__( 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 Destroy(self): # noqa: N802 - this is a wx method @@ -473,7 +483,7 @@ 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() From f13250fe6c971f8160460d326826fa7e88669ea1 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 28 Oct 2024 11:19:05 +0100 Subject: [PATCH 43/49] docs --- wgpu/gui/base.py | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index a44c464d..5d2d0739 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -1,6 +1,6 @@ import sys -from ._events import EventEmitter +from ._events import EventEmitter, WgpuEventType # noqa: F401 from ._loop import Scheduler, WgpuLoop, WgpuTimer # noqa: F401 from ._gui_utils import log_exception @@ -82,30 +82,32 @@ def present_image(self, image, **kwargs): class WgpuCanvasBase(WgpuCanvasInterface): - """A convenient base canvas class. + """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 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). - - 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, - min_fps=1, - max_fps=30, + update_mode="ondemand", + min_fps=1.0, + max_fps=30.0, vsync=True, present_method=None, - update_mode="ondemand", **kwargs, ): super().__init__(*args, **kwargs) @@ -228,7 +230,12 @@ def request_draw(self, draw_function=None): # this fails if we'd store _draw_frame on the scheduler! def force_draw(self): - """Perform a draw right now.""" + """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() From d053bc785bb873d35b6d4a24848b70018fad4f50 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 28 Oct 2024 11:23:41 +0100 Subject: [PATCH 44/49] update tests --- tests/test_gui_scheduling.py | 8 +++++--- wgpu/gui/base.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_gui_scheduling.py b/tests/test_gui_scheduling.py index 62837c0a..100ae2ce 100644 --- a/tests/test_gui_scheduling.py +++ b/tests/test_gui_scheduling.py @@ -129,8 +129,10 @@ def test_gui_scheduling_ondemand(): 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 == 3 + assert canvas.draw_count == 1 + assert canvas.events_count == 0 def test_gui_scheduling_ondemand_always_request_draw(): @@ -177,7 +179,7 @@ def _test_gui_scheduling_continuous(canvas): canvas.draw_count = canvas.events_count = 0 canvas.force_draw() assert canvas.draw_count == 1 - assert canvas.events_count == 1 + assert canvas.events_count == 0 def test_gui_scheduling_fastest(): @@ -203,7 +205,7 @@ def test_gui_scheduling_fastest(): canvas.draw_count = canvas.events_count = 0 canvas.force_draw() assert canvas.draw_count == 1 - assert canvas.events_count == 1 + assert canvas.events_count == 0 if __name__ == "__main__": diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 5d2d0739..1e362072 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -345,7 +345,7 @@ def close(self): def is_closed(self): """Get whether the window is closed.""" - raise NotImplementedError() + return False # === Secondary canvas management methods From 4dcc589ce72522486d477d70e421d77e05e52cb8 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 28 Oct 2024 11:31:44 +0100 Subject: [PATCH 45/49] disable a subtest on ci, plus fix docs --- tests/test_gui_offscreen.py | 9 +++++---- wgpu/gui/_events.py | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_gui_offscreen.py b/tests/test_gui_offscreen.py index 0d19aa4c..65e6cec8 100644 --- a/tests/test_gui_offscreen.py +++ b/tests/test_gui_offscreen.py @@ -15,10 +15,11 @@ def test_offscreen_selection_using_env_var(): ori = os.environ.get("WGPU_FORCE_OFFSCREEN", "") try: - for value in ["", "0", "false", "False", "wut"]: - os.environ["WGPU_FORCE_OFFSCREEN"] = value - module = select_backend() - assert module.WgpuCanvas is not WgpuManualOffscreenCanvas + 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 diff --git a/wgpu/gui/_events.py b/wgpu/gui/_events.py index a3c25f42..d989505f 100644 --- a/wgpu/gui/_events.py +++ b/wgpu/gui/_events.py @@ -66,8 +66,8 @@ 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. + 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. From d209a53dbc0b071cc5f01897a1a82ae981980f54 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 28 Oct 2024 11:39:33 +0100 Subject: [PATCH 46/49] fix test --- tests/test_gui_offscreen.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_gui_offscreen.py b/tests/test_gui_offscreen.py index 65e6cec8..f5f6daba 100644 --- a/tests/test_gui_offscreen.py +++ b/tests/test_gui_offscreen.py @@ -11,9 +11,13 @@ def test_offscreen_selection_using_env_var(): from wgpu.gui.offscreen import WgpuManualOffscreenCanvas - from wgpu.gui.auto import select_backend 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 wgpu.gui.auto import select_backend + try: if not os.getenv("CI"): for value in ["", "0", "false", "False", "wut"]: From 3fab756a8a414b0958b5cd3227a9a9172c641aab Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 7 Nov 2024 09:55:03 +0100 Subject: [PATCH 47/49] Overload qt canvas.update() to request a draw --- wgpu/gui/_loop.py | 2 +- wgpu/gui/qt.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/wgpu/gui/_loop.py b/wgpu/gui/_loop.py index ccb17cfe..fd9ab08c 100644 --- a/wgpu/gui/_loop.py +++ b/wgpu/gui/_loop.py @@ -360,7 +360,7 @@ def _schedule_next_tick(self): return # Determine delay - if self._mode == "fastest": + if self._mode == "fastest" or self._max_fps <= 0: delay = 0 else: delay = 1 / self._max_fps diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 046c29c6..aa2279c5 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -184,6 +184,10 @@ def paintEvent(self, event): # noqa: N802 - this is a Qt method # Methods that we add from wgpu (snake_case) + def update(self): + # Overload update() because that's how Qt devs are used to requesting a new draw + self.request_draw() + def _request_draw(self): # Ask Qt to do a paint event QtWidgets.QWidget.update(self) @@ -496,6 +500,10 @@ def closeEvent(self, event): # noqa: N802 # Methods that we add from wgpu (snake_case) + def update(self): + # Overload update() because that's how Qt devs are used to requesting a new draw + self.request_draw() + def _request_draw(self): self._subwidget._request_draw() From 3d22fcb830f0004e7498058d208384b38575e635 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 7 Nov 2024 10:09:36 +0100 Subject: [PATCH 48/49] better --- wgpu/gui/qt.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index aa2279c5..ae226035 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -184,10 +184,6 @@ def paintEvent(self, event): # noqa: N802 - this is a Qt method # Methods that we add from wgpu (snake_case) - def update(self): - # Overload update() because that's how Qt devs are used to requesting a new draw - self.request_draw() - def _request_draw(self): # Ask Qt to do a paint event QtWidgets.QWidget.update(self) @@ -494,16 +490,16 @@ def __init__(self, *, size=None, title=None, **kwargs): # 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) - def update(self): - # Overload update() because that's how Qt devs are used to requesting a new draw - self.request_draw() - def _request_draw(self): self._subwidget._request_draw() From fc05ed7a25ad0f8d130aa148c5f507d8fc7241f3 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 7 Nov 2024 10:12:02 +0100 Subject: [PATCH 49/49] no actually, this is better --- wgpu/gui/qt.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index ae226035..acca306a 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -184,6 +184,10 @@ def paintEvent(self, event): # noqa: N802 - this is a Qt method # Methods that we add from wgpu (snake_case) + def update(self): + # Overload update() because that's how Qt devs are used to requesting a new draw + self.request_draw() + def _request_draw(self): # Ask Qt to do a paint event QtWidgets.QWidget.update(self)