From 31d3209ce0ff1b165fc085b4b0c918c90b05085a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 13 Nov 2025 16:16:05 +0100 Subject: [PATCH 1/3] Better support for custom contexts. --- rendercanvas/_size.py | 9 +-- rendercanvas/base.py | 95 ++++++++++++++------------ rendercanvas/contexts/basecontext.py | 15 +++- rendercanvas/contexts/bitmapcontext.py | 6 ++ rendercanvas/contexts/wgpucontext.py | 6 ++ tests/test_context.py | 62 +++++++++++++++-- tests/test_size.py | 10 +-- 7 files changed, 140 insertions(+), 63 deletions(-) diff --git a/rendercanvas/_size.py b/rendercanvas/_size.py index ffaa1fe..8355826 100644 --- a/rendercanvas/_size.py +++ b/rendercanvas/_size.py @@ -12,8 +12,7 @@ def __init__(self): self["canvas_pixel_ratio"] = 1.0 self["total_pixel_ratio"] = 1.0 self["logical_size"] = 1.0, 1.0 - self["need_size_event"] = False - self["need_context_resize"] = False + self["changed"] = False def set_physical_size(self, width: int, height: int, pixel_ratio: float): """Must be called by subclasses when their size changes. @@ -40,8 +39,7 @@ def _resolve_total_pixel_ratio_and_logical_size(self): self["total_pixel_ratio"] = total_pixel_ratio self["logical_size"] = logical_size - self["need_size_event"] = True - self["need_context_resize"] = True + self["changed"] = True def set_logical_size(self, width: float, height: float): """Called by the canvas when the logical size is set. @@ -62,8 +60,7 @@ def set_logical_size(self, width: float, height: float): self["logical_size"] = lwidth, lheight self["total_pixel_ratio"] = self["physical_size"][0] / lwidth - self["need_size_event"] = True - self["need_context_resize"] = True + self["changed"] = True def set_zoom(self, zoom: float): """Set the zoom factor, i.e. the canvas pixel ratio.""" diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 7a6e4a6..b309d01 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -227,16 +227,15 @@ def get_wgpu_context(self) -> contexts.WgpuContext: """Get the ``WgpuContext`` to render to this canvas.""" return self.get_context("wgpu") - def get_context( - self, context_type: Literal["bitmap", "wgpu"] - ) -> contexts.BaseContext: + def get_context(self, context_type: str | type) -> contexts.BaseContext: """Get a context object that can be used to render to this canvas. The context takes care of presenting the rendered result to the canvas. Different types of contexts are available: - * "wgpu": get a ``WgpuContext`` - * "bitmap": get a ``BitmapContext`` + * Use "wgpu" to get a ``WgpuContext`` + * Use "bitmap" to get a ``BitmapContext`` + * Use a subclass of ``BaseContext`` to get an instance of it. Later calls to this method, with the same context_type argument, will return the same context instance as was returned the first time the method was @@ -246,22 +245,45 @@ def get_context( # Note that this method is analog to HtmlCanvas.getContext(), except with different context types. - if not isinstance(context_type, str): - raise TypeError("context_type must be str.") + context_name = None + context_class = None - # Resolve the context type name - if context_type not in ("bitmap", "wgpu"): + # Resolve the context class + if isinstance(context_type, str): + # Builtin contexts + if context_type == "bitmap": + context_class = contexts.BitmapContext + elif context_type == "wgpu": + context_class = contexts.WgpuContext + else: + raise TypeError( + f"The given context type is invalid: {context_type!r} is not 'bitmap' or 'wgpu'." + ) + elif isinstance(context_type, type) and issubclass( + context_type, contexts.BaseContext + ): + # Custom context + context_class = context_type + else: raise TypeError( - "The given context type is invalid: {context_type!r} is not 'bitmap' or 'wgpu'." + "canvas.get_context(): context_type must be str or a subclass of BaseContext." ) + # Get the name + context_name = context_class.__name__ + # Is the context already set? if self._canvas_context is not None: - if context_type == self._canvas_context._context_type: + ref_context_name = getattr( + self._canvas_context, + "_context_name", + self._canvas_context.__class__.__name__, + ) + if context_name == ref_context_name: return self._canvas_context else: raise RuntimeError( - f"Cannot get context for '{context_type}': a context of type '{self._canvas_context._context_type}' is already set." + f"Cannot get context for '{context_name}': a context of type '{ref_context_name}' is already set." ) # Get available present methods. @@ -274,22 +296,13 @@ def get_context( ) # Select present_method - present_method = None - if context_type == "bitmap": - if "bitmap" in present_methods: - present_method = "bitmap" - elif "screen" in present_methods: - present_method = "screen" + for present_method in context_class.present_methods: + assert present_method in ("bitmap", "screen") + if present_method in present_methods: + break else: - if "screen" in present_methods: - present_method = "screen" - elif "bitmap" in present_methods: - present_method = "bitmap" - - # This should never happen, unless there's a bug - if present_method is None: - raise RuntimeError( - "Could not select present_method for context_type {context_type!r} from present_methods {present_methods!r}" + 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}." ) # Select present_info, and shape it into what the contexts need. @@ -304,14 +317,10 @@ def get_context( "vsync": self._vsync, } - if context_type == "bitmap": - context = contexts.BitmapContext(present_info) - else: - context = contexts.WgpuContext(present_info) - - # Done - self._canvas_context = context - self._canvas_context._context_type = context_type + # Create the context + self._canvas_context = context_class(present_info) + self._canvas_context._context_name = context_name + self._canvas_context._rc_set_size_dict(self._size_info) return self._canvas_context # %% Events @@ -337,14 +346,12 @@ def submit_event(self, event: dict) -> None: # %% Scheduling and drawing def __maybe_emit_resize_event(self): - # Keep context up-to-date - if self._size_info["need_context_resize"] and self._canvas_context is not None: - self._size_info["need_context_resize"] = False - self._canvas_context._rc_set_size_dict(self._size_info) - - # Keep event listeners up-to-date - if self._size_info["need_size_event"]: - self._size_info["need_size_event"] = False + if self._size_info["changed"]: + self._size_info["changed"] = False + # Keep context up-to-date + if self._canvas_context is not None: + self._canvas_context._rc_set_size_dict(self._size_info) + # Keep event listeners up-to-date lsize = self._size_info["logical_size"] self._events.emit( { @@ -752,7 +759,7 @@ def remove_event_handler(self, callback: EventHandlerFunction, *types: str) -> N def submit_event(self, event: dict) -> None: return self._subwidget._events.submit(event) - def get_context(self, context_type: str) -> object: + def get_context(self, context_type: str | type) -> object: return self._subwidget.get_context(context_type) def set_update_mode( diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index 36b199c..51dac75 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -9,11 +9,20 @@ class BaseContext: All contexts provide detailed size information. A rendering system should generally be capable to perform the rendering with just the context object; without a reference to the canvas. With this, we try to promote a clear - separation of concern, where one system listens to events from the canvas to - update a certain state, and the renderer uses this state and the context to - render the image. + separation of concerns, where one system listens to events from the canvas + to update a certain state, and the renderer uses this state and the context + to render the image. + + It's possible for users to create their own context sub-classes, and to then + do ``canvas.get_context(MyContext)``. That said, it's generally better, when + possible, to create an object that wraps a built-in context object instead. + That way your code will not break if the internal interface between this + class and the canvas is changed. """ + # Subclasses must define their present-methods that they support, in oder of preference + present_methods = [] + def __init__(self, present_info: dict): self._present_info = present_info assert present_info["method"] in ("bitmap", "screen") # internal sanity check diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index 868d67f..f2c0d24 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -14,6 +14,8 @@ class BitmapContext(BaseContext): which returns a subclass of this class, depending on the needs of the canvas. """ + present_methods = ["bitmap", "screen"] + def __new__(cls, present_info: dict): # Instantiating this class actually produces a subclass present_method = present_info["method"] @@ -72,6 +74,8 @@ def set_bitmap(self, bitmap): class BitmapContextToBitmap(BitmapContext): """A BitmapContext that just presents the bitmap to the canvas.""" + present_methods = ["bitmap"] + def __init__(self, present_info): super().__init__(present_info) assert self._present_info["method"] == "bitmap" @@ -109,6 +113,8 @@ class BitmapContextToScreen(BitmapContext): This is uses for canvases that do not support presenting a bitmap. """ + present_methods = ["screen"] + def __init__(self, present_info): super().__init__(present_info) diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index a27b02f..85e39df 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -15,6 +15,8 @@ class WgpuContext(BaseContext): which returns a subclass of this class, depending on the needs of the canvas. """ + present_methods = ["screen", "bitmap"] + def __new__(cls, present_info: dict): # Instantiating this class actually produces a subclass present_method = present_info["method"] @@ -139,6 +141,8 @@ class WgpuContextToScreen(WgpuContext): When running in Pyodide, it means it renders directly to a ````. """ + present_methods = ["screen"] + def __init__(self, present_info: dict): super().__init__(present_info) assert self._present_info["method"] == "screen" @@ -173,6 +177,8 @@ class WgpuContextToBitmap(WgpuContext): actually that big. """ + present_methods = ["bitmap"] + def __init__(self, present_info: dict): super().__init__(present_info) diff --git a/tests/test_context.py b/tests/test_context.py index 41ab3a3..a8815eb 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -56,6 +56,8 @@ def close(self): class BitmapContextToWgpuAndBackToBimap(BitmapContextToScreen): """A bitmap context that takes a detour via wgpu :)""" + present_methods = ["bitmap"] + def _create_wgpu_py_context(self): self._wgpu_context = WgpuContextToBitmapLookLikeWgpuPy(self._present_info) @@ -63,6 +65,15 @@ def _create_wgpu_py_context(self): # %% +def test_context_size(): + canvas = ManualOffscreenRenderCanvas() + context = canvas.get_context("bitmap") + + # Size should be immediate + assert canvas.get_physical_size() == (640, 480) + assert context.physical_size == (640, 480) + + def test_context_selection_bitmap(): # Select our builtin bitmap context @@ -108,6 +119,49 @@ def test_context_selection_wgpu(): assert context2 is context +def test_context_selection_by_class(): + canvas = ManualOffscreenRenderCanvas() + + # Select by class + context = canvas.get_context(BitmapContext) + assert isinstance(context, BitmapContext) + assert isinstance(context, BaseContext) + + # Again + context2 = canvas.get_context(BitmapContext) + assert context2 is context + + # Compatible with builtin bitmap + context2 = canvas.get_context("bitmap") + assert context2 is context + + # Cannot select another context now + with pytest.raises(RuntimeError): + canvas.get_context("wgpu") + with pytest.raises(RuntimeError): + canvas.get_context(WgpuContext) + + +def test_context_selection_by_custom_class(): + class MyContext1(BaseContext): + present_methods = ["bitmap"] + + class MyContext2(BaseContext): + present_methods = ["screen"] + + canvas = ManualOffscreenRenderCanvas() + + context = canvas.get_context(MyContext1) + assert isinstance(context, MyContext1) + assert context._present_info["method"] == "bitmap" + + canvas = ManualOffscreenRenderCanvas() + + with pytest.raises(TypeError) as err: + canvas.get_context(MyContext2) + assert "not supported by the canvas backend" in str(err) + + def test_context_selection_fails(): canvas = ManualOffscreenRenderCanvas() @@ -118,7 +172,7 @@ def test_context_selection_fails(): # Must be a string with pytest.raises(TypeError) as err: - canvas.get_context(BitmapContext) + canvas.get_context([1, 2, 3]) assert "must be str" in str(err) # Must be a valid string @@ -163,10 +217,8 @@ def test_bitmap_context(): def test_wgpu_context(): # Create canvas and attach our special adapter canvas canvas = ManualOffscreenRenderCanvas() - context = BitmapContextToWgpuAndBackToBimap( - {"method": "bitmap", "formats": ["rgba-u8"]} - ) - canvas._canvas_context = context + context = canvas.get_context(BitmapContextToWgpuAndBackToBimap) + assert isinstance(context, BitmapContextToWgpuAndBackToBimap) assert isinstance(context, BitmapContext) # Create and set bitmap diff --git a/tests/test_size.py b/tests/test_size.py index b27c129..965c1ab 100644 --- a/tests/test_size.py +++ b/tests/test_size.py @@ -12,7 +12,7 @@ def test_size_info_basic(): assert si["physical_size"] == (1, 1) assert si["logical_size"] == (1.0, 1.0) assert si["total_pixel_ratio"] == 1.0 - assert si["need_size_event"] is False + assert si["changed"] is False # Backends setting physical size si.set_physical_size(10, 10, 1) @@ -20,7 +20,7 @@ def test_size_info_basic(): assert si["physical_size"] == (10, 10) assert si["logical_size"] == (10.0, 10.0) assert si["total_pixel_ratio"] == 1.0 - assert si["need_size_event"] is True + assert si["changed"] is True # Different pixel ratio si.set_physical_size(10, 10, 2.5) @@ -28,7 +28,7 @@ def test_size_info_basic(): assert si["physical_size"] == (10, 10) assert si["logical_size"] == (4.0, 4.0) assert si["total_pixel_ratio"] == 2.5 - assert si["need_size_event"] is True + assert si["changed"] is True def test_size_info_logical(): @@ -36,7 +36,7 @@ def test_size_info_logical(): assert si["physical_size"] == (1, 1) assert si["logical_size"] == (1.0, 1.0) assert si["total_pixel_ratio"] == 1.0 - assert si["need_size_event"] is False + assert si["changed"] is False # Base class setting logical size si.set_logical_size(100, 100) @@ -44,7 +44,7 @@ def test_size_info_logical(): assert si["physical_size"] == (1, 1) # don't touch! assert si["logical_size"] == (100.0, 100.0) assert si["total_pixel_ratio"] == 0.01 - assert si["need_size_event"] is True + assert si["changed"] is True # Again si.set_logical_size(640, 480) From a6639e26d2667d304883e702e0c87d050c45ae9f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 14 Nov 2025 09:31:13 +0100 Subject: [PATCH 2/3] Tweak docs on contexts --- docs/contexts.rst | 35 +++++++++++++++++++++++++- rendercanvas/base.py | 4 +-- rendercanvas/contexts/__init__.py | 16 ------------ rendercanvas/contexts/basecontext.py | 16 +----------- rendercanvas/contexts/bitmapcontext.py | 8 +++--- rendercanvas/contexts/wgpucontext.py | 9 ++++--- 6 files changed, 46 insertions(+), 42 deletions(-) diff --git a/docs/contexts.rst b/docs/contexts.rst index 784f7a4..5950827 100644 --- a/docs/contexts.rst +++ b/docs/contexts.rst @@ -1,7 +1,40 @@ Context API =========== -.. automodule:: rendercanvas.contexts +A context provides an API to provide a rendered image, and implements a +mechanism to present that image for display. The concept of a context is +heavily inspired by the ```` and its contexts in the browser. + +**Available context classes** + +* The :class:`~rendercanvas.contexts.BaseContext` exposes the common API. +* The :class:`~rendercanvas.contexts.BitmapContext` exposes an API that takes image bitmaps in RAM. +* The :class:`~rendercanvas.contexts.WgpuContext` exposes an API that provides image textures on the GPU to render to. + +**Getting a context** + +Context objects must be created using ``context = canvas.get_context(..)``, +or the dedicated ``canvas.get_bitmap_context()`` and +``canvas.get_wgpu_context()``. + +**Using a context** + +All contexts provide detailed size information (which is kept up-to-date by +the canvas). A rendering system should generally be capable to perform the +rendering with just the context object; without a reference to the canvas. +With this, we try to promote a clear separation of concerns, where one +system listens to events from the canvas to update a certain state, and the +renderer uses this state and the context to render the image. + +**Advanced: creating a custom context API** + +It's possible for users to create their own context sub-classes. This can be +a good solution, e.g. when your system needs to handle the presentation by +itself. In general it's better, when possible, to create an object that *wraps* a +built-in context object: ``my_ob = MyClass(canvas.get_context(..))``. That +way your code will not break if the internal interface between the context +and the canvas is changed. + .. autoclass:: rendercanvas.contexts.BaseContext :members: diff --git a/rendercanvas/base.py b/rendercanvas/base.py index b309d01..701d473 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -235,7 +235,7 @@ def get_context(self, context_type: str | type) -> contexts.BaseContext: * Use "wgpu" to get a ``WgpuContext`` * Use "bitmap" to get a ``BitmapContext`` - * Use a subclass of ``BaseContext`` to get an instance of it. + * Use a subclass of ``BaseContext`` to create an instance that is set up for this canvas. Later calls to this method, with the same context_type argument, will return the same context instance as was returned the first time the method was @@ -266,7 +266,7 @@ def get_context(self, context_type: str | type) -> contexts.BaseContext: context_class = context_type else: raise TypeError( - "canvas.get_context(): context_type must be str or a subclass of BaseContext." + "canvas.get_context(): context_type must be 'bitmap', 'wgpu', or a subclass of BaseContext." ) # Get the name diff --git a/rendercanvas/contexts/__init__.py b/rendercanvas/contexts/__init__.py index 2f00624..4e18492 100644 --- a/rendercanvas/contexts/__init__.py +++ b/rendercanvas/contexts/__init__.py @@ -1,19 +1,3 @@ -""" -A context provides an API to provide a rendered image, and implements a -mechanism to present that image for display. The concept of a context is heavily -inspired by the canvas and its contexts in the browser. - -In ``rendercanvas``, there are two types of contexts: the *bitmap* context -exposes an API that takes image bitmaps in RAM, and the *wgpu* context exposes -an API that provides image textures on the GPU to render to. - -The presentation of the rendered image is handled by a sub-system, e.g. display -directly to screen, pass as bitmap to a GUI toolkit, send bitmap to a remote -client, etc. Each such subsystem is handled by a dedicated subclasses of -``BitmapContext`` and ``WgpuContext``. Users only need to be aware of the base -classes. -""" - from .basecontext import * # noqa: F403 from .bitmapcontext import * # noqa: F403 from .wgpucontext import * # noqa: F403 diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index 51dac75..d880d9c 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -4,21 +4,7 @@ class BaseContext: - """The base class for context objects in ``rendercanvas``. - - All contexts provide detailed size information. A rendering system should - generally be capable to perform the rendering with just the context object; - without a reference to the canvas. With this, we try to promote a clear - separation of concerns, where one system listens to events from the canvas - to update a certain state, and the renderer uses this state and the context - to render the image. - - It's possible for users to create their own context sub-classes, and to then - do ``canvas.get_context(MyContext)``. That said, it's generally better, when - possible, to create an object that wraps a built-in context object instead. - That way your code will not break if the internal interface between this - class and the canvas is changed. - """ + """The base class for context objects in ``rendercanvas``.""" # Subclasses must define their present-methods that they support, in oder of preference present_methods = [] diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index f2c0d24..3a5a5c0 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -8,12 +8,12 @@ class BitmapContext(BaseContext): This is loosely inspired by JS' ``ImageBitmapRenderingContext``. Rendering bitmaps is a simple way to use ``rendercanvas``, but usually not as - performant as a wgpu context. - - Users typically don't instantiate contexts directly, but use ``canvas.get_bitmap_context()``, - which returns a subclass of this class, depending on the needs of the canvas. + performant as a wgpu context. Use ``canvas.get_bitmap_context()`` to create + a ``BitmapContext``. """ + # Note: instantiating this class creates an instance of a sub-class, dedicated to the present method of the canvas. + present_methods = ["bitmap", "screen"] def __new__(cls, present_info: dict): diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 85e39df..8d9f96a 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -9,12 +9,13 @@ class WgpuContext(BaseContext): """A context that exposes an API that provides a GPU texture to render to. - This is inspired by JS' ``GPUCanvasContext``, and the more performant approach for rendering to a ``rendercanvas``. - - Users typically don't instantiate contexts directly, but use ``canvas.get_wgpu_context()``, - which returns a subclass of this class, depending on the needs of the canvas. + This is inspired by JS' ``GPUCanvasContext``, and the more performant + approach for rendering to a ``rendercanvas``. Use + ``canvas.get_wgpu_context()`` to create a ``WgpuContext``. """ + # Note: instantiating this class creates an instance of a sub-class, dedicated to the present method of the canvas. + present_methods = ["screen", "bitmap"] def __new__(cls, present_info: dict): From 63a5b1216e80641f352892fd93c3bce0391ecd9c Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 14 Nov 2025 09:34:09 +0100 Subject: [PATCH 3/3] update test for new error msg --- tests/test_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_context.py b/tests/test_context.py index a8815eb..f935f14 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -173,7 +173,7 @@ def test_context_selection_fails(): # Must be a string with pytest.raises(TypeError) as err: canvas.get_context([1, 2, 3]) - assert "must be str" in str(err) + assert "must be 'bitmap', 'wgpu', or " in str(err) # Must be a valid string with pytest.raises(TypeError) as err: