From a67d6b6fdb05be2471758317b80698f14f7f0b2b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 14 Jan 2026 22:33:56 +0100 Subject: [PATCH 1/3] Refactor how the render_method is selected --- examples/wx_app.py | 2 +- rendercanvas/base.py | 103 +++++++++++++++++++++----------------- rendercanvas/glfw.py | 52 +++++++++---------- rendercanvas/jupyter.py | 14 +++--- rendercanvas/offscreen.py | 10 ++-- rendercanvas/pyodide.py | 22 ++++---- rendercanvas/qt.py | 85 ++++++++++++++++--------------- rendercanvas/stub.py | 4 +- rendercanvas/wx.py | 75 ++++++++++++++------------- tests/test_base.py | 7 ++- 10 files changed, 194 insertions(+), 180 deletions(-) diff --git a/examples/wx_app.py b/examples/wx_app.py index 5111c120..cc0a3208 100644 --- a/examples/wx_app.py +++ b/examples/wx_app.py @@ -18,7 +18,7 @@ def __init__(self): super().__init__(None, title="wgpu triangle embedded in a wx app") self.SetSize(640, 480) - # Using present_method 'image' because it reports "The surface texture is suboptimal" + # Using present_method 'bitmap' because it reports "The surface texture is suboptimal" self.canvas = RenderWidget( self, update_mode="continuous", present_method="bitmap" ) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index bb04f000..8f8a3bfa 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -108,7 +108,7 @@ class BaseRenderCanvas: against screen tearing, but limits the fps. Default True. present_method (str | None): Override the method to present the rendered result. Can be set to 'screen' or 'bitmap'. Default None, which means that the method is selected - based on what the canvas supports and what the context prefers. + based on what the canvas and context support and prefer. """ @@ -151,6 +151,13 @@ def __init__( # The vsync is not-so-elegantly strored on the canvas, and picked up by wgou's canvas contex. self._vsync = bool(vsync) + # Handle custom present method + if not (present_method is None or isinstance(present_method, str)): + raise TypeError( + f"The canvas present_method should be None or str, not {present_method!r}." + ) + self._present_method = present_method + # Variables and flags used internally self.__is_drawing = False self.__title_info = { @@ -294,36 +301,34 @@ def get_context(self, context_type: str | type) -> contexts.BaseContext: f"Cannot get context for '{context_name}': a context of type '{ref_context_name}' is already set." ) - # Get available present methods. - # Take care not to hold onto this dict, it may contain objects that we don't want to unnecessarily reference. - present_methods = self._rc_get_present_methods() - invalid_methods = set(present_methods.keys()) - {"screen", "bitmap"} - if invalid_methods: - logger.warning( - f"{self.__class__.__name__} reports unknown present methods {invalid_methods!r}" - ) + # Get available present methods that the canvas can chose from. + present_methods = list(context_class.present_methods) + assert all(m in ("bitmap", "screen") for m in present_methods) # sanity check + if self._present_method is not None: + if self._present_method not in present_methods: + raise RuntimeError( + f"Explicitly requested present_method {present_method!r} is not available for {context_name}." + ) + present_methods = [self._present_method] - # Select present_method - for present_method in context_class.present_methods: - assert present_method in ("bitmap", "screen") - if present_method in present_methods: - break - else: + # Let the canvas select the method and provide the corresponding info object. + # Take care not to hold onto this dict, it may contain objects that we don't want to unnecessarily reference. + info = self._rc_get_present_info(present_methods) + if info is None: + method_message = f"Methods {set(present_methods)!r} are not supported." + if len(present_methods) == 1: + method_message = f"Method {present_methods[0]!r} is not supported." raise TypeError( - f"Could not select present_method for context {context_name!r}: The methods {tuple(context_class.present_methods)!r} are not supported by the canvas backend {tuple(present_methods.keys())!r}." + f"Could not create {context_name!r} for {self.__class__.__name__!r}: {method_message}" ) + if info.get("method") not in present_methods: + raise RuntimeError( + f"Present info dict field 'method' must be present in available present_methods {set(present_methods)}." + ) + self._present_method = info["method"] - # Select present_info, and shape it into what the contexts need. - present_info = present_methods[present_method] - assert "method" not in present_info, ( - "the field 'method' is reserved in present_methods dicts" - ) - present_info = { - "method": present_method, - "source": self.__class__.__name__, - **present_info, - "vsync": self._vsync, - } + # Add some info + present_info = {**info, "source": self.__class__.__name__, "vsync": self._vsync} # Create the context self._canvas_context = context_class(present_info) @@ -641,32 +646,38 @@ def _rc_gui_poll(self): """Process native events.""" pass - def _rc_get_present_methods(self): - """Get info on the present methods supported by this canvas. + def _rc_get_present_info(self, present_methods: list[str]) -> dict | None: + """Select a present method and return corresponding info dict. + + This method is only called once, when the context is created. The + subclass can use this moment to setup the internal state for the + selected presentation method. - Must return a small dict, used by the canvas-context to determine - how the rendered result will be presented to the canvas. - This method is only called once, when the context is created. + The ``present_methods`` represents the supported methods of the + canvas-context, possibly filtered by a user-specified method. A canvas + backend must implement at least the "screen" or "bitmap" method. - Each supported method is represented by a field in the dict. The value - is another dict with information specific to that present method. - A canvas backend must implement at least either "screen" or "bitmap". + The returned dict must contain at least the key 'method', which must + match one of the ``present_methods``. The remaining values represent + information required by the canvas-context to perform the presentation, + and optionally some (debug) meta data. The backend may optionally return + None to indicate that none of the ``present_methods`` is supported. - With method "screen", the context will render directly to a surface - representing the region on the screen. The sub-dict should have a ``window`` - field containing the window id. On Linux there should also be ``platform`` - field to distinguish between "wayland" and "x11", and a ``display`` field - for the display id. This information is used by wgpu to obtain the required - surface id. For Pyodide the required info is different. + With method "screen", the context will render directly to a (virtual) + surface. The dict should have a ``window`` field containing the window + id. On Linux there should also be ``platform`` field to distinguish + between "wayland" and "x11", and a ``display`` field for the display id. + This information is used by wgpu to obtain the required surface id. For + Pyodide the 'window' field should be the ```` object. With method "bitmap", the context will present the result as an image - bitmap. For the `WgpuContext`, the result will first be rendered to texture, - and then downloaded to RAM. The sub-dict must have a - field 'formats': a list of supported image formats. Examples are "rgba-u8" - and "i-u8". A canvas must support at least "rgba-u8". Note that srgb mapping + bitmap. For the ``WgpuContext``, the result will first be rendered to a + texture, and then downloaded to RAM. The dict must have a field + 'formats': a list of supported image formats. Examples are "rgba-u8" and + "i-u8". A canvas must support at least "rgba-u8". Note that srgb mapping is assumed to be handled by the canvas. """ - raise NotImplementedError() + return None def _rc_request_draw(self): """Request the GUI layer to perform a draw. diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 01537875..642641bf 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -119,38 +119,38 @@ } -def get_glfw_present_methods(window): +def get_glfw_present_info(window): if sys.platform.startswith("win"): return { - "screen": { - "platform": "windows", - "window": int(glfw.get_win32_window(window)), - } + "method": "screen", + "platform": "windows", + "window": int(glfw.get_win32_window(window)), } + elif sys.platform.startswith("darwin"): return { - "screen": { - "platform": "cocoa", - "window": int(glfw.get_cocoa_window(window)), - } + "method": "screen", + "platform": "cocoa", + "window": int(glfw.get_cocoa_window(window)), } + elif sys.platform.startswith("linux"): if api_is_wayland: return { - "screen": { - "platform": "wayland", - "window": int(glfw.get_wayland_window(window)), - "display": int(glfw.get_wayland_display()), - } + "method": "screen", + "platform": "wayland", + "window": int(glfw.get_wayland_window(window)), + "display": int(glfw.get_wayland_display()), } + else: return { - "screen": { - "platform": "x11", - "window": int(glfw.get_x11_window(window)), - "display": int(glfw.get_x11_display()), - } + "method": "screen", + "platform": "x11", + "window": int(glfw.get_x11_window(window)), + "display": int(glfw.get_x11_display()), } + else: raise RuntimeError(f"Cannot get GLFW surface info on {sys.platform}.") @@ -192,15 +192,10 @@ class GlfwRenderCanvas(BaseRenderCanvas): _rc_canvas_group = GlfwCanvasGroup(loop) - def __init__(self, *args, present_method=None, **kwargs): + def __init__(self, *args, **kwargs): enable_glfw() super().__init__(*args, **kwargs) - if present_method == "bitmap": - logger.warning( - "Ignoring present_method 'bitmap'; glfw can only render to screen" - ) - # Set window hints glfw.window_hint(glfw.CLIENT_API, glfw.NO_API) glfw.window_hint(glfw.RESIZABLE, True) @@ -318,8 +313,11 @@ def _rc_gui_poll(self): self._is_in_poll_events = False self._maybe_close() - def _rc_get_present_methods(self): - return get_glfw_present_methods(self._window) + def _rc_get_present_info(self, present_methods): + if "screen" in present_methods: + return get_glfw_present_info(self._window) + else: + return None # raises error def _rc_request_draw(self): if not self._is_minimized: diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index 44eda921..0d8357be 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -49,17 +49,19 @@ def get_frame(self): def _rc_gui_poll(self): pass - def _rc_get_present_methods(self): - # We stick to the two common formats, because these can be easily converted to png - # We assyme that srgb is used for perceptive color mapping. This is the + def _rc_get_present_info(self, present_methods): + # We stick to the a format, because these can be easily converted to png. + # We assume that srgb is used 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 { - "bitmap": { + if "bitmap" in present_methods: + return { + "method": "bitmap", "formats": ["rgba-u8"], } - } + else: + return None # raises error def _rc_request_draw(self): self._draw_request_time = time.perf_counter() diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 77748b51..c59bccff 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -52,12 +52,14 @@ def __init__(self, *args, pixel_ratio=1.0, format="rgba-u8", **kwargs): def _rc_gui_poll(self): pass - def _rc_get_present_methods(self): - return { - "bitmap": { + def _rc_get_present_info(self, present_methods): + if "bitmap" in present_methods: + return { + "method": "bitmap", "formats": self._present_formats, } - } + else: + return None # raises error def _rc_request_draw(self): # Ok, cool, the scheduler want a draw. But we only draw when the user diff --git a/rendercanvas/pyodide.py b/rendercanvas/pyodide.py index 7c2723b8..39ae978a 100644 --- a/rendercanvas/pyodide.py +++ b/rendercanvas/pyodide.py @@ -427,18 +427,22 @@ def unregister_events(): def _rc_gui_poll(self): pass # Nothing to be done; the JS loop is always running (and Pyodide wraps that in a global asyncio loop) - def _rc_get_present_methods(self): - return { - # Generic presentation - "bitmap": { - "formats": ["rgba-u8"], - }, + def _rc_get_present_info(self, present_methods): + if "screen" in present_methods: # wgpu-specific presentation. The wgpu.backends.pyodide.GPUCanvasContext must be able to consume this. - "screen": { + return { + "method": "screen", "platform": "browser", "window": self._canvas_element, # Just provide the canvas object - }, - } + } + elif "bitmap" in present_methods: + # Generic presentation + return { + "method": "bitmap", + "formats": ["rgba-u8"], + } + else: + return None # raises error def _rc_request_draw(self): window.requestAnimationFrame( diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 5d92ab6b..2a42be9e 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -186,10 +186,6 @@ def enable_hidpi(): # needed, so not our responsibility (some users may NOT want it set). enable_hidpi() -_show_image_method_warning = ( - "Qt falling back to offscreen rendering, which is less performant." -) - class CallerHelper(QtCore.QObject): """Little helper for _rc_call_soon_threadsafe""" @@ -279,31 +275,11 @@ class QRenderWidget(BaseRenderCanvas, QtWidgets.QWidget): _rc_canvas_group = QtCanvasGroup(loop) - def __init__(self, *args, present_method=None, **kwargs): + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Determine present method self._last_winid = None - self._surface_ids = None - if not present_method: - self._present_to_screen = True - if SYSTEM_IS_WAYLAND: - # Trying to render to screen on Wayland segfaults. This might be because - # the "display" is not the real display id. We can tell Qt to use - # XWayland, so we can use the X11 path. This worked at some point, - # but later this resulted in a Rust panic. So, until this is sorted - # out, we fall back to rendering via an image. - self._present_to_screen = False - elif present_method == "screen": - self._present_to_screen = True - elif present_method == "bitmap": - global _show_image_method_warning - - _show_image_method_warning = None - self._present_to_screen = False - else: - raise ValueError(f"Invalid present_method {present_method}") - + self._present_to_screen = None self._is_closed = False self.setAutoFillBackground(False) @@ -375,25 +351,48 @@ def _rc_gui_poll(self): loop._app.sendPostedEvents() loop._app.processEvents() - def _rc_get_present_methods(self): - global _show_image_method_warning - - if self._present_to_screen and self._surface_ids is None: - self._surface_ids = self._get_surface_ids() - if self._surface_ids is None: + def _rc_get_present_info(self, present_methods): + # Select what method the canvas prefers + preferred_method = "screen" + if SYSTEM_IS_WAYLAND: + # Trying to render to screen on Wayland segfaults. This might be because + # the "display" is not the real display id. We can tell Qt to use + # XWayland, so we can use the X11 path. This worked at some point, + # but later this resulted in a Rust panic. So, until this is sorted + # out, we fall back to rendering via an image. + preferred_method = "bitmap" + + # Select method + the_method = None + if preferred_method in present_methods: + the_method = preferred_method + elif "screen" in present_methods: + the_method = "screen" + elif "bitmap" in present_methods: + the_method = "bitmap" + + # Apply + if the_method == "screen": + surface_ids = self._get_surface_ids() + if surface_ids: + self._present_to_screen = True + return {"method": "screen", **surface_ids} + elif "bitmap" in present_methods: self._present_to_screen = False - - methods = {} - if self._present_to_screen: - methods["screen"] = self._surface_ids - # Now is a good time to set WA_PaintOnScreen. Note that it implies WA_NativeWindow. - self.setAttribute(WA_PaintOnScreen, self._present_to_screen) + return { + "method": "bitmap", + "formats": list(BITMAP_FORMAT_MAP.keys()), + } + else: + return None + elif the_method == "bitmap": + self._present_to_screen = False + return { + "method": "bitmap", + "formats": list(BITMAP_FORMAT_MAP.keys()), + } else: - if _show_image_method_warning: - logger.warning(_show_image_method_warning) - _show_image_method_warning = None - methods["bitmap"] = {"formats": list(BITMAP_FORMAT_MAP.keys())} - return methods + return None # raises error def _rc_request_draw(self): # Ask Qt to do a paint event diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index 3ccf678f..302c9586 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -88,8 +88,8 @@ class StubRenderCanvas(BaseRenderCanvas): def _rc_gui_poll(self): raise NotImplementedError() - def _rc_get_present_methods(self): - raise NotImplementedError() + def _rc_get_present_info(self, present_methods): + return None def _rc_request_draw(self): pass diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 0609c918..f4ee0d7c 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -135,11 +135,6 @@ def enable_hidpi(): enable_hidpi() -_show_image_method_warning = ( - "wx falling back to offscreen rendering, which is less performant." -) - - class TimerWithCallback(wx.Timer): def __init__(self, callback): super().__init__() @@ -213,26 +208,10 @@ class WxRenderWidget(BaseRenderCanvas, wx.Window): _rc_canvas_group = WxCanvasGroup(loop) - def __init__(self, *args, present_method=None, **kwargs): + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Determine present method - self._surface_ids = None - if not present_method: - self._present_to_screen = True - if SYSTEM_IS_WAYLAND: - # See comments in same place in qt.py - self._present_to_screen = False - elif present_method == "screen": - self._present_to_screen = True - elif present_method == "bitmap": - global _show_image_method_warning - - _show_image_method_warning = None - self._present_to_screen = False - else: - raise ValueError(f"Invalid present_method {present_method}") - + self._present_to_screen = None self._is_closed = False self._pointer_inside = None self._is_pointer_inside_according_to_wx = False @@ -302,28 +281,48 @@ def _rc_gui_poll(self): else: loop.process_wx_events() - def _rc_get_present_methods(self): - global _show_image_method_warning - - if self._present_to_screen and self._surface_ids is None: + def _rc_get_present_info(self, present_methods): + # Select what method the canvas prefers + preferred_method = "screen" + if SYSTEM_IS_WAYLAND: + preferred_method = "bitmap" # also see qt.py + + # Select method + the_method = None + if preferred_method in present_methods: + the_method = preferred_method + elif "screen" in present_methods: + the_method = "screen" + elif "bitmap" in present_methods: + the_method = "bitmap" + + # Apply + if the_method == "screen": # 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() - if self._surface_ids is None: + surface_ids = self._get_surface_ids() + if surface_ids: + self._present_to_screen = True + return {"method": "screen", **surface_ids} + elif "bitmap" in present_methods: self._present_to_screen = False - - methods = {} - if self._present_to_screen: - methods["screen"] = self._surface_ids + return { + "method": "bitmap", + "formats": ["rgba-u8"], + } + else: + return None + elif the_method == "bitmap": + self._present_to_screen = False + return { + "method": "bitmap", + "formats": ["rgba-u8"], + } else: - if _show_image_method_warning: - logger.warning(_show_image_method_warning) - _show_image_method_warning = None - methods["bitmap"] = {"formats": ["rgba-u8"]} - return methods + return None # raises error def _rc_request_draw(self): if self._draw_lock: diff --git a/tests/test_base.py b/tests/test_base.py index 74da38aa..46d58a07 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -78,11 +78,10 @@ def __init__(self): self.frame_count = 0 self._size_info.set_physical_size(100, 100, 1) - def _rc_get_present_methods(self): + def _rc_get_present_info(self, present_methods): return { - "bitmap": { - "formats": ["rgba-u8"], - } + "method": "bitmap", + "formats": ["rgba-u8"], } def _rc_present_bitmap(self, *, data, format, **kwargs): From 02543c850663b764dbb93d4c513677b20ff14e48 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 15 Jan 2026 09:01:45 +0100 Subject: [PATCH 2/3] tiny fix --- rendercanvas/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 8f8a3bfa..01a86c53 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -307,7 +307,7 @@ def get_context(self, context_type: str | type) -> contexts.BaseContext: if self._present_method is not None: if self._present_method not in present_methods: raise RuntimeError( - f"Explicitly requested present_method {present_method!r} is not available for {context_name}." + f"Explicitly requested present_method {self._present_method!r} is not available for {context_name}." ) present_methods = [self._present_method] From ff8607fef437f420253c93126fd1391e61ca3f1c Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 15 Jan 2026 13:10:42 +0100 Subject: [PATCH 3/3] Fix and add tests --- rendercanvas/base.py | 2 +- tests/test_context.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 01a86c53..173d1614 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -323,7 +323,7 @@ def get_context(self, context_type: str | type) -> contexts.BaseContext: ) if info.get("method") not in present_methods: raise RuntimeError( - f"Present info dict field 'method' must be present in available present_methods {set(present_methods)}." + f"Present info method field ({info.get('method')!r}) is not part of the available methods {set(present_methods)}." ) self._present_method = info["method"] diff --git a/tests/test_context.py b/tests/test_context.py index f935f145..34d38afe 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -30,6 +30,15 @@ def get_test_bitmap(width, height): return bitmap +class CanvasWithExplicitPresentInfo(ManualOffscreenRenderCanvas): + def __init__(self, present_info): + super().__init__() + self._present_info = present_info + + def _rc_get_present_info(self, present_methods): + return self._present_info + + class WgpuContextToBitmapLookLikeWgpuPy(WgpuContextToBitmap): """A WgpuContextToBitmap with an API like (the new) wgpu.GPUCanvasContext. @@ -159,7 +168,7 @@ class MyContext2(BaseContext): with pytest.raises(TypeError) as err: canvas.get_context(MyContext2) - assert "not supported by the canvas backend" in str(err) + assert "is not supported" in str(err) def test_context_selection_fails(): @@ -181,6 +190,31 @@ def test_context_selection_fails(): assert "context type is invalid" in str(err) +def test_context_canvas_decides(): + # Method bitmap + canvas = CanvasWithExplicitPresentInfo({"method": "bitmap"}) + c = canvas.get_context("bitmap") + assert c.__class__.__name__.endswith("Bitmap") + + # Method screen (cannot do with this canvas) + # canvas = CanvasWithExplicitPresentInfo({"method": "screen"}) + # c = canvas.get_context("bitmap") + # assert c.__class__.__name__.endswith("Screen") + + # Nonexisting method + canvas = CanvasWithExplicitPresentInfo({"method": "foobar"}) + with pytest.raises(RuntimeError) as err: + c = canvas.get_context("bitmap") + assert "is not part of the available methods" in str(err) + + # No method + canvas = CanvasWithExplicitPresentInfo({}) + with pytest.raises(RuntimeError) as err: + c = canvas.get_context("bitmap") + assert "is not part of the available methods" in str(err) + assert "None" in str(err) + + def test_bitmap_context(): # Create canvas, and select the rendering context canvas = ManualOffscreenRenderCanvas()