Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion docs/contexts.rst
Original file line number Diff line number Diff line change
@@ -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 ``<canvas>`` 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:
Expand Down
9 changes: 3 additions & 6 deletions rendercanvas/_size.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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."""
Expand Down
95 changes: 51 additions & 44 deletions rendercanvas/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand All @@ -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 'bitmap', 'wgpu', 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.
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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(
{
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 0 additions & 16 deletions rendercanvas/contexts/__init__.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 4 additions & 9 deletions rendercanvas/contexts/basecontext.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,10 @@


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 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.
"""
"""The base class for context objects in ``rendercanvas``."""

# 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
Expand Down
14 changes: 10 additions & 4 deletions rendercanvas/contexts/bitmapcontext.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ 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):
# Instantiating this class actually produces a subclass
present_method = present_info["method"]
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down
15 changes: 11 additions & 4 deletions rendercanvas/contexts/wgpucontext.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@
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):
# Instantiating this class actually produces a subclass
present_method = present_info["method"]
Expand Down Expand Up @@ -139,6 +142,8 @@ class WgpuContextToScreen(WgpuContext):
When running in Pyodide, it means it renders directly to a ``<canvas>``.
"""

present_methods = ["screen"]

def __init__(self, present_info: dict):
super().__init__(present_info)
assert self._present_info["method"] == "screen"
Expand Down Expand Up @@ -173,6 +178,8 @@ class WgpuContextToBitmap(WgpuContext):
actually that big.
"""

present_methods = ["bitmap"]

def __init__(self, present_info: dict):
super().__init__(present_info)

Expand Down
Loading