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()