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/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..961109b 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. @@ -24,36 +37,67 @@ class BaseRenderCanvas: """ - def __init__( - self, - *args, + # + __canvas_kwargs = dict( + size=(640, 480), + title="$backend", update_mode="ondemand", min_fps=1.0, max_fps=30.0, vsync=True, present_method=None, - **kwargs, - ): + ) + + def __init__(self, *args, **kwargs): + # Extract canvas kwargs + canvas_kwargs = {} + for key, default in BaseRenderCanvas.__canvas_kwargs.items(): + val = kwargs.pop(key, default) + if val is None: + val = default + canvas_kwargs[key] = val + + # Initialize superclass. Note that super() can be e.g. a QWidget, RemoteFrameBuffer, or object. super().__init__(*args, **kwargs) - 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 = { + # If this is a wrapper, it should pass the canvas kwargs to the subwidget. + if isinstance(self, WrapperRenderCanvas): + self._rc_init(**canvas_kwargs) + self.__events = self._subwidget.__events + return + + # The vsync is not-so-elegantly strored on the canvas, and picked up by wgou's canvas contex. + self._vsync = bool(canvas_kwargs["vsync"]) + + # Variables and flags used internally + self.__is_drawing = False + self.__title_info = { + "raw": "", "fps": "?", "backend": self.__class__.__name__, } - self.__is_drawing = False - self._events = EventEmitter() - self._scheduler = None - loop = self._get_loop() - if loop: - self._scheduler = Scheduler( - self, loop, min_fps=min_fps, max_fps=max_fps, mode=update_mode + # Events and scheduler + self.__events = EventEmitter() + 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=canvas_kwargs["min_fps"], + max_fps=canvas_kwargs["max_fps"], + mode=canvas_kwargs["update_mode"], ) + # Initialze the canvas subclass + self._rc_init(**canvas_kwargs) + + # Finalize the initialization + self.set_logical_size(*canvas_kwargs["size"]) + self.set_title(canvas_kwargs["title"]) + def __del__(self): # On delete, we call the custom close method. try: @@ -67,7 +111,7 @@ def __del__(self): except Exception: pass - # === Implement WgpuCanvasInterface + # %% Implement WgpuCanvasInterface _canvas_context = None # set in get_context() @@ -97,11 +141,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,26 +179,28 @@ 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) + return self.__events.add_handler(*args, **kwargs) def remove_event_handler(self, *args, **kwargs): - return self._events.remove_handler(*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) + 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 and drawing + def _process_events(self): """Process events and animations. Called from the scheduler.""" @@ -163,13 +209,13 @@ 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() # Flush our events, so downstream code can update stuff. # Maybe that downstream code request a new draw. - self._events.flush() + self.__events.flush() # TODO: implement later (this is a start but is not tested) # Schedule animation events until the lag is gone @@ -178,21 +224,19 @@ def _process_events(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.__events.submit({"event_type": "animate", "step": step, "catch_up": 0}) # # Do the animations. This costs time. - # self._events.flush() + # 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( + # 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. @@ -216,8 +260,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 +276,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,23 +295,23 @@ 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 # Note that we must not process normal events here, since these can do stuff # with the canvas (resize/close/etc) and most GUI systems don't like that. - self._events.emit({"event_type": "before_draw"}) + 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 +327,69 @@ 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).""" + self._rc_set_logical_size(float(width), float(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_init(self, *, present_method): + """Method to initialize the canvas. + + This method is called near the end of the initialization + process, but before setting things like size and title. + """ + pass + + 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,102 @@ 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.""" - return False + def _rc_close(self): + """Close the canvas. - # === Secondary canvas management methods + Note that ``BaseRenderCanvas`` implements the ``close()`` method, which + is a rather common name; it may be necessary to re-implement that too. + """ + raise NotImplementedError() - # These methods provide extra control over the canvas. Subclasses should - # implement the methods they can, but these features are likely not critical. + def _rc_is_closed(self): + """Get whether the canvas is closed.""" + raise NotImplementedError() - def set_logical_size(self, width, height): - """Set the window size (in logical pixels).""" + def _rc_set_title(self): + """Set the canvas title. May be ignored when it makes no sense. + + 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. + """ + + # Must implement + def get_context(self, *args, **kwargs): + return self._subwidget.get_context(*args, **kwargs) -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 + # 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 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..ac9ace1 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -146,22 +146,20 @@ class GlfwRenderCanvas(BaseRenderCanvas): # See https://www.glfw.org/docs/latest/group__window.html - def __init__(self, *, size=None, title=None, **kwargs): + def _rc_init(self, *, present_method, **_): loop.init_glfw() - super().__init__(**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,49 +195,13 @@ 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"}) - def _on_window_dirty(self, *args): self.request_draw() 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 +224,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 +262,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 +511,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..f875b26 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -17,9 +17,7 @@ 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 _rc_init(self, **_): # Internal variables self._last_image = None self._pixel_ratio = 1 @@ -30,22 +28,6 @@ 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) - def get_frame(self): # The _draw_frame_and_present() does the drawing and then calls # present_context.present(), which calls our present() method. @@ -62,62 +44,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..7ac0943 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -7,55 +7,62 @@ 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 + + def _rc_init(self, **_): self._closed = False self._last_image = None - 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 _rc_set_logical_size(self, width, height): + self._logical_size = width, height - def close(self): + 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..feee9ed 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, @@ -142,9 +147,7 @@ def enable_hidpi(): class QRenderWidget(BaseRenderCanvas, QtWidgets.QWidget): """A QWidget representing a render canvas that can be embedded in a Qt application.""" - def __init__(self, *args, present_method=None, **kwargs): - super().__init__(*args, **kwargs) - + def _rc_init(self, *, present_method, **_): # Determine present method self._surface_ids = None if not present_method: @@ -172,37 +175,6 @@ 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 - def _get_surface_ids(self): if sys.platform.startswith("win") or sys.platform.startswith("darwin"): return { @@ -225,7 +197,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 +236,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 get_physical_size(self): + 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") + + 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 +301,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 +321,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 +457,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 +466,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): + def __init__(self, *args, **kwargs): # When using Qt, there needs to be an # application before any widget is created loop.init_qt() + super().__init__(*args, **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 + def _rc_init(self, **canvas_kwargs): + self._subwidget = QRenderWidget(self, **canvas_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 @@ -488,9 +487,6 @@ def __init__(self, *, size=None, title=None, **kwargs): layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) layout.addWidget(self._subwidget) - - self.set_logical_size(*size) - self.set_title(title) self.show() # Qt methods @@ -500,56 +496,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..eba0361 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 = { @@ -123,9 +128,7 @@ def enable_hidpi(): class WxRenderWidget(BaseRenderCanvas, wx.Window): """A wx Window representing a render canvas that can be embedded in a wx application.""" - def __init__(self, *args, present_method=None, **kwargs): - super().__init__(*args, **kwargs) - + def _rc_init(self, present_method=None, **_): # Determine present method self._surface_ids = None if not present_method: @@ -157,10 +160,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) - self.Show() - - 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 +171,109 @@ 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: + 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): + 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 _rc_close(self): + self._is_closed = True + 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 +288,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,205 +409,34 @@ 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__(*args, **kwargs): loop.init_wx() - sub_kwargs = pop_kwargs_for_base_canvas(kwargs) - super().__init__(parent, **kwargs) + super().__init__(*args, **kwargs) - # Handle inputs - if title is None: - title = "wx canvas" - if not size: - size = 640, 480 + def _rc_init(self, **canvas_kwargs): + self._subwidget = WxRenderWidget(parent=self, **canvas_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. + self.Show() etime = time.perf_counter() + 1 while self._subwidget.GetHandle() == 0 and time.perf_counter() < etime: loop.process_wx_events() - self.set_logical_size(*size) - self.set_title(title) - # wx methods def 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