diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e321e6d..a0d72f8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,6 +40,7 @@ jobs:
docs:
name: Docs
+ needs: [release]
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -48,11 +49,21 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
- python-version: 3.12
+ python-version: 3.12
- name: Install dev dependencies
run: |
python -m pip install --upgrade pip
pip install -U -e .[docs]
+ - name: Download assets
+ uses: actions/download-artifact@v4
+ with:
+ name: dist
+ path: dist
+ - name: move wheel into static
+ run: |
+ mkdir -p docs/static
+ mv dist/* docs/static
+ ls -la docs/static
- name: Build docs
run: |
cd docs
diff --git a/docs/backends.rst b/docs/backends.rst
index 13248cb..4facfaa 100644
--- a/docs/backends.rst
+++ b/docs/backends.rst
@@ -46,7 +46,12 @@ The table below gives an overview of the names in the different ``rendercanvas``
| ``loop``
- | Create a standalone canvas using wx, or
| integrate a render canvas in a wx application.
-
+ * - ``html``
+ - | ``HTMLRenderCanvas`` (toplevel)
+ | ``RenderCanvas`` (alias)
+ | ``loop`` (an ``AsyncioLoop``)
+ - | A canvas that runs in a web browser, using Pyodide.
+
There are also three loop-backends. These are mainly intended for use with the glfw backend:
@@ -168,7 +173,7 @@ Alternatively, you can select the specific qt library to use, making it easy to
loop.run() # calls app.exec_()
-It is technically possible to e.g. use a ``glfw`` canvas with the Qt loop. However, this is not recommended because Qt gets confused in the precense of other windows and may hang or segfault.
+It is technically possible to e.g. use a ``glfw`` canvas with the Qt loop. However, this is not recommended because Qt gets confused in the presence of other windows and may hang or segfault.
But the other way around, running a Qt canvas in e.g. the trio loop, works fine:
.. code-block:: py
@@ -264,6 +269,47 @@ subclass implementing a remote frame-buffer. There are also some `wgpu examples
canvas # Use as cell output
+Support for HTMLCanvas in Pyodide
+---------------------------------
+When RenderCanvas runs in the browser using Pyodide the auto backend selects ``rendercanvas.html.HTMLRenderCanvas`` class.
+It expects a HTMLCanvasElement to be present in the DOM. It requires no additional dependencies, as rendercanvas can be installed from micropip.
+
+.. code-block:: html
+
+
+
+
+
+
+
+ ...
+
+
+
+
+
+
+Currently only presenting a bitmap is supported, as shown in the examples :doc:`noise.py ` and :doc:`snake.py `.
.. _env_vars:
diff --git a/docs/conf.py b/docs/conf.py
index 42759c7..ec632a2 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -22,8 +22,8 @@
# Load wglibu so autodoc can query docstrings
import rendercanvas # noqa: E402
-import rendercanvas.stub # noqa: E402 - we use the stub backend to generate doccs
-import rendercanvas._context # noqa: E402 - we use the ContexInterface to generate doccs
+import rendercanvas.stub # noqa: E402 - we use the stub backend to generate docs
+import rendercanvas._context # noqa: E402 - we use the ContextInterface to generate docs
import rendercanvas.utils.bitmappresentadapter # noqa: E402
# -- Project information -----------------------------------------------------
diff --git a/docs/contextapi.rst b/docs/contextapi.rst
index 252ea08..f5bbf16 100644
--- a/docs/contextapi.rst
+++ b/docs/contextapi.rst
@@ -24,7 +24,7 @@ then present the result to the screen. For this, the canvas provides one or more
└─────────┘ └────────┘
This means that for the context to be able to present to any canvas, it must
-support *both* the 'image' and 'screen' present-methods. If the context prefers
+support *both* the 'bitmap' and 'screen' present-methods. If the context prefers
presenting to the screen, and the canvas supports that, all is well. Similarly,
if the context has a bitmap to present, and the canvas supports the
bitmap-method, there's no problem.
@@ -44,7 +44,7 @@ on the CPU. All GPU API's have ways to do this.
download from gpu to cpu
If the context has a bitmap to present, and the canvas only supports presenting
-to screen, you can usse a small utility: the ``BitmapPresentAdapter`` takes a
+to screen, you can use a small utility: the ``BitmapPresentAdapter`` takes a
bitmap and presents it to the screen.
.. code-block::
@@ -58,7 +58,7 @@ bitmap and presents it to the screen.
This way, contexts can be made to work with all canvas backens.
-Canvases may also provide additionaly present-methods. If a context knows how to
+Canvases may also provide additionally present-methods. If a context knows how to
use that present-method, it can make use of it. Examples could be presenting
diff images or video streams.
diff --git a/docs/start.rst b/docs/start.rst
index a2817d7..598548e 100644
--- a/docs/start.rst
+++ b/docs/start.rst
@@ -79,12 +79,12 @@ Async
A render canvas can be used in a fully async setting using e.g. Asyncio or Trio, or in an event-drived framework like Qt.
If you like callbacks, ``loop.call_later()`` always works. If you like async, use ``loop.add_task()``. Event handlers can always be async.
-If you make use of async functions (co-routines), and want to keep your code portable accross
+If you make use of async functions (co-routines), and want to keep your code portable across
different canvas backends, restrict your use of async features to ``sleep`` and ``Event``;
-these are the only features currently implemened in our async adapter utility.
+these are the only features currently implemented in our async adapter utility.
We recommend importing these from :doc:`rendercanvas.utils.asyncs ` or use ``sniffio`` to detect the library that they can be imported from.
-On the other hand, if you know your code always runs on the asyncio loop, you can fully make use of ``asyncio``. Dito for Trio.
+On the other hand, if you know your code always runs on the asyncio loop, you can fully make use of ``asyncio``. Ditto for Trio.
If you use Qt and get nervous from async code, no worries, when running on Qt, ``asyncio`` is not even imported. You can regard most async functions
as syntactic sugar for pieces of code chained with ``call_later``. That's more or less how our async adapter works :)
diff --git a/docs/static/_pyodide_iframe.html b/docs/static/_pyodide_iframe.html
new file mode 100644
index 0000000..0b3a02e
--- /dev/null
+++ b/docs/static/_pyodide_iframe.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/static/custom.css b/docs/static/custom.css
index 0fd546b..02641fe 100644
--- a/docs/static/custom.css
+++ b/docs/static/custom.css
@@ -1,4 +1,9 @@
div.sphx-glr-download,
div.sphx-glr-download-link-note {
display: none;
+}
+div.document iframe {
+ width: 100%;
+ height: 500px;
+ border: none;
}
\ No newline at end of file
diff --git a/examples/events.py b/examples/events.py
index 262aad5..50e0321 100644
--- a/examples/events.py
+++ b/examples/events.py
@@ -19,3 +19,16 @@ def process_event(event):
if __name__ == "__main__":
loop.run()
+
+# %%
+#
+# .. only:: html
+#
+# Interactive example
+# ===================
+# There is no visible canvas, but events will get printed to your browsers console.
+#
+# .. raw:: html
+#
+#
+#
diff --git a/examples/local_browser.html b/examples/local_browser.html
new file mode 100644
index 0000000..c1862e0
--- /dev/null
+++ b/examples/local_browser.html
@@ -0,0 +1,80 @@
+
+
+
+
+ RenderCanvas HTML canvas via Pyodide:
+
+
+
+
+
+
+
+
+
+
+Load WebGPU (experimental)
+
+
+
+some text below the canvas!
+
+
+
+
\ No newline at end of file
diff --git a/examples/multicanvas_browser.html b/examples/multicanvas_browser.html
new file mode 100644
index 0000000..6c6d3cc
--- /dev/null
+++ b/examples/multicanvas_browser.html
@@ -0,0 +1,106 @@
+
+
+
+ How to distinguish multiple canvas elements?:
+
+
+
+
+
+
+
+
+First canvas updates when the pointer hovers, second canvas changes direction while keypress, third canvas updates when you click!
+
+
+
+
\ No newline at end of file
diff --git a/examples/noise.py b/examples/noise.py
index e97df57..a7bc430 100644
--- a/examples/noise.py
+++ b/examples/noise.py
@@ -25,3 +25,16 @@ def animate():
loop.run()
+
+# %%
+#
+# .. only:: html
+#
+# Interactive example
+# ===================
+# This example can be run interactively in the browser using Pyodide.
+#
+# .. raw:: html
+#
+#
+#
diff --git a/examples/snake.py b/examples/snake.py
index d7d9e8e..53915a7 100644
--- a/examples/snake.py
+++ b/examples/snake.py
@@ -64,3 +64,16 @@ def animate():
loop.run()
+
+# %%
+#
+# .. only:: html
+#
+# Interactive example
+# ===================
+# Keyboard events are supported in the browser. Use the arrow keys to control the snake!
+#
+# .. raw:: html
+#
+#
+#
diff --git a/rendercanvas/_events.py b/rendercanvas/_events.py
index bc523ba..cd2a1ef 100644
--- a/rendercanvas/_events.py
+++ b/rendercanvas/_events.py
@@ -78,8 +78,8 @@ def my_handler(event):
.. code-block:: py
- @canvas.add_event_handler("pointer_up", "pointer_down") def
- my_handler(event):
+ @canvas.add_event_handler("pointer_up", "pointer_down")
+ def my_handler(event):
print(event)
Catch 'm all:
diff --git a/rendercanvas/auto.py b/rendercanvas/auto.py
index a1987e8..ae16226 100644
--- a/rendercanvas/auto.py
+++ b/rendercanvas/auto.py
@@ -29,6 +29,8 @@ def _load_backend(backend_name):
from . import wx as module
elif backend_name == "offscreen":
from . import offscreen as module
+ elif backend_name == "html":
+ from . import html as module
else: # no-cover
raise ImportError("Unknown rendercanvas backend: '{backend_name}'")
return module
@@ -82,6 +84,7 @@ def backends_generator():
for gen in [
backends_by_env_vars,
backends_by_jupyter,
+ backends_by_browser,
backends_by_imported_modules,
backends_by_trying_in_order,
]:
@@ -204,6 +207,13 @@ def backends_by_trying_in_order():
yield backend_name, f"{libname} can be imported"
+def backends_by_browser():
+ """If python runs in a web browser, we use the html backend."""
+ # https://pyodide.org/en/stable/usage/faq.html#how-to-detect-that-code-is-run-with-pyodide
+ if sys.platform == "emscripten":
+ yield "html", "running in a web browser"
+
+
# Load!
module = select_backend()
RenderCanvas = cast(type[BaseRenderCanvas], module.RenderCanvas)
diff --git a/rendercanvas/base.py b/rendercanvas/base.py
index 1f34903..f2f374b 100644
--- a/rendercanvas/base.py
+++ b/rendercanvas/base.py
@@ -624,7 +624,7 @@ def _rc_get_pixel_ratio(self) -> float:
raise NotImplementedError()
def _rc_set_logical_size(self, width: float, height: float):
- """Set the logical size. May be ignired when it makes no sense.
+ """Set the logical size. May be ignored when it makes no sense.
The default implementation does nothing.
"""
diff --git a/rendercanvas/html.py b/rendercanvas/html.py
new file mode 100644
index 0000000..230e591
--- /dev/null
+++ b/rendercanvas/html.py
@@ -0,0 +1,448 @@
+"""
+Support to run rendercanvas on the webbrowser via Pyodide.
+
+We expect to have a HTMLCanvas element with the id "canvas".
+It is not required to set the default sdl2 canvas as the Pyodide docs describe.
+"""
+
+__all__ = ["HtmlRenderCanvas", "RenderCanvas", "loop"]
+
+from rendercanvas.base import BaseRenderCanvas, BaseCanvasGroup
+from rendercanvas.asyncio import loop
+
+import sys
+
+if "pyodide" not in sys.modules:
+ raise ImportError("This module is only for use with Pyodide in the browser.")
+
+# packages available inside pyodide
+from pyodide.ffi import run_sync, create_proxy
+from js import document, ImageData, Uint8ClampedArray, window, HTMLCanvasElement, ResizeObserver
+
+
+# needed for completeness? somehow is required for other examples - hmm?
+class HtmlCanvasGroup(BaseCanvasGroup):
+ pass
+
+
+# https://rendercanvas.readthedocs.io/stable/backendapi.html#rendercanvas.stub.StubRenderCanvas
+class HtmlRenderCanvas(BaseRenderCanvas):
+ _rc_canvas_group = HtmlCanvasGroup(loop) # todo do we need the group?
+
+ def __init__(
+ self,
+ canvas_el: str = "canvas",
+ *args,
+ **kwargs,
+ ):
+ if isinstance(canvas_el, str):
+ self.canvas_element = document.querySelector(canvas_el)
+ else:
+ self.canvas_element = canvas_el
+ if "size" not in kwargs:
+ # if size isn't given, we use the existing size.
+ # otherwise the final init will set it to the default (480,640)
+ kwargs["size"] = self.get_logical_size()
+ super().__init__(*args, **kwargs)
+ self._setup_events()
+ self._js_array = Uint8ClampedArray.new(0)
+ self._final_canvas_init()
+
+ @property
+ def html_context(self):
+ # this should only be accessed canvas.get_context("ctx_type") was called.
+ return self._html_context
+
+
+ def _setup_events(self):
+ # following list from: https://jupyter-rfb.readthedocs.io/en/stable/events.html
+ # better: https://rendercanvas.readthedocs.io/stable/api.html#rendercanvas.EventType
+ key_mod_map = {
+ "altKey": "Alt",
+ "ctrlKey": "Control",
+ "metaKey": "Meta",
+ "shiftKey": "Shift",
+ }
+
+ # https://jupyter-rfb.readthedocs.io/en/stable/events.html#mouse-buttons
+ # https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button
+ mouse_button_map = {
+ -1:0, # no button
+ 0: 1, # left
+ 1: 3, # middle/wheel
+ 2: 2, # right
+ 3: 4, # backwards
+ 4: 5, # forwards
+ }
+
+ # https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
+ def buttons_mask_to_tuple(mask) -> tuple[int, ...]:
+ bin(mask)
+ res = ()
+ for i, v in enumerate(bin(mask)[:1:-1]):
+ if v == "1":
+ res += (mouse_button_map.get(i, i),)
+ return res
+
+ self._pointer_inside = False # keep track for the pointer_move event
+ # resize ? maybe composition?
+ # perhaps: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
+
+ def _resize_callback(entries, observer):
+ entry = entries[0] # assume it's just this as we are observing the canvas element only?
+ # print(entry)
+ new_size = ()
+ ratio = self.get_pixel_ratio()
+ if entry.devicePixelContentBoxSize: # safari doesn't
+ new_size = (entry.devicePixelContentBoxSize[0].inlineSize, entry.devicePixelContentBoxSize[0].blockSize)
+ else:
+ lsize = ()
+ if entry.contentBoxSize:
+ lsize = (entry.contentBoxSize[0].inlineSize, entry.contentBoxSize[0].blockSize)
+ else:
+ lsize = (entry.contentRect.width, entry.contentRect.height)
+ new_size = (int(lsize[0]*ratio), int(lsize[1]*ratio))
+
+ event = {
+ "width": new_size[0],
+ "height": new_size[1],
+ "pixel_ratio": ratio,
+ "event_type": "resize",
+ }
+ self.submit_event(event)
+
+ self._resize_callback_proxy = create_proxy(_resize_callback)
+ self._resize_observer = ResizeObserver.new(self._resize_callback_proxy)
+ self._resize_observer.observe(self.canvas_element)
+
+ # close ? perhaps https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
+
+ # pointer_down
+ def _html_pointer_down(proxy_args):
+ modifiers = tuple(
+ [v for k, v in key_mod_map.items() if getattr(proxy_args, k)]
+ )
+ event = {
+ "event_type": "pointer_down",
+ "x": proxy_args.offsetX,
+ "y": proxy_args.offsetY,
+ "button": mouse_button_map.get(proxy_args.button, proxy_args.button),
+ "buttons": buttons_mask_to_tuple(proxy_args.buttons),
+ "modifiers": modifiers,
+ "ntouches": 0, # TODO: maybe via https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent
+ "touches": {},
+ "time_stamp": proxy_args.timeStamp,
+ }
+ self.submit_event(event)
+
+ self._pointer_down_proxy = create_proxy(_html_pointer_down)
+ self.canvas_element.addEventListener("pointerdown", self._pointer_down_proxy)
+
+ # pointer_up
+ def _html_pointer_up(proxy_args):
+ modifiers = tuple(
+ [v for k, v in key_mod_map.items() if getattr(proxy_args, k)]
+ )
+ event = {
+ "event_type": "pointer_up",
+ "x": proxy_args.offsetX,
+ "y": proxy_args.offsetY,
+ "button": mouse_button_map.get(proxy_args.button, proxy_args.button),
+ "buttons": buttons_mask_to_tuple(proxy_args.buttons),
+ "modifiers": modifiers,
+ "ntouches": 0,
+ "touches": {},
+ "time_stamp": proxy_args.timeStamp,
+ }
+ self.submit_event(event)
+
+ self._pointer_up_proxy = create_proxy(_html_pointer_up)
+ self.canvas_element.addEventListener("pointerup", self._pointer_up_proxy)
+
+ # pointer_move
+ def _html_pointer_move(proxy_args):
+ if (not self._pointer_inside) and (not proxy_args.buttons): # only when inside or a button is pressed
+ return
+ modifiers = tuple(
+ [v for k, v in key_mod_map.items() if getattr(proxy_args, k)]
+ )
+ event = {
+ "event_type": "pointer_move",
+ "x": proxy_args.offsetX,
+ "y": proxy_args.offsetY,
+ "button": mouse_button_map.get(proxy_args.button, proxy_args.button),
+ "buttons": buttons_mask_to_tuple(proxy_args.buttons),
+ "modifiers": modifiers,
+ "ntouches": 0,
+ "touches": {},
+ "time_stamp": proxy_args.timeStamp,
+ }
+ self.submit_event(event)
+
+ self._pointer_move_proxy = create_proxy(_html_pointer_move)
+ document.addEventListener("pointermove", self._pointer_move_proxy)
+
+ # pointer_enter
+ def _html_pointer_enter(proxy_args):
+ modifiers = tuple(
+ [v for k, v in key_mod_map.items() if getattr(proxy_args, k)]
+ )
+ event = {
+ "event_type": "pointer_enter",
+ "x": proxy_args.offsetX,
+ "y": proxy_args.offsetY,
+ "button": mouse_button_map.get(proxy_args.button, proxy_args.button),
+ "buttons": buttons_mask_to_tuple(proxy_args.buttons),
+ "modifiers": modifiers,
+ "ntouches": 0,
+ "touches": {},
+ "time_stamp": proxy_args.timeStamp,
+ }
+ self.submit_event(event)
+ self._pointer_inside = True
+
+ self._pointer_enter_proxy = create_proxy(_html_pointer_enter)
+ self.canvas_element.addEventListener("pointerenter", self._pointer_enter_proxy)
+
+ # pointer_leave
+ def _html_pointer_leave(proxy_args):
+ modifiers = tuple(
+ [v for k, v in key_mod_map.items() if getattr(proxy_args, k)]
+ )
+ event = {
+ "event_type": "pointer_leave",
+ "x": proxy_args.offsetX,
+ "y": proxy_args.offsetY,
+ "button": mouse_button_map.get(proxy_args.button, proxy_args.button),
+ "buttons": buttons_mask_to_tuple(proxy_args.buttons),
+ "modifiers": modifiers,
+ "ntouches": 0,
+ "touches": {},
+ "time_stamp": proxy_args.timeStamp,
+ }
+ self.submit_event(event)
+ self._pointer_inside = False
+
+ self._pointer_leave_proxy = create_proxy(_html_pointer_leave)
+ self.canvas_element.addEventListener("pointerleave", self._pointer_leave_proxy)
+ # TODO: can all the above be refactored into a function consturctor/factory?
+
+ # double_click
+ def _html_double_click(proxy_args):
+ modifiers = tuple(
+ [v for k, v in key_mod_map.items() if getattr(proxy_args, k)]
+ )
+ event = {
+ "event_type": "double_click",
+ "x": proxy_args.offsetX,
+ "y": proxy_args.offsetY,
+ "button": mouse_button_map.get(proxy_args.button, proxy_args.button),
+ "buttons": buttons_mask_to_tuple(proxy_args.buttons),
+ "modifiers": modifiers,
+ # no touches here
+ "time_stamp": proxy_args.timeStamp,
+ }
+ self.submit_event(event)
+
+ self._double_click_proxy = create_proxy(_html_double_click)
+ self.canvas_element.addEventListener("dblclick", self._double_click_proxy)
+
+ # wheel
+ def _html_wheel(proxy_args):
+ modifiers = tuple(
+ [v for k, v in key_mod_map.items() if getattr(proxy_args, k)]
+ )
+ event = {
+ "event_type": "wheel",
+ "dx": proxy_args.deltaX,
+ "dy": proxy_args.deltaY,
+ "x": proxy_args.offsetX,
+ "y": proxy_args.offsetY,
+ "buttons": buttons_mask_to_tuple(proxy_args.buttons),
+ "modifiers": modifiers,
+ "time_stamp": proxy_args.timeStamp,
+ }
+ self.submit_event(event)
+
+ self._wheel_proxy = create_proxy(_html_wheel)
+ self.canvas_element.addEventListener("wheel", self._wheel_proxy)
+
+ # key_down
+ def _html_key_down(proxy_args):
+ modifiers = tuple(
+ [v for k, v in key_mod_map.items() if getattr(proxy_args, k)]
+ )
+ event = {
+ "event_type": "key_down",
+ "modifiers": modifiers,
+ "key": proxy_args.key,
+ "time_stamp": proxy_args.timeStamp,
+ }
+ self.submit_event(event)
+
+ self._key_down_proxy = create_proxy(_html_key_down)
+ document.addEventListener(
+ "keydown", self._key_down_proxy
+ ) # key events happen on document scope?
+
+ # key_up
+ def _html_key_up(proxy_args):
+ modifiers = tuple(
+ [v for k, v in key_mod_map.items() if getattr(proxy_args, k)]
+ )
+ event = {
+ "event_type": "key_up",
+ "modifiers": modifiers,
+ "key": proxy_args.key,
+ "time_stamp": proxy_args.timeStamp,
+ }
+ self.submit_event(event)
+
+ self._key_up_proxy = create_proxy(_html_key_up)
+ document.addEventListener("keyup", self._key_up_proxy)
+
+ # char ... it's not this
+ # def _html_char(proxy_args):
+ # print(dir(proxy_args))
+ # modifiers = tuple(
+ # [v for k, v in key_mod_map.items() if getattr(proxy_args, k)]
+ # )
+ # event = {
+ # "event_type": "char",
+ # "modifiers": modifiers,
+ # "char_str": proxy_args.key, # unsure if this works, it's experimental anyway: https://github.com/pygfx/rendercanvas/issues/28
+ # "time_stamp": proxy_args.timeStamp,
+ # }
+ # self.submit_event(event)
+
+ # self._char_proxy = create_proxy(_html_char)
+ # document.addEventListener(
+ # "input", self._char_proxy
+ # ) # maybe just another keydown? (seems to include unicode chars)
+
+ # animate event doesn't seem to be actually implemented, and it's by the loop not the gui.
+
+ def _rc_gui_poll(self):
+ # not sure if anything has to be done
+ pass
+
+ def _rc_get_present_methods(self):
+ # in the future maybe we can get the webgpu context (as JsProxy) or something... future stuff!
+ return {
+ "bitmap": {
+ "formats": ["rgba-u8"],
+ },
+ "screen": {
+ "formats": ["rgba-u8"],
+ "window": self.canvas_element.js_id, #is a number - doubt it's useful though...
+ }
+ }
+
+ def _rc_request_draw(self):
+ loop = self._rc_canvas_group.get_loop()
+ loop.call_soon(self._draw_frame_and_present)
+
+ def _rc_force_draw(self):
+ self._draw_frame_and_present()
+
+ def _rc_present_bitmap(self, **kwargs):
+ data = kwargs.get("data") # data is a memoryview
+ shape = data.shape # use data shape instead of canvas size
+ if (
+ self._js_array.length != shape[0] * shape[1] * 4
+ ): # #assumes rgba-u8 -> 4 bytes per pixel
+ # resize step here? or on first use.
+ self._js_array = Uint8ClampedArray.new(shape[0] * shape[1] * 4)
+ self._js_array.assign(data)
+ image_data = ImageData.new(
+ self._js_array, shape[1], shape[0]
+ ) # width, height !
+ size = self.get_logical_size()
+ image_bitmap = run_sync(
+ window.createImageBitmap(
+ image_data,
+ {
+ "resizeQuality": "pixelated",
+ "resizeWidth": int(size[0]),
+ "resizeHeight": int(size[1]),
+ },
+ )
+ )
+ # this actually "writes" the data to the canvas I guess.
+ self.html_context.transferFromImageBitmap(image_bitmap)
+ # handles lower res just fine it seems.
+
+ # maybe if we don't use the existing bitmaprendering context, we could have something like this:
+ # _rc_present_js_array?
+ # _rc_present_js_image_data?
+
+ # TODO: consider switching:
+ def _rc_present_bitmap_2d(self, **kwargs):
+ # still takes a bitmap, but uses the 2d context instead which might be faster
+ if not hasattr(self, "_2d_context"):
+ # will give `null` if other context already exists! so we would need to avoid that above.
+ self._2d_context = self.canvas_element.getContext("2d")
+ print("got 2d context:", self._2d_context)
+ data = kwargs.get("data")
+
+ ## same as above ## (might be extracted to the bitmappresentcontext class one day?)
+ shape = data.shape # use data shape instead of canvas size
+ if (
+ self._js_array.length != shape[0] * shape[1] * 4
+ ): # #assumes rgba-u8 -> 4 bytes per pixel
+ # resize step here? or on first use.
+ self._js_array = Uint8ClampedArray.new(shape[0] * shape[1] * 4)
+ self._js_array.assign(data)
+ image_data = ImageData.new(
+ self._js_array, shape[1], shape[0]
+ ) # width, height !
+ #######
+ # TODO: is not resized because we writing bytes to pixels directly.
+ self._2d_context.putImageData(image_data, 0, 0) # x,y
+
+ def _rc_get_physical_size(self):
+ return self.canvas_element.style.width, self.canvas_element.style.height
+
+ def _rc_get_logical_size(self):
+ return float(self.canvas_element.width), float(self.canvas_element.height)
+
+ def _rc_get_pixel_ratio(self) -> float:
+ ratio = window.devicePixelRatio
+ return ratio
+
+ def _rc_gical_size(self, width: float, height: float):
+ ratio = self._rc_get_pixel_ratio()
+ self.canvas_element.width = int(
+ width * ratio
+ ) # only positive, int() -> floor()
+ self.canvas_element.height = int(height * ratio)
+ # also set the physical scale here?
+ # self.canvas_element.style.width = f"{width}px"
+ # self.canvas_element.style.height = f"{height}px"
+
+ def _rc_close(self):
+ # self.canvas_element.remove() # shouldn't really be needed?
+ pass
+
+ def _rc_get_closed(self):
+ # TODO: like check if the element still exists?
+ return False
+
+ def _rc_set_title(self, title: str):
+ # canvas element doens't have a title directly... but maybe the whole page?
+ document.title = title
+
+ def get_context(self, context_type: str):
+ # hook onto this function so we get the "html_context" (js proxy) representation available...
+ res = super().get_context(context_type)
+ if context_type == "bitmap":
+ self._html_context = self.canvas_element.getContext("bitmaprenderer")
+ elif context_type in ("wgpu", "webgpu"):
+ self._html_context = self.canvas_element.getContext("webgpu")
+ else:
+ raise ValueError(f"Unsupported context_type for html canvas: {context_type}")
+ return res
+
+
+# provide for the auto namespace:
+RenderCanvas = HtmlRenderCanvas