diff --git a/examples/demo.py b/examples/demo.py index 07692b3..37f68fd 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -13,7 +13,7 @@ from rendercanvas.auto import RenderCanvas, loop -from cube import setup_drawing_sync +from rendercanvas.utils.cube import setup_drawing_sync canvas = RenderCanvas( diff --git a/examples/qt_app.py b/examples/qt_app.py index a109a48..13b8be5 100644 --- a/examples/qt_app.py +++ b/examples/qt_app.py @@ -31,7 +31,7 @@ def __init__(self): splitter = QtWidgets.QSplitter() self.button = QtWidgets.QPushButton("Hello world", self) - self.canvas = QRenderWidget(splitter) + self.canvas = QRenderWidget(splitter, update_mode="continuous") self.output = QtWidgets.QTextEdit(splitter) self.button.clicked.connect(self.whenButtonClicked) diff --git a/examples/qt_app_asyncio.py b/examples/qt_app_asyncio.py index f7af902..a9c5d38 100644 --- a/examples/qt_app_asyncio.py +++ b/examples/qt_app_asyncio.py @@ -49,7 +49,7 @@ def __init__(self): # todo: use update_mode = 'continuous' when that feature has arrived self.button = QtWidgets.QPushButton("Hello world", self) - self.canvas = QRenderWidget(splitter) + self.canvas = QRenderWidget(splitter, update_mode="continuous") self.output = QtWidgets.QTextEdit(splitter) # self.button.clicked.connect(self.whenButtonClicked) # see above :( diff --git a/examples/wx_app.py b/examples/wx_app.py index 8916de3..b20a629 100644 --- a/examples/wx_app.py +++ b/examples/wx_app.py @@ -2,6 +2,8 @@ An example demonstrating a wx app with a wgpu viz inside. """ +import time + import wx from rendercanvas.wx import RenderWidget @@ -13,30 +15,35 @@ def __init__(self): super().__init__(None, title="wgpu triangle embedded in a wx app") self.SetSize(640, 480) - splitter = wx.SplitterWindow(self) - + # Using present_method 'image' because it reports "The surface texture is suboptimal" + self.canvas = RenderWidget( + self, update_mode="continuous", present_method="image" + ) self.button = wx.Button(self, -1, "Hello world") - self.canvas1 = RenderWidget(splitter) - self.canvas2 = RenderWidget(splitter) - - splitter.SplitVertically(self.canvas1, self.canvas2) - splitter.SetSashGravity(0.5) + self.output = wx.StaticText(self) sizer = wx.BoxSizer(wx.HORIZONTAL) sizer.Add(self.button, 0, wx.EXPAND) - sizer.Add(splitter, 1, wx.EXPAND) + sizer.Add(self.canvas, 1, wx.EXPAND) + sizer.Add(self.output, 1, wx.EXPAND) self.SetSizer(sizer) + self.button.Bind(wx.EVT_BUTTON, self.OnClicked) + + # Force the canvas to be shown, so that it gets a valid handle. + # Otherwise GetHandle() is initially 0, and getting a surface will fail. self.Show() + def OnClicked(self, event): # noqa: N802 + t = self.output.GetLabel() + t += f"\nClicked at {time.time():0.1f}" + self.output.SetLabel(t) + app = wx.App() example = Example() -draw_frame1 = setup_drawing_sync(example.canvas1) -draw_frame2 = setup_drawing_sync(example.canvas2) - -example.canvas1.request_draw(draw_frame1) -example.canvas2.request_draw(draw_frame2) +draw_frame = setup_drawing_sync(example.canvas) +example.canvas.request_draw(draw_frame) app.MainLoop() diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 941e8a4..91f1842 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -307,12 +307,14 @@ 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="ondemand", min_fps=1, max_fps=30): + def __init__(self, canvas, events, loop, *, mode="ondemand", min_fps=1, max_fps=30): + assert loop is not None + # We don't keep a ref to the canvas to help gc. This scheduler object can be # referenced via a callback in an event loop, but it won't prevent the canvas # from being deleted! self._canvas_ref = weakref.ref(canvas) - self._events = canvas._events + self._events = events # ... = canvas.get_context() -> No, context creation should be lazy! # Scheduling variables @@ -329,8 +331,6 @@ def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30): # Keep track of fps self._draw_stats = 0, time.perf_counter() - assert loop is not None - # Initialise the timer that runs our scheduling loop. # Note that the backend may do a first draw earlier, starting the loop, and that's fine. self._last_tick_time = -0.1 @@ -390,22 +390,22 @@ def _tick(self): if self._mode == "fastest": # fastest: draw continuously as fast as possible, ignoring fps settings. - canvas._request_draw() + canvas._rc_request_draw() elif self._mode == "continuous": # continuous: draw continuously, aiming for a steady max framerate. - canvas._request_draw() + canvas._rc_request_draw() elif self._mode == "ondemand": # ondemand: draw when needed (detected by calls to request_draw). # Aim for max_fps when drawing is needed, otherwise min_fps. if self._draw_requested: - canvas._request_draw() + canvas._rc_request_draw() elif ( self._min_fps > 0 and time.perf_counter() - self._last_draw_time > 1 / self._min_fps ): - canvas._request_draw() + canvas._rc_request_draw() else: self._schedule_next_tick() diff --git a/rendercanvas/base.py b/rendercanvas/base.py index df65c5f..e1c641b 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -5,6 +5,17 @@ from ._gui_utils import log_exception +# Notes on naming and prefixes: +# +# Since BaseRenderCanvas can be used as a mixin with classes in a GUI framework, +# we must avoid using generic names to avoid name clashes. +# +# * `.public_method`: Public API: usually at least two words, (except the close() method) +# * `._private_method`: Private methods for scheduler and subclasses. +# * `.__private_attr`: Private to exactly this class. +# * `._rc_method`: Methods that the subclass must implement. + + class BaseRenderCanvas: """The base canvas class. @@ -12,6 +23,8 @@ class BaseRenderCanvas: code that is portable accross multiple GUI libraries and canvas targets. Arguments: + size (tuple): the logical size (width, height) of the canvas. + title (str): The title of the canvas. update_mode (EventType): 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. @@ -27,6 +40,8 @@ class BaseRenderCanvas: def __init__( self, *args, + size=(640, 480), + title="$backend", update_mode="ondemand", min_fps=1.0, max_fps=30.0, @@ -34,26 +49,60 @@ def __init__( present_method=None, **kwargs, ): + # Initialize superclass. Note that super() can be e.g. a QWidget, RemoteFrameBuffer, or object. super().__init__(*args, **kwargs) + + # If this is a wrapper, no need to initialize furher + if isinstance(self, WrapperRenderCanvas): + return + + # The vsync is not-so-elegantly strored on the canvas, and picked up by wgou's canvas contex. 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 = { + # Variables and flags used internally + self.__is_drawing = False + self.__title_info = { + "raw": "", "fps": "?", "backend": self.__class__.__name__, } - self.__is_drawing = False + # Events and scheduler self._events = EventEmitter() - self._scheduler = None - loop = self._get_loop() - if loop: - self._scheduler = Scheduler( - self, loop, min_fps=min_fps, max_fps=max_fps, mode=update_mode + self.__scheduler = None + loop = self._rc_get_loop() + if loop is not None: + self.__scheduler = Scheduler( + self, + self._events, + self._rc_get_loop(), + min_fps=min_fps, + max_fps=max_fps, + mode=update_mode, ) + # We cannot initialize the size and title now, because the subclass may not have done + # the initialization to support this. So we require the subclass to call _final_canvas_init. + self.__kwargs_for_later = dict(size=size, title=title) + + def _final_canvas_init(self): + """Must be called by the subclasses at the end of their ``__init__``. + + This sets the canvas size and title, which must happen *after* the widget itself + is initialized. Doing this automatically can be done with a metaclass, but let's keep it simple. + """ + # Pop kwargs + try: + kwargs = self.__kwargs_for_later + except AttributeError: + return + else: + del self.__kwargs_for_later + # Apply + if not isinstance(self, WrapperRenderCanvas): + self.set_logical_size(*kwargs["size"]) + self.set_title(kwargs["title"]) + def __del__(self): # On delete, we call the custom close method. try: @@ -67,7 +116,7 @@ def __del__(self): except Exception: pass - # === Implement WgpuCanvasInterface + # %% Implement WgpuCanvasInterface _canvas_context = None # set in get_context() @@ -97,11 +146,11 @@ def get_present_info(self): formats is ``["rgba8unorm-srgb", "rgba8unorm"]``, and the default alpha_modes is ``["opaque"]``. """ - raise NotImplementedError() + return self._rc_get_present_info() def get_physical_size(self): """Get the physical size of the canvas in integer pixels.""" - raise NotImplementedError() + return self._rc_get_physical_size() def get_context(self, kind="webgpu"): """Get the ``GPUCanvasContext`` object corresponding to this canvas. @@ -135,9 +184,9 @@ def present_image(self, image, **kwargs): Canvases that don't support offscreen rendering don't need to implement this method. """ - raise NotImplementedError() + self._rc_present_image(image, **kwargs) - # === Events + # %% Events def add_event_handler(self, *args, **kwargs): return self._events.add_handler(*args, **kwargs) @@ -155,6 +204,8 @@ def submit_event(self, event): remove_event_handler.__doc__ = EventEmitter.remove_handler.__doc__ submit_event.__doc__ = EventEmitter.submit.__doc__ + # %% Scheduling and drawing + def _process_events(self): """Process events and animations. Called from the scheduler.""" @@ -163,7 +214,7 @@ def _process_events(self): # when there are no draws (in ondemand and manual mode). # Get events from the GUI into our event mechanism. - loop = self._get_loop() + loop = self._rc_get_loop() if loop: loop._rc_gui_poll() @@ -191,8 +242,6 @@ def _process_events(self): # {"event_type": "animate", "step": step * n, "catch_up": n} # ) - # === Scheduling and drawing - def _draw_frame(self): """The method to call to draw a frame. @@ -216,8 +265,8 @@ def request_draw(self, draw_function=None): """ if draw_function is not None: self._draw_frame = draw_function - if self._scheduler is not None: - self._scheduler.request_draw() + if self.__scheduler is not None: + self.__scheduler.request_draw() # -> Note that the draw func is likely to hold a ref to the canvas. By # storing it here, the gc can detect this case, and its fine. However, @@ -232,7 +281,7 @@ def force_draw(self): """ if self.__is_drawing: raise RuntimeError("Cannot force a draw while drawing.") - self._force_draw() + self._rc_force_draw() def _draw_frame_and_present(self): """Draw the frame and present the result. @@ -251,7 +300,7 @@ def _draw_frame_and_present(self): # "draw event" that we requested, or as part of a forced draw. # Cannot draw to a closed canvas. - if self.is_closed(): + if self._rc_is_closed(): return # Process special events @@ -260,14 +309,14 @@ def _draw_frame_and_present(self): self._events.emit({"event_type": "before_draw"}) # Notify the scheduler - if self._scheduler is not None: - fps = self._scheduler.on_draw() + if self.__scheduler is not None: + fps = self.__scheduler.on_draw() # Maybe update title if fps is not None: - self.__title_kwargs["fps"] = f"{fps:0.1f}" - if "$fps" in self.__raw_title: - self.set_title(self.__raw_title) + self.__title_info["fps"] = f"{fps:0.1f}" + if "$fps" in self.__title_info["raw"]: + self.set_title(self.__title_info["raw"]) # Perform the user-defined drawing code. When this errors, # we should report the error and then continue, otherwise we crash. @@ -283,21 +332,64 @@ def _draw_frame_and_present(self): finally: self.__is_drawing = False - def _get_loop(self): - """For the subclass to implement: + # %% Primary canvas management methods + + def get_logical_size(self): + """Get the logical size (width, height) in float pixels.""" + return self._rc_get_logical_size() + + def get_pixel_ratio(self): + """Get the float ratio between logical and physical pixels.""" + return self._rc_get_pixel_ratio() + + def close(self): + """Close the canvas.""" + self._rc_close() + + def is_closed(self): + """Get whether the window is closed.""" + return self._rc_is_closed() + + # %% 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).""" + width, height = float(width), float(height) + if width < 0 or height < 0: + raise ValueError("Canvas width and height must not be negative") + self._rc_set_logical_size(width, height) + + def set_title(self, title): + """Set the window title.""" + self.__title_info["raw"] = title + for k, v in self.__title_info.items(): + title = title.replace("$" + k, v) + self._rc_set_title(title) + + # %% Methods for the subclass to implement + + def _rc_get_loop(self): + """Get the loop instance for this backend. Must return the global loop instance (a BaseLoop subclass) for the canvas subclass, or None for a canvas without scheduled draws. """ return None - def _request_draw(self): - """For the subclass to implement: + def _rc_get_present_info(self): + """Get present info. See the corresponding public method.""" + raise NotImplementedError() + + def _rc_request_draw(self): + """Request the GUI layer to perform a draw. - Request the GUI layer to perform a draw. Like requestAnimationFrame in - JS. The draw must be performed by calling _draw_frame_and_present(). - It's the responsibility for the canvas subclass to make sure that a draw - is made as soon as possible. + Like requestAnimationFrame in JS. The draw must be performed + by calling _draw_frame_and_present(). It's the responsibility + for the canvas subclass to make sure that a draw is made as + soon as possible. 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 @@ -309,60 +401,119 @@ def _request_draw(self): """ pass - def _force_draw(self): - """For the subclass to implement: + def _rc_force_draw(self): + """Perform a synchronous draw. - Perform a synchronous draw. When it returns, the draw must have been done. + When it returns, the draw must have been done. The default implementation just calls _draw_frame_and_present(). """ self._draw_frame_and_present() - # === Primary canvas management methods + def _rc_present_image(self, image, **kwargs): + """Present the given image. Only used with present_method 'image'.""" + raise NotImplementedError() - # todo: we require subclasses to implement public methods, while everywhere else the implementable-methods are private. + def _rc_get_physical_size(self): + """Get the physical size (with, height) in integer pixels.""" + raise NotImplementedError() - def get_logical_size(self): - """Get the logical size in float pixels.""" + def _rc_get_logical_size(self): + """Get the logical size (with, height) in float pixels.""" raise NotImplementedError() - def get_pixel_ratio(self): - """Get the float ratio between logical and physical pixels.""" + def _rc_get_pixel_ratio(self): + """Get ratio between physical and logical size.""" raise NotImplementedError() - def close(self): - """Close the window.""" + def _rc_set_logical_size(self, width, height): + """Set the logical size. May be ignired when it makes no sense. + + The default implementation does nothing. + """ pass - def is_closed(self): - """Get whether the window is closed.""" + def _rc_close(self): + """Close the canvas. + + For widgets that are wrapped by a WrapperRenderCanvas, this should probably + close the wrapper instead. + + Note that ``BaseRenderCanvas`` implements the ``close()`` method, which + is a rather common name; it may be necessary to re-implement that too. + """ + pass + + def _rc_is_closed(self): + """Get whether the canvas is closed.""" return False - # === Secondary canvas management methods + def _rc_set_title(self, title): + """Set the canvas title. May be ignored when it makes no sense. - # These methods provide extra control over the canvas. Subclasses should - # implement the methods they can, but these features are likely not critical. + For widgets that are wrapped by a WrapperRenderCanvas, this should probably + set the title of the wrapper instead. - def set_logical_size(self, width, height): - """Set the window size (in logical pixels).""" + The default implementation does nothing. + """ pass - 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 +class WrapperRenderCanvas(BaseRenderCanvas): + """A base render canvas for top-level windows that wrap a widget, as used in e.g. Qt and wx. + + This base class implements all the re-direction logic, so that the subclass does not have to. + Wrapper classes should not implement any of the ``_rc_`` methods. + """ + + # Events + + def add_event_handler(self, *args, **kwargs): + return self._subwidget._events.add_handler(*args, **kwargs) + + def remove_event_handler(self, *args, **kwargs): + return self._subwidget._events.remove_handler(*args, **kwargs) + + def submit_event(self, event): + return self._subwidget._events.submit(event) + + # Must implement + + def get_context(self, *args, **kwargs): + return self._subwidget.get_context(*args, **kwargs) + + # So these should not be necessary + + def get_present_info(self): + raise NotImplementedError() + def present_image(self, image, **kwargs): + raise NotImplementedError() + + # More redirection + + def request_draw(self, *args, **kwargs): + return self._subwidget.request_draw(*args, **kwargs) + + def force_draw(self): + self._subwidget.force_draw() -def pop_kwargs_for_base_canvas(kwargs_dict): - """Convenience functions for wrapper canvases like in Qt and wx.""" - code = BaseRenderCanvas.__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 + def get_physical_size(self): + return self._subwidget.get_physical_size() + + def get_logical_size(self): + return self._subwidget.get_logical_size() + + def get_pixel_ratio(self): + return self._subwidget.get_pixel_ratio() + + def set_logical_size(self, width, height): + self._subwidget.set_logical_size(width, height) + + def set_title(self, *args): + self._subwidget.set_title(*args) + + def close(self): + self._subwidget.close() + + def is_closed(self): + return self._subwidget.is_closed() diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 0b11737..1480e81 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -146,22 +146,21 @@ class GlfwRenderCanvas(BaseRenderCanvas): # See https://www.glfw.org/docs/latest/group__window.html - def __init__(self, *, size=None, title=None, **kwargs): + def __init__(self, *args, present_method=None, **kwargs): loop.init_glfw() - super().__init__(**kwargs) + super().__init__(*args, **kwargs) - # Handle inputs - if title is None: - title = "glfw canvas" - if not size: - size = 640, 480 + if present_method == "image": + logger.warning( + "Ignoreing present_method 'image'; glfw can only render to screen" + ) # Set window hints glfw.window_hint(glfw.CLIENT_API, glfw.NO_API) glfw.window_hint(glfw.RESIZABLE, True) # Create the window (the initial size may not be in logical pixels) - self._window = glfw.create_window(int(size[0]), int(size[1]), title, None, None) + self._window = glfw.create_window(640, 480, "", None, None) # Other internal variables self._changing_pixel_ratio = False @@ -197,39 +196,8 @@ def __init__(self, *, size=None, title=None, **kwargs): 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 - - def _on_pixelratio_change(self, *args): - if self._changing_pixel_ratio: - return - self._changing_pixel_ratio = True # prevent recursion (on Wayland) - try: - self._set_logical_size(self._logical_size) - finally: - self._changing_pixel_ratio = False - self.request_draw() - - def _on_size_change(self, *args): - self._determine_size() - self.request_draw() - - def _check_close(self, *args): - # Follow the close flow that glfw intended. - # This method can be overloaded and the close-flag can be set to False - # using set_window_should_close() if now is not a good time to close. - if self._window is not None and glfw.window_should_close(self._window): - self._on_close() - - def _on_close(self, *args): - 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.submit_event({"event_type": "close"}) + # Set size, title, etc. + self._final_canvas_init() def _on_window_dirty(self, *args): self.request_draw() @@ -237,9 +205,7 @@ 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 + self._rc_request_draw() def _determine_size(self): if self._window is None: @@ -262,6 +228,8 @@ def _determine_size(self): } self.submit_event(ev) + # %% Methods to implement RenderCanvas + def _set_logical_size(self, new_logical_size): if self._window is None: return @@ -298,47 +266,79 @@ def _set_logical_size(self, new_logical_size): if pixel_ratio != self._pixel_ratio: self._determine_size() - # API - - def _get_loop(self): + def _rc_get_loop(self): return loop - def _request_draw(self): + def _rc_get_present_info(self): + return get_glfw_present_info(self._window) + + def _rc_request_draw(self): if not self._is_minimized: loop.call_soon(self._draw_frame_and_present) - def _force_draw(self): + def _rc_force_draw(self): self._draw_frame_and_present() - def get_present_info(self): - return get_glfw_present_info(self._window) + def _rc_present_image(self, image, **kwargs): + raise NotImplementedError() + # AFAIK glfw does not have a builtin way to blit an image. It also does + # not really need one, since it's the most reliable backend to + # render to the screen. - def get_pixel_ratio(self): - return self._pixel_ratio + def _rc_get_physical_size(self): + return self._physical_size - def get_logical_size(self): + def _rc_get_logical_size(self): return self._logical_size - def get_physical_size(self): - return self._physical_size + def _rc_get_pixel_ratio(self): + return self._pixel_ratio - def set_logical_size(self, width, height): + def _rc_set_logical_size(self, width, height): if width < 0 or height < 0: raise ValueError("Window width and height must not be negative") self._set_logical_size((float(width), float(height))) - def _set_title(self, title): - glfw.set_window_title(self._window, title) - - def close(self): + def _rc_close(self): if self._window is not None: glfw.set_window_should_close(self._window, True) self._check_close() - def is_closed(self): + def _rc_is_closed(self): return self._window is None - # User events + def _rc_set_title(self, title): + glfw.set_window_title(self._window, title) + + # %% Turn glfw events into rendercanvas events + + def _on_pixelratio_change(self, *args): + if self._changing_pixel_ratio: + return + self._changing_pixel_ratio = True # prevent recursion (on Wayland) + try: + self._set_logical_size(self._logical_size) + finally: + self._changing_pixel_ratio = False + self.request_draw() + + def _on_size_change(self, *args): + self._determine_size() + self.request_draw() + + def _check_close(self, *args): + # Follow the close flow that glfw intended. + # This method can be overloaded and the close-flag can be set to False + # using set_window_should_close() if now is not a good time to close. + if self._window is not None and glfw.window_should_close(self._window): + self._on_close() + + def _on_close(self, *args): + 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.submit_event({"event_type": "close"}) def _on_mouse_button(self, window, but, action, mods): # Map button being changed, which we use to update self._pointer_buttons. @@ -515,12 +515,6 @@ def _on_char(self, window, char): } self.submit_event(ev) - def present_image(self, image, **kwargs): - raise NotImplementedError() - # AFAIK glfw does not have a builtin way to blit an image. It also does - # not really need one, since it's the most reliable backend to - # render to the screen. - # Make available under a name that is the same for all backends RenderCanvas = GlfwRenderCanvas diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index fe04a1d..17774de 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -17,8 +17,8 @@ class JupyterRenderCanvas(BaseRenderCanvas, RemoteFrameBuffer): """An ipywidgets widget providing a render canvas. Needs the jupyter_rfb library.""" - def __init__(self, *, size=None, title=None, **kwargs): - super().__init__(**kwargs) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) # Internal variables self._last_image = None @@ -30,21 +30,8 @@ def __init__(self, *, size=None, title=None, **kwargs): # Register so this can be display'ed when run() is called loop._pending_jupyter_canvases.append(weakref.ref(self)) - # Initialize size - if size is not None: - self.set_logical_size(*size) - - # Implementation needed for RemoteFrameBuffer - - def handle_event(self, event): - event_type = event.get("event_type") - if event_type == "close": - self._is_closed = True - elif event_type == "resize": - self._pixel_ratio = event["pixel_ratio"] - self._logical_size = event["width"], event["height"] - - self.submit_event(event) + # Set size, title, etc. + self._final_canvas_init() def get_frame(self): # The _draw_frame_and_present() does the drawing and then calls @@ -62,62 +49,74 @@ def get_frame(self): self._draw_frame_and_present() return self._last_image - # Implementation needed for BaseRenderCanvas + # %% Methods to implement RenderCanvas - def _get_loop(self): + def _rc_get_loop(self): return loop - def get_pixel_ratio(self): - return self._pixel_ratio + def _rc_get_present_info(self): + # Use a format that maps well to PNG: rgba8norm. Use srgb for + # perceptive color mapping. This is the common colorspace for + # e.g. png and jpg images. Most tools (browsers included) will + # blit the png to screen as-is, and a screen wants colors in srgb. + return { + "method": "image", + "formats": ["rgba8unorm-srgb", "rgba8unorm"], + } - def get_logical_size(self): - return self._logical_size + def _rc_request_draw(self): + self._draw_request_time = time.perf_counter() + RemoteFrameBuffer.request_draw(self) + + def _rc_force_draw(self): + # A bit hacky to use the internals of jupyter_rfb this way. + # This pushes frames to the browser as long as the websocket + # buffer permits it. It works! + # But a better way would be `await canvas.wait_draw()`. + # Todo: would also be nice if jupyter_rfb had a public api for this. + array = self.get_frame() + if array is not None: + self._rfb_send_frame(array) + + def _rc_present_image(self, image, **kwargs): + # Convert memoryview to ndarray (no copy) + self._last_image = np.frombuffer(image, np.uint8).reshape(image.shape) - def get_physical_size(self): + def _rc_get_physical_size(self): return int(self._logical_size[0] * self._pixel_ratio), int( self._logical_size[1] * self._pixel_ratio ) - def set_logical_size(self, width, height): + def _rc_get_logical_size(self): + return self._logical_size + + def _rc_get_pixel_ratio(self): + return self._pixel_ratio + + def _rc_set_logical_size(self, width, height): self.css_width = f"{width}px" self.css_height = f"{height}px" - def _set_title(self, title): - pass # not supported yet - - def close(self): + def _rc_close(self): RemoteFrameBuffer.close(self) - def is_closed(self): + def _rc_is_closed(self): return self._is_closed - def _request_draw(self): - self._draw_request_time = time.perf_counter() - RemoteFrameBuffer.request_draw(self) + def _rc_set_title(self, title): + pass # not supported yet - def _force_draw(self): - # A bit hacky to use the internals of jupyter_rfb this way. - # This pushes frames to the browser as long as the websocket - # buffer permits it. It works! - # But a better way would be `await canvas.wait_draw()`. - # Todo: would also be nice if jupyter_rfb had a public api for this. - array = self.get_frame() - if array is not None: - self._rfb_send_frame(array) + # %% Turn jupyter_rfb events into rendercanvas events - def get_present_info(self): - # Use a format that maps well to PNG: rgba8norm. Use srgb for - # perceptive color mapping. This is the common colorspace for - # e.g. png and jpg images. Most tools (browsers included) will - # blit the png to screen as-is, and a screen wants colors in srgb. - return { - "method": "image", - "formats": ["rgba8unorm-srgb", "rgba8unorm"], - } + def handle_event(self, event): + event_type = event.get("event_type") + if event_type == "close": + self._is_closed = True + elif event_type == "resize": + self._pixel_ratio = event["pixel_ratio"] + self._logical_size = event["width"], event["height"] - def present_image(self, image, **kwargs): - # Convert memoryview to ndarray (no copy) - self._last_image = np.frombuffer(image, np.uint8).reshape(image.shape) + self.submit_event(event) # Make available under a name that is the same for all backends diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 522faf7..9765f92 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -7,55 +7,61 @@ class ManualOffscreenRenderCanvas(BaseRenderCanvas): Call the ``.draw()`` method to perform a draw and get the result. """ - def __init__(self, *args, size=None, pixel_ratio=1, title=None, **kwargs): + def __init__(self, *args, pixel_ratio=1.0, **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._closed = False self._last_image = None + self._final_canvas_init() - def get_present_info(self): + # %% Methods to implement RenderCanvas + + def _rc_get_loop(self): + return None # No scheduling + + def _rc_get_present_info(self): return { "method": "image", "formats": ["rgba8unorm-srgb", "rgba8unorm"], } - def present_image(self, image, **kwargs): - self._last_image = image + def _rc_request_draw(self): + # 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 get_pixel_ratio(self): - return self._pixel_ratio + def _rc_force_draw(self): + self._draw_frame_and_present() - def get_logical_size(self): - return self._logical_size + def _rc_present_image(self, image, **kwargs): + self._last_image = image - def get_physical_size(self): + def _rc_get_physical_size(self): return int(self._logical_size[0] * self._pixel_ratio), int( self._logical_size[1] * self._pixel_ratio ) - def set_logical_size(self, width, height): - self._logical_size = width, height + def _rc_get_logical_size(self): + return self._logical_size - def _set_title(self, title): - pass + def rc_get_pixel_ratio(self): + return self._pixel_ratio - def close(self): + def _rc_set_logical_size(self, width, height): + self._logical_size = width, height + + def _rc_close(self): self._closed = True - def is_closed(self): + def _rc_is_closed(self): return self._closed - def _get_loop(self): - return None # No scheduling - - def _request_draw(self): - # Ok, cool, the scheduler want a draw. But we only draw when the user - # calls draw(), so that's how this canvas ticks. + def _rc_set_title(self, title): pass - def _force_draw(self): - self._draw_frame_and_present() + # %% events - there are no GUI events + + # %% Extra API def draw(self): """Perform a draw and get the resulting image. diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 1071a4a..807e602 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -7,7 +7,12 @@ import ctypes import importlib -from .base import BaseRenderCanvas, BaseLoop, BaseTimer, pop_kwargs_for_base_canvas +from .base import ( + WrapperRenderCanvas, + BaseRenderCanvas, + BaseLoop, + BaseTimer, +) from ._gui_utils import ( logger, SYSTEM_IS_WAYLAND, @@ -172,36 +177,8 @@ def __init__(self, *args, present_method=None, **kwargs): self.setMouseTracking(True) self.setFocusPolicy(FocusPolicy.StrongFocus) - 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: - return None - else: - return super().paintEngine() - - def paintEvent(self, event): # noqa: N802 - this is a Qt method - self._draw_frame_and_present() - - def update(self): - # Bypass Qt's mechanics and request a draw so that the scheduling mechanics work as intended. - # Eventually this will call _request_draw(). - self.request_draw() - - # Methods that we add for BaseRenderCanvas (snake_case) - - def _request_draw(self): - # Ask Qt to do a paint event - QtWidgets.QWidget.update(self) - - def _force_draw(self): - # Call the paintEvent right now. - # This works on all platforms I tested, except on MacOS when drawing with the 'image' method. - # Not sure why this is. It be made to work by calling processEvents() but that has all sorts - # of nasty side-effects (e.g. the scheduler timer keeps ticking, invoking other draws, etc.). - self.repaint() - - def _get_loop(self): - return loop + # Set size, title, etc. + self._final_canvas_init() def _get_surface_ids(self): if sys.platform.startswith("win") or sys.platform.startswith("darwin"): @@ -225,7 +202,29 @@ def _get_surface_ids(self): else: raise RuntimeError(f"Cannot get Qt surface info on {sys.platform}.") - def get_present_info(self): + # %% Qt methods + + 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: + return None + else: + return super().paintEngine() + + def paintEvent(self, event): # noqa: N802 - this is a Qt method + self._draw_frame_and_present() + + def update(self): + # Bypass Qt's mechanics and request a draw so that the scheduling mechanics work as intended. + # Eventually this will call _request_draw(). + self.request_draw() + + # %% Methods to implement RenderCanvas + + def _rc_get_loop(self): + return loop + + def _rc_get_present_info(self): global _show_image_method_warning if self._surface_ids is None: self._surface_ids = self._get_surface_ids() @@ -242,18 +241,57 @@ def get_present_info(self): } return info - def get_pixel_ratio(self): - # Observations: - # * On Win10 + PyQt5 the ratio is a whole number (175% becomes 2). - # * On Win10 + PyQt6 the ratio is correct (non-integer). - return self.devicePixelRatioF() + def _rc_request_draw(self): + # Ask Qt to do a paint event + QtWidgets.QWidget.update(self) - def get_logical_size(self): - # Sizes in Qt are logical - lsize = self.width(), self.height() - return float(lsize[0]), float(lsize[1]) + def _rc_force_draw(self): + # Call the paintEvent right now. + # This works on all platforms I tested, except on MacOS when drawing with the 'image' method. + # Not sure why this is. It be made to work by calling processEvents() but that has all sorts + # of nasty side-effects (e.g. the scheduler timer keeps ticking, invoking other draws, etc.). + self.repaint() + + def _rc_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). + # Converting to a QPixmap and painting that only makes it slower. + + # Just in case, set render hints that may hurt performance. + painter.setRenderHints( + painter.RenderHint.Antialiasing | painter.RenderHint.SmoothPixmapTransform, + False, + ) + + image = QtGui.QImage( + image_data, + size[0], + size[1], + size[0] * 4, + QtGui.QImage.Format.Format_RGBA8888, + ) + + painter.drawImage(rect2, image, rect1) + + # Uncomment for testing purposes + # painter.setPen(QtGui.QColor("#0000ff")) + # painter.setFont(QtGui.QFont("Arial", 30)) + # painter.drawText(100, 100, "This is an image") - def get_physical_size(self): + painter.end() + # backingstore.endPaint() + # backingstore.flush(rect2) + + def _rc_get_physical_size(self): # https://doc.qt.io/qt-5/qpaintdevice.html # https://doc.qt.io/qt-5/highdpi.html lsize = self.width(), self.height() @@ -268,7 +306,18 @@ def get_physical_size(self): # integer then. return round(lsize[0] * ratio + 0.01), round(lsize[1] * ratio + 0.01) - def set_logical_size(self, width, height): + def _rc_get_logical_size(self): + # Sizes in Qt are logical + lsize = self.width(), self.height() + return float(lsize[0]), float(lsize[1]) + + def _rc_get_pixel_ratio(self): + # Observations: + # * On Win10 + PyQt5 the ratio is a whole number (175% becomes 2). + # * On Win10 + PyQt6 the ratio is correct (non-integer). + return self.devicePixelRatioF() + + def _rc_set_logical_size(self, width, height): if width < 0 or height < 0: raise ValueError("Window width and height must not be negative") parent = self.parent() @@ -277,20 +326,24 @@ def set_logical_size(self, width, height): else: self.resize(width, height) # See comment on pixel ratio - def _set_title(self, title): + def _rc_close(self): + parent = self.parent() + if isinstance(parent, QRenderCanvas): + QtWidgets.QWidget.close(parent) + else: + QtWidgets.QWidget.close(self) + + def _rc_is_closed(self): + return self._is_closed + + def _rc_set_title(self, title): # A QWidgets title can actually be shown when the widget is shown in a dock. # But the application should probably determine that title, not us. parent = self.parent() if isinstance(parent, QRenderCanvas): parent.setWindowTitle(title) - def close(self): - QtWidgets.QWidget.close(self) - - def is_closed(self): - return self._is_closed - - # User events to jupyter_rfb events + # %% Turn Qt events into rendercanvas events def _key_event(self, event_type, event): modifiers = tuple( @@ -409,49 +462,8 @@ 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). - # Converting to a QPixmap and painting that only makes it slower. - - # Just in case, set render hints that may hurt performance. - painter.setRenderHints( - painter.RenderHint.Antialiasing | painter.RenderHint.SmoothPixmapTransform, - False, - ) - - image = QtGui.QImage( - image_data, - size[0], - size[1], - size[0] * 4, - QtGui.QImage.Format.Format_RGBA8888, - ) - - painter.drawImage(rect2, image, rect1) - - # Uncomment for testing purposes - # painter.setPen(QtGui.QColor("#0000ff")) - # painter.setFont(QtGui.QFont("Arial", 30)) - # painter.drawText(100, 100, "This is an image") - - painter.end() - # backingstore.endPaint() - # backingstore.flush(rect2) - -class QRenderCanvas(BaseRenderCanvas, QtWidgets.QWidget): +class QRenderCanvas(WrapperRenderCanvas, QtWidgets.QWidget): """A toplevel Qt widget providing a render canvas.""" # Most of this is proxying stuff to the inner widget. @@ -459,26 +471,18 @@ class QRenderCanvas(BaseRenderCanvas, 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, **kwargs): - # When using Qt, there needs to be an - # application before any widget is created + def __init__(self, parent=None, **kwargs): + # There needs to be an application before any widget is created. loop.init_qt() + # Any kwargs that we want to pass to *this* class, must be explicitly + # specified in the signature. The rest goes to the subwidget. + super().__init__(parent) - 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._subwidget = QRenderWidget(self, **kwargs) self.setAttribute(WA_DeleteOnClose, True) self.setMouseTracking(True) - self._subwidget = QRenderWidget(self, **sub_kwargs) - self._events = self._subwidget._events - # Note: At some point we called `self._subwidget.winId()` here. For some # reason this was needed to "activate" the canvas. Otherwise the viz was # not shown if no canvas was provided to request_adapter(). Removed @@ -489,9 +493,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() + self._final_canvas_init() # Qt methods @@ -500,56 +503,7 @@ def update(self): super().update() def closeEvent(self, event): # noqa: N802 - self._subwidget._is_closed = True - self.submit_event({"event_type": "close"}) - - # Methods that we add from BaseRenderCanvas (snake_case) - - def _request_draw(self): - self._subwidget._request_draw() - - def _force_draw(self): - self._subwidget._force_draw() - - def _get_loop(self): - return None # This means this outer widget won't have a scheduler - - def get_present_info(self): - return self._subwidget.get_present_info() - - def get_pixel_ratio(self): - return self._subwidget.get_pixel_ratio() - - def get_logical_size(self): - return self._subwidget.get_logical_size() - - def get_physical_size(self): - return self._subwidget.get_physical_size() - - def set_logical_size(self, width, height): - if width < 0 or height < 0: - raise ValueError("Window width and height must not be negative") - self.resize(width, height) # See comment on pixel ratio - - def close(self): - QtWidgets.QWidget.close(self) - - def is_closed(self): - return self._subwidget.is_closed() - - # Methods that we need to explicitly delegate to the subwidget - - def set_title(self, *args): - self._subwidget.set_title(*args) - - def get_context(self, *args, **kwargs): - return self._subwidget.get_context(*args, **kwargs) - - def request_draw(self, *args, **kwargs): - return self._subwidget.request_draw(*args, **kwargs) - - def present_image(self, image, **kwargs): - return self._subwidget.present_image(image, **kwargs) + self._subwidget.closeEvent(event) # Make available under a name that is the same for all gui backends diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 8fa5787..5c0332b 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -16,7 +16,12 @@ get_alt_x11_display, get_alt_wayland_display, ) -from .base import BaseRenderCanvas, BaseLoop, BaseTimer, pop_kwargs_for_base_canvas +from .base import ( + WrapperRenderCanvas, + BaseRenderCanvas, + BaseLoop, + BaseTimer, +) BUTTON_MAP = { @@ -158,9 +163,11 @@ def __init__(self, *args, present_method=None, **kwargs): self.Bind(wx.EVT_MOTION, self._on_mouse_move) self.Show() + self._final_canvas_init() - def _get_loop(self): - return loop + def _on_resize_done(self, *args): + self._draw_lock = False + self.Refresh() def on_paint(self, event): dc = wx.PaintDC(self) # needed for wx @@ -169,6 +176,117 @@ def on_paint(self, event): del dc event.Skip() + def _get_surface_ids(self): + if sys.platform.startswith("win") or sys.platform.startswith("darwin"): + return { + "window": int(self.GetHandle()), + } + elif sys.platform.startswith("linux"): + if False: + # We fall back to XWayland, see _gui_utils.py + return { + "platform": "wayland", + "window": int(self.GetHandle()), + "display": int(get_alt_wayland_display()), + } + else: + return { + "platform": "x11", + "window": int(self.GetHandle()), + "display": int(get_alt_x11_display()), + } + else: + raise RuntimeError(f"Cannot get wx surface info on {sys.platform}.") + + # %% Methods to implement RenderCanvas + + def _rc_get_loop(self): + return loop + + def _rc_get_present_info(self): + if self._surface_ids is None: + # On wx it can take a little while for the handle to be available, + # causing GetHandle() to be initially 0, so getting a surface will fail. + etime = time.perf_counter() + 1 + while self.GetHandle() == 0 and time.perf_counter() < etime: + loop.process_wx_events() + self._surface_ids = self._get_surface_ids() + global _show_image_method_warning + if self._present_to_screen and self._surface_ids: + info = {"method": "screen"} + info.update(self._surface_ids) + else: + if _show_image_method_warning: + logger.warn(_show_image_method_warning) + _show_image_method_warning = None + info = { + "method": "image", + "formats": ["rgba8unorm-srgb", "rgba8unorm"], + } + return info + + def _rc_request_draw(self): + if self._draw_lock: + return + try: + self.Refresh() + except Exception: + pass # avoid errors when window no longer lives + + def _rc_force_draw(self): + self.Refresh() + self.Update() + + def _rc_present_image(self, image_data, **kwargs): + size = image_data.shape[1], image_data.shape[0] # width, height + + dc = wx.PaintDC(self) + bitmap = wx.Bitmap.FromBufferRGBA(*size, image_data) + dc.DrawBitmap(bitmap, 0, 0, False) + + def _rc_get_physical_size(self): + lsize = self.Size[0], self.Size[1] + lsize = float(lsize[0]), float(lsize[1]) + ratio = self.GetContentScaleFactor() + return round(lsize[0] * ratio + 0.01), round(lsize[1] * ratio + 0.01) + + def _rc_get_logical_size(self): + lsize = self.Size[0], self.Size[1] + return float(lsize[0]), float(lsize[1]) + + def _rc_get_pixel_ratio(self): + # todo: this is not hidpi-ready (at least on win10) + # Observations: + # * On Win10 this always returns 1 - so hidpi is effectively broken + return self.GetContentScaleFactor() + + def _rc_set_logical_size(self, width, height): + width, height = int(width), int(height) + parent = self.Parent + if isinstance(parent, WxRenderCanvas): + parent.SetSize(width, height) + else: + self.SetSize(width, height) + + def _rc_close(self): + self._is_closed = True + parent = self.Parent + if isinstance(parent, WxRenderCanvas): + parent.Hide() + else: + self.Hide() + + def _rc_is_closed(self): + return self._is_closed + + def _rc_set_title(self, title): + # Set title only on frame + parent = self.Parent + if isinstance(parent, WxRenderCanvas): + parent.SetTitle(title) + + # %% Turn Qt events into rendercanvas events + def _on_resize(self, event: wx.SizeEvent): self._draw_lock = True self._resize_timer.Start(100, wx.TIMER_ONE_SHOT) @@ -183,12 +301,6 @@ def _on_resize(self, event: wx.SizeEvent): } self.submit_event(ev) - def _on_resize_done(self, *args): - self._draw_lock = False - self.Refresh() - - # Methods for input events - def _on_key_down(self, event: wx.KeyEvent): char_str = self._get_char_from_event(event) self._key_event("key_down", event, char_str) @@ -310,150 +422,25 @@ def _on_mouse_events(self, event: wx.MouseEvent): def _on_mouse_move(self, event: wx.MouseEvent): self._mouse_event("pointer_move", event) - # Methods that we add from BaseRenderCanvas - def _get_surface_ids(self): - if sys.platform.startswith("win") or sys.platform.startswith("darwin"): - return { - "window": int(self.GetHandle()), - } - elif sys.platform.startswith("linux"): - if False: - # We fall back to XWayland, see _gui_utils.py - return { - "platform": "wayland", - "window": int(self.GetHandle()), - "display": int(get_alt_wayland_display()), - } - else: - return { - "platform": "x11", - "window": int(self.GetHandle()), - "display": int(get_alt_x11_display()), - } - else: - 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"} - info.update(self._surface_ids) - else: - if _show_image_method_warning: - logger.warn(_show_image_method_warning) - _show_image_method_warning = None - info = { - "method": "image", - "formats": ["rgba8unorm-srgb", "rgba8unorm"], - } - return info - - def get_pixel_ratio(self): - # todo: this is not hidpi-ready (at least on win10) - # Observations: - # * On Win10 this always returns 1 - so hidpi is effectively broken - return self.GetContentScaleFactor() - - def get_logical_size(self): - lsize = self.Size[0], self.Size[1] - return float(lsize[0]), float(lsize[1]) - - def get_physical_size(self): - lsize = self.Size[0], self.Size[1] - lsize = float(lsize[0]), float(lsize[1]) - ratio = self.GetContentScaleFactor() - return round(lsize[0] * ratio + 0.01), round(lsize[1] * ratio + 0.01) - - def set_logical_size(self, width, height): - if width < 0 or height < 0: - raise ValueError("Window width and height must not be negative") - parent = self.Parent - if isinstance(parent, WxRenderCanvas): - parent.SetSize(width, height) - else: - self.SetSize(width, height) - - def _set_title(self, title): - # Set title only on frame - parent = self.Parent - if isinstance(parent, WxRenderCanvas): - parent.SetTitle(title) - - def _request_draw(self): - 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 self._is_closed - - @staticmethod - def _call_later(delay, callback, *args): - delay_ms = int(delay * 1000) - if delay_ms <= 0: - callback(*args) - - wx.CallLater(max(delay_ms, 1), callback, *args) - - def present_image(self, image_data, **kwargs): - size = image_data.shape[1], image_data.shape[0] # width, height - - dc = wx.PaintDC(self) - bitmap = wx.Bitmap.FromBufferRGBA(*size, image_data) - dc.DrawBitmap(bitmap, 0, 0, False) - - -class WxRenderCanvas(BaseRenderCanvas, wx.Frame): +class WxRenderCanvas(WrapperRenderCanvas, wx.Frame): """A toplevel wx Frame providing a render canvas.""" # Most of this is proxying stuff to the inner widget. - def __init__( - self, - *, - parent=None, - size=None, - title=None, - **kwargs, - ): + def __init__(self, parent=None, **kwargs): + # There needs to be an application before any widget is created. loop.init_wx() - sub_kwargs = pop_kwargs_for_base_canvas(kwargs) - super().__init__(parent, **kwargs) + # Any kwargs that we want to pass to *this* class, must be explicitly + # specified in the signature. The rest goes to the subwidget. + super().__init__(parent) - # Handle inputs - if title is None: - title = "wx canvas" - if not size: - size = 640, 480 + self._subwidget = WxRenderWidget(parent=self, **kwargs) - self._subwidget = WxRenderWidget(parent=self, **sub_kwargs) - self._events = self._subwidget._events self.Bind(wx.EVT_CLOSE, lambda e: self.Destroy()) self.Show() - - # Force the canvas to be shown, so that it gets a valid handle. - # Otherwise GetHandle() is initially 0, and getting a surface will fail. - etime = time.perf_counter() + 1 - while self._subwidget.GetHandle() == 0 and time.perf_counter() < etime: - loop.process_wx_events() - - self.set_logical_size(*size) - self.set_title(title) + self._final_canvas_init() # wx methods @@ -461,54 +448,6 @@ def Destroy(self): # noqa: N802 - this is a wx method self._subwidget._is_closed = True super().Destroy() - # Methods that we add from wgpu - def _get_loop(self): - return None # wrapper widget does not have scheduling - - def get_present_info(self): - return self._subwidget.get_present_info() - - def get_pixel_ratio(self): - return self._subwidget.get_pixel_ratio() - - def get_logical_size(self): - return self._subwidget.get_logical_size() - - def get_physical_size(self): - return self._subwidget.get_physical_size() - - 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) - - def set_title(self, title): - self._subwiget.set_title(title) - - def _request_draw(self): - return self._subwidget._request_draw() - - def _force_draw(self): - return self._subwidget._force_draw() - - def close(self): - self._subwidget._is_closed = True - super().Close() - - def is_closed(self): - return self._subwidget._is_closed - - # Methods that we need to explicitly delegate to the subwidget - - def get_context(self, *args, **kwargs): - return self._subwidget.get_context(*args, **kwargs) - - def request_draw(self, *args, **kwargs): - return self._subwidget.request_draw(*args, **kwargs) - - def present_image(self, image, **kwargs): - return self._subwidget.present_image(image, **kwargs) - # Make available under a name that is the same for all gui backends RenderWidget = WxRenderWidget diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index 3db0d0f..7f5f605 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -46,9 +46,15 @@ def __init__(self, *args, **kwargs): self.draw_count = 0 self.events_count = 0 - def _get_loop(self): + def _rc_get_loop(self): return self._loop + def _rc_close(self): + self._closed = True + + def _rc_is_closed(self): + return self._closed + def _process_events(self): super()._process_events() self.events_count += 1 @@ -57,7 +63,7 @@ def _draw_frame_and_present(self): super()._draw_frame_and_present() self.draw_count += 1 - def _request_draw(self): + def _rc_request_draw(self): self._gui_draw_requested = True def draw_if_necessary(self): @@ -65,14 +71,8 @@ def draw_if_necessary(self): 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() + loop = self._rc_get_loop() etime = time.perf_counter() + delay while time.perf_counter() < etime: time.sleep(0.001)