From c81ab5090c646fccb176e50ca4f74a4b15c58e39 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 16 Sep 2025 02:09:45 +0200 Subject: [PATCH 01/61] js bitmap context --- examples/local_browser.html | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 examples/local_browser.html diff --git a/examples/local_browser.html b/examples/local_browser.html new file mode 100644 index 0000000..9127682 --- /dev/null +++ b/examples/local_browser.html @@ -0,0 +1,67 @@ + + + + + + + + + + + + \ No newline at end of file From 393b590e501ebebf728b6fed576c95c4c4f55ab9 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 17 Sep 2025 00:58:34 +0200 Subject: [PATCH 02/61] might need a loop -.- --- examples/html_canvas.py | 128 ++++++++++++++++++++++++++++++++++++ examples/local_browser.html | 52 +++------------ rendercanvas/base.py | 2 +- 3 files changed, 138 insertions(+), 44 deletions(-) create mode 100644 examples/html_canvas.py diff --git a/examples/html_canvas.py b/examples/html_canvas.py new file mode 100644 index 0000000..6908e41 --- /dev/null +++ b/examples/html_canvas.py @@ -0,0 +1,128 @@ +import rendercanvas +print("rendercanvas version:", rendercanvas.__version__) +from rendercanvas.base import BaseRenderCanvas, BaseCanvasGroup, BaseLoop + +from rendercanvas.asyncio import loop + +import logging +import numpy as np + +# packages available inside pyodide +from pyodide.ffi import run_sync +from js import document, ImageData, Uint8ClampedArray, window +# import sys +# assert sys.platform == "emscripten" # use in the future to direct the auto backend? + +logger = logging.getLogger("rendercanvas") +logger.setLevel(logging.DEBUG) +# needed for completeness? somehow is required for other examples - hmm? +class HTMLCanvasGroup(BaseCanvasGroup): + pass + +# TODO: make this a proper RenderCanvas, just a poc for now +# https://rendercanvas.readthedocs.io/stable/backendapi.html#rendercanvas.stub.StubRenderCanvas +class HTMLBitmapCanvas(BaseRenderCanvas): + _rc_canvas_group = HTMLCanvasGroup(loop) # todo do we need the group? + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + canvas_element = document.getElementById("canvas") + self.canvas_element = canvas_element + self.context = canvas_element.getContext("bitmaprenderer") + + self._final_canvas_init() + + 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"], + } + } + + def _rc_request_draw(self): + # loop.call_soon? + # window.requestAnimationFrame(self._draw_frame_and_present) + self._rc_force_draw() + print("request draw called?") + + def _rc_force_draw(self): + self._draw_frame_and_present() + + def _rc_present_bitmap(self): + print("presenting...") + # this actually "writes" the data to the canvas I guess. + self.context.transferFromImageBitmap(self._image_bitmap) + print("presented!") + + 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_set_logical_size(self, width: float, height: float): + return + ratio = self._rc_get_pixel_ratio() + self.canvas_element.width = f"{int(width * ratio)}px" + self.canvas_element.height = f"{int(height * ratio)}px" + # also set the physical scale here? + # self.canvas_element.style.width = f"{width}px" + # self.canvas_element.style.height = f"{height}px" + + def set_bitmap(self, bitmap): + # doesn't really exist? as it's part of the context? maybe we move it into the draw function... + h, w, _ = bitmap.shape + flat_bitmap = bitmap.flatten() + js_array = Uint8ClampedArray.new(flat_bitmap.tolist()) + image_data = ImageData.new(js_array, w, h) + # now this is the fake async call so it should be blocking + self._image_bitmap = run_sync(window.createImageBitmap(image_data)) + + 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 + + # TODO: events + +# TODO event loop for js? https://rendercanvas.readthedocs.io/stable/backendapi.html#rendercanvas.stub.StubLoop +# https://pyodide.org/en/stable/usage/api/python-api/webloop.html +# https://pyodide.org/en/stable/usage/sdl.html#working-with-infinite-loop +# also the asyncio implementation +class HTMLLoop(BaseLoop): + def _rc_init(): + from pyodide.webloop import WebLoop, PyodideFuture, PyodideTask + + + +canvas = HTMLBitmapCanvas(title="RenderCanvas in Pyodide", max_fps=10.0) +def animate(): + # based on the noise.py example + w, h = canvas._rc_get_logical_size() + shape = (int(h), int(w), 4) # third dimension sounds like it's needed + print(shape) + bitmap = np.random.uniform(0, 255, shape).astype(np.uint8) + canvas.set_bitmap(bitmap) + print("bitmap set") + +animate() +# canvas.force_draw() +canvas._rc_force_draw() +canvas._rc_present_bitmap() +# canvas.request_draw(animate) +# loop.run() diff --git a/examples/local_browser.html b/examples/local_browser.html index 9127682..fde745b 100644 --- a/examples/local_browser.html +++ b/examples/local_browser.html @@ -2,54 +2,20 @@ - + RenderCanvas HTML canvas via Pyodide
- + +some text below the canvas! - +
some text below the canvas! -
+
some text below the canvas! + + + ... + + + + + + +Currently only presenting a bitmap is supported, as shown in the examples :doc:`noise.py ` and :doc:`snake.py `. .. _env_vars: From 8b71ed7a5e5e52bef55a41d803c0e9271b7b266a Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 21 Sep 2025 16:05:36 +0200 Subject: [PATCH 14/61] typos pass --- docs/backends.rst | 2 +- docs/conf.py | 4 ++-- docs/contextapi.rst | 4 ++-- docs/start.rst | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/backends.rst b/docs/backends.rst index b8c6e42..b192eb4 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -173,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 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 c0d02d2..f5bbf16 100644 --- a/docs/contextapi.rst +++ b/docs/contextapi.rst @@ -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 :) From f197d7d93b3b6579e314a81c2c238220a34d94e6 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 21 Sep 2025 19:04:32 +0200 Subject: [PATCH 15/61] embed examples into docs --- .github/workflows/ci.yml | 10 +++++++++ docs/static/_pyodide_iframe.html | 35 ++++++++++++++++++++++++++++++++ docs/static/custom.css | 5 +++++ examples/events.py | 13 ++++++++++++ examples/noise.py | 13 ++++++++++++ examples/snake.py | 13 ++++++++++++ 6 files changed, 89 insertions(+) create mode 100644 docs/static/_pyodide_iframe.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e321e6d..2dddc8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,11 +40,21 @@ jobs: docs: name: Docs + needs: [release] runs-on: ubuntu-latest strategy: fail-fast: false steps: - uses: actions/checkout@v4 + - 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/*.whl docs/static/ - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/docs/static/_pyodide_iframe.html b/docs/static/_pyodide_iframe.html new file mode 100644 index 0000000..a483909 --- /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/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 +# +# +# From 529c3eca806cc8e1d6811495ea44c7becb5aa25a Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 21 Sep 2025 19:33:47 +0200 Subject: [PATCH 16/61] maybe fix wheel location --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dddc8b..5b89712 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,8 @@ jobs: - name: move wheel into static run: | mkdir -p docs/static - mv dist/*.whl docs/static/ + mv dist/* docs/static + ls -la docs/static - name: Set up Python uses: actions/setup-python@v5 with: From 7025f874631b401eb3ab3f66def89584b435629a Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 21 Sep 2025 20:07:10 +0200 Subject: [PATCH 17/61] maybe fix files --- .github/workflows/ci.yml | 16 ++++++++-------- docs/static/_pyodide_iframe.html | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b89712..a0d72f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,14 @@ jobs: fail-fast: false steps: - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + 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: @@ -56,14 +64,6 @@ jobs: mkdir -p docs/static mv dist/* docs/static ls -la docs/static - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.12 - - name: Install dev dependencies - run: | - python -m pip install --upgrade pip - pip install -U -e .[docs] - name: Build docs run: | cd docs diff --git a/docs/static/_pyodide_iframe.html b/docs/static/_pyodide_iframe.html index a483909..0b3a02e 100644 --- a/docs/static/_pyodide_iframe.html +++ b/docs/static/_pyodide_iframe.html @@ -9,7 +9,7 @@ + + + + + +
+Left canvas updates when the pointer hovers, right canvas updates when you click! + + + + \ No newline at end of file From 6cf0ba5ed1cf851c4550c022a7eb399ade6e0e6d Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 25 Sep 2025 15:46:21 +0200 Subject: [PATCH 20/61] use asyncio loop --- docs/backends.rst | 2 +- rendercanvas/html.py | 64 +++----------------------------------------- 2 files changed, 4 insertions(+), 62 deletions(-) diff --git a/docs/backends.rst b/docs/backends.rst index b192eb4..4facfaa 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -49,7 +49,7 @@ The table below gives an overview of the names in the different ``rendercanvas`` * - ``html`` - | ``HTMLRenderCanvas`` (toplevel) | ``RenderCanvas`` (alias) - | ``PyodideLoop`` + | ``loop`` (an ``AsyncioLoop``) - | A canvas that runs in a web browser, using Pyodide. diff --git a/rendercanvas/html.py b/rendercanvas/html.py index 57fdce1..fd64372 100644 --- a/rendercanvas/html.py +++ b/rendercanvas/html.py @@ -7,8 +7,8 @@ __all__ = ["HtmlRenderCanvas", "RenderCanvas", "loop"] -from rendercanvas.base import BaseRenderCanvas, BaseCanvasGroup, BaseLoop -import weakref +from rendercanvas.base import BaseRenderCanvas, BaseCanvasGroup +from rendercanvas.asyncio import loop import sys if "pyodide" not in sys.modules: @@ -18,63 +18,6 @@ from pyodide.ffi import run_sync, create_proxy from js import document, ImageData, Uint8ClampedArray, window -# TODO event loop for js? https://rendercanvas.readthedocs.io/stable/backendapi.html#rendercanvas.stub.StubLoop -# https://pyodide.org/en/stable/usage/sdl.html#working-with-infinite-loop -# https://pyodide.org/en/stable/usage/api/python-api/webloop.html -# https://github.com/pyodide/pyodide/blob/0.28.2/src/py/pyodide/webloop.py -# also the asyncio.py implementation -class PyodideLoop(BaseLoop): - def __init__(self): - super().__init__() - self._webloop = None - self.__pending_tasks = [] - self._stop_event = None - - def _rc_init(self): - from pyodide.webloop import WebLoop - self._webloop = WebLoop() - - # TODO later try this - # try: - # self._interactive_loop = self._webloop.get_running_loop() - # self._stop_event = PyodideFuture() - # self._mark_as_interactive() - # except Exception: - # self._interactive_loop = None - self._interactive_loop = None - - def _rc_run(self): - import asyncio #so the .run method is now overwritten I guess - if self._interactive_loop is not None: - return - # self._webloop.run_forever() # or untill stop event? - asyncio.run(self._rc_run_async()) - - async def _rc_run_async(self): - import asyncio - self._run_loop = self._webloop - - while self.__pending_tasks: - self._rc_add_task(*self.__pending_tasks.pop(-1)) - - if self._stop_event is None: - self._stop_event = asyncio.Event() - await self._stop_event.wait() - - # untested maybe... - def _rc_stop_(self): - while self.__tasks: - task = self.__tasks.pop() - task.cancel() - - self._stop_event.set() - self._stop_event = None - self._run_loop = None - - def _rc_call_later(self, delay, callback, *args): - self._webloop.call_later(delay, callback, *args) - -pyodide_loop = PyodideLoop() # needed for completeness? somehow is required for other examples - hmm? class HtmlCanvasGroup(BaseCanvasGroup): @@ -82,7 +25,7 @@ class HtmlCanvasGroup(BaseCanvasGroup): # https://rendercanvas.readthedocs.io/stable/backendapi.html#rendercanvas.stub.StubRenderCanvas class HtmlRenderCanvas(BaseRenderCanvas): - _rc_canvas_group = HtmlCanvasGroup(pyodide_loop) # todo do we need the group? + _rc_canvas_group = HtmlCanvasGroup(loop) # todo do we need the group? def __init__(self, *args, **kwargs): canvas_selector = kwargs.pop("canvas_selector", "canvas") super().__init__(*args, **kwargs) @@ -365,5 +308,4 @@ def _rc_set_title(self, title: str): document.title = title # provide for the auto namespace: -loop = pyodide_loop RenderCanvas = HtmlRenderCanvas From d9f8fc0c8f84f488ed3f8169114b968a373da480 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 25 Sep 2025 15:52:51 +0200 Subject: [PATCH 21/61] ruff format --- rendercanvas/auto.py | 2 + rendercanvas/html.py | 112 ++++++++++++++++++++++++++++++++----------- 2 files changed, 86 insertions(+), 28 deletions(-) diff --git a/rendercanvas/auto.py b/rendercanvas/auto.py index 4783e67..ae16226 100644 --- a/rendercanvas/auto.py +++ b/rendercanvas/auto.py @@ -206,12 +206,14 @@ def backends_by_trying_in_order(): continue 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/html.py b/rendercanvas/html.py index fd64372..e11cfd4 100644 --- a/rendercanvas/html.py +++ b/rendercanvas/html.py @@ -11,6 +11,7 @@ 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.") @@ -23,23 +24,26 @@ 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? + _rc_canvas_group = HtmlCanvasGroup(loop) # todo do we need the group? + def __init__(self, *args, **kwargs): canvas_selector = kwargs.pop("canvas_selector", "canvas") super().__init__(*args, **kwargs) self.canvas_element = document.querySelector(canvas_selector) - self.html_context = self.canvas_element.getContext("bitmaprenderer") # this is part of the canvas, not the context??? + self.html_context = self.canvas_element.getContext( + "bitmaprenderer" + ) # this is part of the canvas, not the context??? self._setup_events() self._js_array = Uint8ClampedArray.new(0) self._final_canvas_init() - 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 = { + key_mod_map = { "altKey": "Alt", "ctrlKey": "Control", "metaKey": "Meta", @@ -51,7 +55,9 @@ def _setup_events(self): # pointer_down def _html_pointer_down(proxy_args): - modifiers = tuple([v for k,v in KEY_MOD_MAP.items() if getattr(proxy_args, k)]) + 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, @@ -64,12 +70,15 @@ def _html_pointer_down(proxy_args): "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)]) + 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, @@ -82,6 +91,7 @@ def _html_pointer_up(proxy_args): "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) @@ -89,7 +99,9 @@ def _html_pointer_up(proxy_args): # TODO: track pointer_inside and pointer_down to only trigger this when relevant? # also figure out why it doesn't work in the first place... def _html_pointer_move(proxy_args): - modifiers = tuple([v for k,v in KEY_MOD_MAP.items() if getattr(proxy_args, k)]) + 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, @@ -102,12 +114,15 @@ def _html_pointer_move(proxy_args): "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)]) + 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, @@ -120,12 +135,15 @@ def _html_pointer_enter(proxy_args): "time_stamp": proxy_args.timeStamp, } self.submit_event(event) + 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)]) + 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, @@ -138,13 +156,16 @@ def _html_pointer_leave(proxy_args): "time_stamp": proxy_args.timeStamp, } self.submit_event(event) + 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)]) + 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, @@ -156,12 +177,15 @@ def _html_double_click(proxy_args): "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)]) + modifiers = tuple( + [v for k, v in key_mod_map.items() if getattr(proxy_args, k)] + ) event = { "event_type": "wheel", "dx": proxy_args.deltaX, @@ -173,12 +197,15 @@ def _html_wheel(proxy_args): "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)]) + modifiers = tuple( + [v for k, v in key_mod_map.items() if getattr(proxy_args, k)] + ) event = { "event_type": "key_down", "modifiers": modifiers, @@ -188,11 +215,15 @@ def _html_key_down(proxy_args): 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? + 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)]) + modifiers = tuple( + [v for k, v in key_mod_map.items() if getattr(proxy_args, k)] + ) event = { "event_type": "key_up", "modifiers": modifiers, @@ -200,22 +231,28 @@ def _html_key_up(proxy_args): "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 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)]) + 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 + "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) + 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. @@ -239,15 +276,28 @@ 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 + 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 ! + 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])})) + 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. @@ -266,16 +316,19 @@ def _rc_present_bitmap_2d(self, **kwargs): 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 + 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 ! + 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 - + 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 @@ -289,7 +342,9 @@ def _rc_get_pixel_ratio(self) -> float: def _rc_set_logical_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.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" @@ -307,5 +362,6 @@ def _rc_set_title(self, title: str): # canvas element doens't have a title directly... but maybe the whole page? document.title = title + # provide for the auto namespace: RenderCanvas = HtmlRenderCanvas From fcd2d1c9a1b7f3656d7cd5ab7534f093c533cd7b Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 25 Sep 2025 16:40:42 +0200 Subject: [PATCH 22/61] add canvas element arg --- examples/multicanvas_browser.html | 35 +++++++++++++++++++++++++++++-- rendercanvas/html.py | 16 ++++++++++---- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/examples/multicanvas_browser.html b/examples/multicanvas_browser.html index 5078203..6b00d75 100644 --- a/examples/multicanvas_browser.html +++ b/examples/multicanvas_browser.html @@ -7,9 +7,10 @@ +
-Left canvas updates when the pointer hovers, right canvas updates when you click! +First canvas updates when the pointer hovers, second canvas changes direction while keypress, third canvas updates when you click! + -
+
some text below the canvas! diff --git a/rendercanvas/html.py b/rendercanvas/html.py index 1231949..230e591 100644 --- a/rendercanvas/html.py +++ b/rendercanvas/html.py @@ -84,7 +84,7 @@ def buttons_mask_to_tuple(mask) -> tuple[int, ...]: 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 @@ -160,9 +160,9 @@ def _html_pointer_up(proxy_args): self.canvas_element.addEventListener("pointerup", self._pointer_up_proxy) # pointer_move - # TODO: track pointer_inside and pointer_down to only trigger this when relevant? - # also figure out why it doesn't work in the first place... 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)] ) @@ -199,6 +199,7 @@ def _html_pointer_enter(proxy_args): "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) @@ -220,6 +221,7 @@ def _html_pointer_leave(proxy_args): "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) @@ -299,24 +301,24 @@ def _html_key_up(proxy_args): self._key_up_proxy = create_proxy(_html_key_up) document.addEventListener("keyup", self._key_up_proxy) - # char - 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) + # 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. From 475943cc7ddf797ed6c2aea27c47532b2773af87 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 28 Oct 2025 09:19:26 +0100 Subject: [PATCH 30/61] some comments and VSCode formatting html --- .github/workflows/ci.yml | 2 +- docs/backends.rst | 6 +- docs/static/_pyodide_iframe.html | 53 +++++++------- docs/static/custom.css | 3 +- examples/local_browser.html | 117 ++++++++++++++++-------------- examples/multicanvas_browser.html | 61 +++++++++------- rendercanvas/html.py | 91 +++++++++++++++-------- 7 files changed, 188 insertions(+), 145 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0d72f8..72902b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ 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 diff --git a/docs/backends.rst b/docs/backends.rst index 4facfaa..c9082e1 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -51,7 +51,7 @@ The table below gives an overview of the names in the different ``rendercanvas`` | ``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: @@ -269,9 +269,10 @@ 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. +When Python is running in the browser using Pyodide, the auto backend selects the ``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 @@ -310,6 +311,7 @@ It expects a HTMLCanvasElement to be present in the DOM. It requires no addition Currently only presenting a bitmap is supported, as shown in the examples :doc:`noise.py ` and :doc:`snake.py `. +Support for wgpu is a work in progress. .. _env_vars: diff --git a/docs/static/_pyodide_iframe.html b/docs/static/_pyodide_iframe.html index 0b3a02e..8042cfe 100644 --- a/docs/static/_pyodide_iframe.html +++ b/docs/static/_pyodide_iframe.html @@ -1,35 +1,38 @@ + - + + -
- - + // Run the Python code async because some calls are async it seems. + pyodide.runPythonAsync(pythonCode); + } + main(); + + \ No newline at end of file diff --git a/docs/static/custom.css b/docs/static/custom.css index 02641fe..aada82c 100644 --- a/docs/static/custom.css +++ b/docs/static/custom.css @@ -2,8 +2,9 @@ div.sphx-glr-download, div.sphx-glr-download-link-note { display: none; } + div.document iframe { width: 100%; - height: 500px; + height: 500px; border: none; } \ No newline at end of file diff --git a/examples/local_browser.html b/examples/local_browser.html index c1862e0..f919fa2 100644 --- a/examples/local_browser.html +++ b/examples/local_browser.html @@ -1,10 +1,11 @@ + - RenderCanvas HTML canvas via Pyodide:
+ RenderCanvas HTML canvas via Pyodide - + - - - - - - -Load WebGPU (experimental) -
- -
-some text below the canvas! - - - let example_file = example_select.value; - pythonCode = await (await fetch(example_file)).text(); - pyodide.runPythonAsync(pythonCode); - }; - } - main(); - +
hey
+ \ No newline at end of file diff --git a/examples/multicanvas_browser.html b/examples/multicanvas_browser.html index 6c6d3cc..6bedf11 100644 --- a/examples/multicanvas_browser.html +++ b/examples/multicanvas_browser.html @@ -1,20 +1,24 @@ + - 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! - - + // Load Pyodide + let pyodide = await loadPyodide(); + + await pyodide.loadPackage("micropip"); + const micropip = pyodide.pyimport("micropip"); + await micropip.install('numpy'); + // await micropip.install('rendercanvas'); + await micropip.install('../dist/rendercanvas-2.2.1-py3-none-any.whl'); // local wheel for auto testing + + // Run the Python code async because some calls are async it seems. + pyodide.runPythonAsync(pythonCode); + } + main(); + + \ No newline at end of file diff --git a/rendercanvas/html.py b/rendercanvas/html.py index 230e591..ee0165a 100644 --- a/rendercanvas/html.py +++ b/rendercanvas/html.py @@ -7,42 +7,52 @@ __all__ = ["HtmlRenderCanvas", "RenderCanvas", "loop"] -from rendercanvas.base import BaseRenderCanvas, BaseCanvasGroup -from rendercanvas.asyncio import loop - import sys +from .base import BaseRenderCanvas, BaseCanvasGroup +from .asyncio import loop + 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 +from js import ( + document, + ImageData, + Uint8ClampedArray, + window, + HTMLCanvasElement, + ResizeObserver, +) -# needed for completeness? somehow is required for other examples - hmm? +# The canvas group manages canvases of the type we define below. In general we don't have to implement anything here. 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? + """An html canvas providing a render canvas.""" + + _rc_canvas_group = HtmlCanvasGroup(loop) def __init__( self, - canvas_el: str = "canvas", + canvas_el: str | object = "canvas", *args, **kwargs, ): if isinstance(canvas_el, str): + # todo: make private attr 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) @@ -53,7 +63,6 @@ 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 @@ -67,12 +76,12 @@ def _setup_events(self): # 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 + -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 @@ -84,24 +93,32 @@ def buttons_mask_to_tuple(mask) -> tuple[int, ...]: res += (mouse_button_map.get(i, i),) return res - self._pointer_inside = False # keep track for the pointer_move event + 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? + 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) + 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) + 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)) + new_size = (int(lsize[0] * ratio), int(lsize[1] * ratio)) event = { "width": new_size[0], @@ -161,7 +178,9 @@ def _html_pointer_up(proxy_args): # 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 + 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)] @@ -323,29 +342,34 @@ def _html_key_up(proxy_args): # 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 + 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): - # in the future maybe we can get the webgpu context (as JsProxy) or something... future stuff! + # TODO: provide access to wgpu context + # TODO: that window id does not make sense return { "bitmap": { "formats": ["rgba-u8"], }, "screen": { - "formats": ["rgba-u8"], - "window": self.canvas_element.js_id, #is a number - doubt it's useful though... - } + "platform": "pyodide", + "window": self.canvas_element.js_id, # is a number - doubt it's useful though... + }, } def _rc_request_draw(self): + # todo: use request animation frame!! loop = self._rc_canvas_group.get_loop() loop.call_soon(self._draw_frame_and_present) def _rc_force_draw(self): + # Not very clean to do this, and not sure if it works in a browser; + # you can draw all you want, but the browser compositer only uses the last frame, I expect. + # But that's ok, since force-drawing is not recomended in general. self._draw_frame_and_present() def _rc_present_bitmap(self, **kwargs): + # TODO: canvases actually support a context that is very similar to our bitmap context data = kwargs.get("data") # data is a memoryview shape = data.shape # use data shape instead of canvas size if ( @@ -410,7 +434,7 @@ def _rc_get_pixel_ratio(self) -> float: ratio = window.devicePixelRatio return ratio - def _rc_gical_size(self, width: float, height: float): + def _xxrc_set_logical_size(self, width: float, height: float): ratio = self._rc_get_pixel_ratio() self.canvas_element.width = int( width * ratio @@ -440,9 +464,12 @@ def get_context(self, context_type: str): 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}") + raise ValueError( + f"Unsupported context_type for html canvas: {context_type}" + ) return res -# provide for the auto namespace: +# Make available under a name that is the same for all backends +loop = loop # must set loop variable to pass meta tests RenderCanvas = HtmlRenderCanvas From 485088a230810a06f11d866d6290d088c399c057 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 28 Oct 2025 12:21:51 +0100 Subject: [PATCH 31/61] script to serve examples --- examples/serve_examples_with_pyscript.py | 146 +++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 examples/serve_examples_with_pyscript.py diff --git a/examples/serve_examples_with_pyscript.py b/examples/serve_examples_with_pyscript.py new file mode 100644 index 0000000..03dbec3 --- /dev/null +++ b/examples/serve_examples_with_pyscript.py @@ -0,0 +1,146 @@ +""" +Little script to make it serve a selection of examples as PyScript applications. +""" + +import os +import sys +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer + +import rendercanvas + +try: + from build.__main__ import main as build_main +except ImportError: + msg = "This script needs the 'build' package. Get it with `pip install build` or similar." + raise ImportError(msg) from None + + +available_examples = [ + "drag.html", + "noise.html", + "events.html", + "demo.html", +] + + +example_list_items = [f"
  • {name}
  • " for name in available_examples] +html_index = f""" + + + + + RenderCanvas PyScript examples + + + +List of examples that run in PyScript: +
      {''.join(example_list_items)}
    + + +""" + +html_template = """ + + + + + example.py via PyScript + + + + + Back to list

    + + +

    Loading...

    +
    + + + + + + + +""" + +root = os.path.abspath(os.path.join(__file__, "..", "..")) + +short_version = ".".join(str(i) for i in rendercanvas.version_info[:3]) +wheel_name = f"rendercanvas-{short_version}-py3-none-any.whl" +# todo: dont hardcode version in html example + +if not ( + os.path.isfile(os.path.join(root, "rendercanvas", "__init__.py")) + and os.path.isfile(os.path.join(root, "pyproject.toml")) +): + raise RuntimeError("This script must run in a checkout repo of rendercanvas.") + + +def build_wheel(): + # pip.main(["wheel", "-w", os.path.join(root, "dist"), root]) + build_main(["-n", "-w", root]) + wheel_filename = os.path.join(root, "dist", wheel_name) + assert os.path.isfile(wheel_filename), f"{wheel_name} does not exist" + + +class MyHandler(BaseHTTPRequestHandler): + def do_GET(self): # noqa + if self.path == "/": + self.respond(200, html_index, "text/html") + elif self.path.endswith(".whl"): + filename = os.path.join(root, "dist", self.path.strip("/")) + if os.path.isfile(filename): + with open(filename, "rb") as f: + data = f.read() + self.respond(200, data, "application/octet-stream") + else: + self.respond(404, "wheel not found") + elif self.path.endswith(".html"): + name = self.path.strip("/") + if name in available_examples: + pyname = name.replace(".html", ".py") + html = html_template.replace("example.py", pyname) + html = html.replace('"rendercanvas"', f'"./{wheel_name}"') + self.respond(200, html, "text/html") + else: + self.respond(404, "example not found") + elif self.path.endswith(".py"): + name = self.path.strip("/") + filename = os.path.join(root, "examples", name) + if os.path.isfile(filename): + with open(filename, "rb") as f: + data = f.read() + self.respond(200, data, "text/plain") + else: + self.respond(404, "py file not found") + else: + self.respond(404, "not found") + + def respond(self, code, body, content_type="text/plain"): + self.send_response(code) + self.send_header("Content-type", content_type) + self.end_headers() + if isinstance(body, str): + body = body.encode() + self.wfile.write(body) + + +if __name__ == "__main__": + port = 8000 + if len(sys.argv) > 1: + try: + port = int(sys.argv[-1]) + except ValueError: + pass + + build_wheel() + print("Opening page in web browser ...") + webbrowser.open(f"http://localhost:{port}/") + HTTPServer(("", port), MyHandler).serve_forever() From 2584ecb58546a81c35a22651c3666d48922caec8 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 28 Oct 2025 13:01:05 +0100 Subject: [PATCH 32/61] Add simple multi-canvas pyodide example --- examples/pyodide.html | 74 ++++++++++++++++++++++++ examples/serve_examples_with_pyscript.py | 71 ++++++++++++++++------- 2 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 examples/pyodide.html diff --git a/examples/pyodide.html b/examples/pyodide.html new file mode 100644 index 0000000..ccf3e71 --- /dev/null +++ b/examples/pyodide.html @@ -0,0 +1,74 @@ + + + + + + RenderCanvas Pyodide example + + + + +

    + This example demonstrates using Pyoide directly. It's a bit more involved that PyScript, + but it shows more directly what happens. Further, this example shows how to deal with + multiple canvases on the same page; the first is made red, the second is made green. + Both canvases are also resized from the Python code. +

    + + + +

    + + + + + \ No newline at end of file diff --git a/examples/serve_examples_with_pyscript.py b/examples/serve_examples_with_pyscript.py index 03dbec3..72fde90 100644 --- a/examples/serve_examples_with_pyscript.py +++ b/examples/serve_examples_with_pyscript.py @@ -1,5 +1,12 @@ """ -Little script to make it serve a selection of examples as PyScript applications. +Little script that: + +* Builds the wheel. +* Start a tiny webserver to host html files for a selection of examples. +* Opens a webpage in the default browser. + +Files are loaded from disk on each request, so you can leave the server running +and just update examples, update rendercanvas and build the wheel, etc. """ import os @@ -16,29 +23,48 @@ raise ImportError(msg) from None -available_examples = [ +# Examples to load as PyScript application +py_examples = [ "drag.html", "noise.html", "events.html", "demo.html", ] +# Examples that are already html +html_examples = [ + "pyodide.html", +] -example_list_items = [f"
  • {name}
  • " for name in available_examples] -html_index = f""" - - - - - RenderCanvas PyScript examples - - - -List of examples that run in PyScript: -
      {''.join(example_list_items)}
    - - -""" + +def get_html_index(): + py_examples_list = [ + f"
  • {name}
  • " for name in py_examples + ] + html_examples_list = [ + f"
  • {name}
  • " for name in html_examples + ] + + html = f""" + + + + RenderCanvas PyScript examples + + + + """ + + html += "List of .py examples that run in PyScript:\n" + html += f"
      {"".join(py_examples_list)}


    \n\n" + + html += "List of .html examples:\n" + html += f"
      {"".join(html_examples_list)}


    \n\n" + + html += "\n\n" + return html + +html_index = get_html_index() html_template = """ @@ -104,16 +130,21 @@ def do_GET(self): # noqa self.respond(404, "wheel not found") elif self.path.endswith(".html"): name = self.path.strip("/") - if name in available_examples: + if name in py_examples: pyname = name.replace(".html", ".py") html = html_template.replace("example.py", pyname) html = html.replace('"rendercanvas"', f'"./{wheel_name}"') self.respond(200, html, "text/html") + elif name in html_examples: + filename = os.path.join(root, "examples", name) + with open(filename, "rb") as f: + html = f.read().decode() + html = html.replace('"rendercanvas"', f'"./{wheel_name}"') + self.respond(200, html, "text/html") else: self.respond(404, "example not found") elif self.path.endswith(".py"): - name = self.path.strip("/") - filename = os.path.join(root, "examples", name) + filename = os.path.join(root, "examples", self.path.strip("/")) if os.path.isfile(filename): with open(filename, "rb") as f: data = f.read() From dd8545f03d4ce158dd9323b5908b845012182b8a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 28 Oct 2025 15:37:06 +0100 Subject: [PATCH 33/61] Check incoming canvas element, and use getElementById --- examples/pyodide.html | 6 +++--- examples/serve_examples_with_pyscript.py | 21 +++++++++++++++------ rendercanvas/html.py | 23 +++++++++++++++++------ 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/examples/pyodide.html b/examples/pyodide.html index ccf3e71..ad34400 100644 --- a/examples/pyodide.html +++ b/examples/pyodide.html @@ -9,7 +9,7 @@

    - This example demonstrates using Pyoide directly. It's a bit more involved that PyScript, + This example demonstrates using Pyodide directly. It's a bit more involved that PyScript, but it shows more directly what happens. Further, this example shows how to deal with multiple canvases on the same page; the first is made red, the second is made green. Both canvases are also resized from the Python code. @@ -27,11 +27,11 @@ from js import document # Select by name - canvas_red = RenderCanvas(canvas_el="#canvas1", size=(320, 240), update_mode="ondemand") + canvas_red = RenderCanvas(canvas_element="canvas1", size=(320, 240), update_mode="ondemand") # Or by element canvas2_el = document.getElementById("canvas2") - canvas_green = RenderCanvas(canvas_el=canvas2_el, size=(320, 240), update_mode="continuous") + canvas_green = RenderCanvas(canvas_element=canvas2_el, size=(320, 240), update_mode="continuous") context_red = canvas_red.get_context("bitmap") context_green = canvas_green.get_context("bitmap") diff --git a/examples/serve_examples_with_pyscript.py b/examples/serve_examples_with_pyscript.py index 72fde90..424a116 100644 --- a/examples/serve_examples_with_pyscript.py +++ b/examples/serve_examples_with_pyscript.py @@ -38,9 +38,7 @@ def get_html_index(): - py_examples_list = [ - f"

  • {name}
  • " for name in py_examples - ] + py_examples_list = [f"
  • {name}
  • " for name in py_examples] html_examples_list = [ f"
  • {name}
  • " for name in html_examples ] @@ -53,17 +51,20 @@ def get_html_index(): + + Rebuild the wheel

    """ html += "List of .py examples that run in PyScript:\n" - html += f"
      {"".join(py_examples_list)}


    \n\n" + html += f"
      {''.join(py_examples_list)}

    \n\n" html += "List of .html examples:\n" - html += f"
      {"".join(html_examples_list)}


    \n\n" + html += f"
      {''.join(html_examples_list)}

    \n\n" html += "\n\n" return html + html_index = get_html_index() html_template = """ @@ -87,7 +88,7 @@ def get_html_index(): loading.showModal(); - + @@ -120,6 +121,14 @@ class MyHandler(BaseHTTPRequestHandler): def do_GET(self): # noqa if self.path == "/": self.respond(200, html_index, "text/html") + elif self.path == "/build": + try: + build_wheel() + except Exception as err: + self.respond(500, str(err), "text/plain") + else: + html = f"Wheel build: {wheel_name}

    Back to list" + self.respond(200, html, "text/html") elif self.path.endswith(".whl"): filename = os.path.join(root, "dist", self.path.strip("/")) if os.path.isfile(filename): diff --git a/rendercanvas/html.py b/rendercanvas/html.py index ee0165a..b21ec8b 100644 --- a/rendercanvas/html.py +++ b/rendercanvas/html.py @@ -38,15 +38,26 @@ class HtmlRenderCanvas(BaseRenderCanvas): def __init__( self, - canvas_el: str | object = "canvas", + canvas_element: str = "rendercanvas", *args, **kwargs, ): - if isinstance(canvas_el, str): - # todo: make private attr - self.canvas_element = document.querySelector(canvas_el) - else: - self.canvas_element = canvas_el + # Resolve and check the canvas element + # todo: make canvas_element a private attr + canvas_id = None + if isinstance(canvas_element, str): + canvas_id = canvas_element + canvas_element = document.getElementById(canvas_id) + if not ( + hasattr(canvas_element, "tagName") and canvas_element.tagName == "CANVAS" + ): + repr = f"{canvas_element!r}" + if canvas_id: + repr = f"{canvas_id!r} -> " + repr + raise TypeError( + f"Given canvas element does not look like a : {repr}" + ) + self.canvas_element = canvas_element if "size" not in kwargs: # if size isn't given, we use the existing size. From 740993aafeacdd41806d4b7e527b3cdcd2b25084 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 28 Oct 2025 15:37:44 +0100 Subject: [PATCH 34/61] make variable private --- rendercanvas/html.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/rendercanvas/html.py b/rendercanvas/html.py index b21ec8b..49de586 100644 --- a/rendercanvas/html.py +++ b/rendercanvas/html.py @@ -57,7 +57,7 @@ def __init__( raise TypeError( f"Given canvas element does not look like a : {repr}" ) - self.canvas_element = canvas_element + self._canvas_element = canvas_element if "size" not in kwargs: # if size isn't given, we use the existing size. @@ -141,7 +141,7 @@ def _resize_callback(entries, observer): self._resize_callback_proxy = create_proxy(_resize_callback) self._resize_observer = ResizeObserver.new(self._resize_callback_proxy) - self._resize_observer.observe(self.canvas_element) + self._resize_observer.observe(self._canvas_element) # close ? perhaps https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent @@ -164,7 +164,7 @@ def _html_pointer_down(proxy_args): self.submit_event(event) self._pointer_down_proxy = create_proxy(_html_pointer_down) - self.canvas_element.addEventListener("pointerdown", self._pointer_down_proxy) + self._canvas_element.addEventListener("pointerdown", self._pointer_down_proxy) # pointer_up def _html_pointer_up(proxy_args): @@ -185,7 +185,7 @@ def _html_pointer_up(proxy_args): self.submit_event(event) self._pointer_up_proxy = create_proxy(_html_pointer_up) - self.canvas_element.addEventListener("pointerup", self._pointer_up_proxy) + self._canvas_element.addEventListener("pointerup", self._pointer_up_proxy) # pointer_move def _html_pointer_move(proxy_args): @@ -232,7 +232,7 @@ def _html_pointer_enter(proxy_args): self._pointer_inside = True self._pointer_enter_proxy = create_proxy(_html_pointer_enter) - self.canvas_element.addEventListener("pointerenter", self._pointer_enter_proxy) + self._canvas_element.addEventListener("pointerenter", self._pointer_enter_proxy) # pointer_leave def _html_pointer_leave(proxy_args): @@ -254,7 +254,7 @@ def _html_pointer_leave(proxy_args): self._pointer_inside = False self._pointer_leave_proxy = create_proxy(_html_pointer_leave) - self.canvas_element.addEventListener("pointerleave", self._pointer_leave_proxy) + self._canvas_element.addEventListener("pointerleave", self._pointer_leave_proxy) # TODO: can all the above be refactored into a function consturctor/factory? # double_click @@ -275,7 +275,7 @@ def _html_double_click(proxy_args): self.submit_event(event) self._double_click_proxy = create_proxy(_html_double_click) - self.canvas_element.addEventListener("dblclick", self._double_click_proxy) + self._canvas_element.addEventListener("dblclick", self._double_click_proxy) # wheel def _html_wheel(proxy_args): @@ -295,7 +295,7 @@ def _html_wheel(proxy_args): self.submit_event(event) self._wheel_proxy = create_proxy(_html_wheel) - self.canvas_element.addEventListener("wheel", self._wheel_proxy) + self._canvas_element.addEventListener("wheel", self._wheel_proxy) # key_down def _html_key_down(proxy_args): @@ -364,7 +364,7 @@ def _rc_get_present_methods(self): }, "screen": { "platform": "pyodide", - "window": self.canvas_element.js_id, # is a number - doubt it's useful though... + "window": self._canvas_element.js_id, # is a number - doubt it's useful though... }, } @@ -416,7 +416,7 @@ 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") + self._2d_context = self._canvas_element.getContext("2d") print("got 2d context:", self._2d_context) data = kwargs.get("data") @@ -436,10 +436,10 @@ def _rc_present_bitmap_2d(self, **kwargs): 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 + 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) + return float(self._canvas_element.width), float(self._canvas_element.height) def _rc_get_pixel_ratio(self) -> float: ratio = window.devicePixelRatio @@ -447,16 +447,16 @@ def _rc_get_pixel_ratio(self) -> float: def _xxrc_set_logical_size(self, width: float, height: float): ratio = self._rc_get_pixel_ratio() - self.canvas_element.width = int( + self._canvas_element.width = int( width * ratio ) # only positive, int() -> floor() - self.canvas_element.height = int(height * ratio) + 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" + # 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? + # self._canvas_element.remove() # shouldn't really be needed? pass def _rc_get_closed(self): @@ -471,9 +471,9 @@ 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") + self._html_context = self._canvas_element.getContext("bitmaprenderer") elif context_type in ("wgpu", "webgpu"): - self._html_context = self.canvas_element.getContext("webgpu") + self._html_context = self._canvas_element.getContext("webgpu") else: raise ValueError( f"Unsupported context_type for html canvas: {context_type}" From 05d5b6e45423ba284e93c0187b71a69b1cf15e28 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 28 Oct 2025 15:56:43 +0100 Subject: [PATCH 35/61] Add pyscript example --- examples/pyscript.html | 38 +++++++++++++++++++ ...ples_with_pyscript.py => serve_pyodide.py} | 0 2 files changed, 38 insertions(+) create mode 100644 examples/pyscript.html rename examples/{serve_examples_with_pyscript.py => serve_pyodide.py} (100%) diff --git a/examples/pyscript.html b/examples/pyscript.html new file mode 100644 index 0000000..cb485a1 --- /dev/null +++ b/examples/pyscript.html @@ -0,0 +1,38 @@ + + + + + + + RenderCanvas PyScript example + + + + + + +

    Loading...

    +
    + + +

    + This example demonstrates using PyScript. It needs to be acced through + a web-server for it to access the Python file. +

    +

    + This uses the latest release from rendercanvas. To run this with the dev version, + run the serve_pyodide.py script and select the example from there. +

    + + +
    + + + + \ No newline at end of file diff --git a/examples/serve_examples_with_pyscript.py b/examples/serve_pyodide.py similarity index 100% rename from examples/serve_examples_with_pyscript.py rename to examples/serve_pyodide.py From 69d238126c31a4de0a4d72e381b0c022668be6f5 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 28 Oct 2025 15:57:09 +0100 Subject: [PATCH 36/61] Add pyscript example --- examples/serve_pyodide.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/serve_pyodide.py b/examples/serve_pyodide.py index 424a116..d907f8e 100644 --- a/examples/serve_pyodide.py +++ b/examples/serve_pyodide.py @@ -32,9 +32,7 @@ ] # Examples that are already html -html_examples = [ - "pyodide.html", -] +html_examples = ["pyodide.html", "pyscript.html"] def get_html_index(): @@ -67,6 +65,7 @@ def get_html_index(): html_index = get_html_index() + html_template = """ From c2a313f5367b16e4733a537776d9524742e14944 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 29 Oct 2025 12:52:21 +0100 Subject: [PATCH 37/61] Flesh out the drawing mechanism --- docs/backends.rst | 2 +- docs/static/_pyodide_iframe.html | 2 +- examples/local_browser.html | 6 +- examples/multicanvas_browser.html | 2 +- examples/serve_pyodide.py | 4 +- rendercanvas/html.py | 121 ++++++++++++++++-------------- 6 files changed, 74 insertions(+), 63 deletions(-) diff --git a/docs/backends.rst b/docs/backends.rst index c9082e1..5e0d1e5 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -280,7 +280,7 @@ It expects a HTMLCanvasElement to be present in the DOM. It requires no addition - + ... diff --git a/docs/static/_pyodide_iframe.html b/docs/static/_pyodide_iframe.html index 8042cfe..3948398 100644 --- a/docs/static/_pyodide_iframe.html +++ b/docs/static/_pyodide_iframe.html @@ -3,7 +3,7 @@ - + diff --git a/examples/local_browser.html b/examples/local_browser.html index f919fa2..09929f1 100644 --- a/examples/local_browser.html +++ b/examples/local_browser.html @@ -4,10 +4,10 @@ RenderCanvas HTML canvas via Pyodide - + - - - - - - - - - - Load WebGPU (experimental) -
    - -
    - some text below the canvas! - - - -
    hey
    - - - \ No newline at end of file diff --git a/examples/multicanvas_browser.html b/examples/multicanvas_browser.html deleted file mode 100644 index d10049c..0000000 --- a/examples/multicanvas_browser.html +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - - - - -
    - First canvas updates when the pointer hovers, second canvas changes direction while keypress, third canvas updates - when you click! - - TODO: How to distinguish multiple canvas elements? - - - - - \ No newline at end of file From 24b353b6671ecb699bbad885e1586edf61c8c631 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 30 Oct 2025 12:39:39 +0100 Subject: [PATCH 49/61] doc-build also builds wheel --- .github/workflows/ci.yml | 11 ----------- .gitignore | 2 ++ docs/conf.py | 32 ++++++++++++++++++++++++++++++-- docs/static/_pyodide_iframe.html | 4 ++-- examples/events.py | 2 +- examples/noise.py | 2 +- examples/snake.py | 2 +- 7 files changed, 37 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72902b1..e321e6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,6 @@ jobs: docs: name: Docs - needs: [release] runs-on: ubuntu-latest strategy: fail-fast: false @@ -54,16 +53,6 @@ jobs: 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/.gitignore b/.gitignore index 3503076..fe8c8a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Special for this repo nogit/ docs/gallery/ +docs/static/*_whl.html +docs/static/*.whl docs/sg_execution_times.rst examples/screenshots/ diff --git a/docs/conf.py b/docs/conf.py index ec632a2..7143a15 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,7 +12,7 @@ import os import sys - +import shutil ROOT_DIR = os.path.abspath(os.path.join(__file__, "..", "..")) sys.path.insert(0, ROOT_DIR) @@ -20,7 +20,9 @@ os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] = "true" -# Load wglibu so autodoc can query docstrings +from build.__main__ import main as build_main + +# Load wgpu so autodoc can query docstrings import rendercanvas # noqa: E402 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 @@ -64,6 +66,32 @@ master_doc = "index" +# -- Build wheel so PyScript examples can use exactly this version of rendercanvas ----------------------------------------------------- + +short_version = ".".join(str(i) for i in rendercanvas.version_info[:3]) +wheel_name = f"rendercanvas-{short_version}-py3-none-any.whl" + +# Build the wheel +build_main(["-n", "-w", ROOT_DIR]) +wheel_filename = os.path.join(ROOT_DIR, "dist", wheel_name) +assert os.path.isfile(wheel_filename), f"{wheel_name} does not exist" + +# Copy into static +shutil.copy( + wheel_filename, + os.path.join(ROOT_DIR, "docs", "static", wheel_name), +) + +# Make a copy of the template file that uses the current rendercanvas wheel +template_file1 = os.path.join(ROOT_DIR, "docs", "static", "_pyodide_iframe.html") +template_file2 = os.path.join(ROOT_DIR, "docs", "static", "_pyodide_iframe_whl.html") +with open(template_file1, "rb") as f: + html = f.read().decode() +html = html.replace('"rendercanvas"', f'"../_static/{wheel_name}"') +with open(template_file2, "wb") as f: + f.write(html.encode()) + + # -- Sphinx Gallery ----------------------------------------------------- # Suppress "cannot cache unpickable configuration value" for sphinx_gallery_conf diff --git a/docs/static/_pyodide_iframe.html b/docs/static/_pyodide_iframe.html index 3948398..55ed223 100644 --- a/docs/static/_pyodide_iframe.html +++ b/docs/static/_pyodide_iframe.html @@ -7,7 +7,7 @@ -
    +
    diff --git a/examples/events.py b/examples/events.py index 68ba7c4..262aad5 100644 --- a/examples/events.py +++ b/examples/events.py @@ -19,16 +19,3 @@ 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/noise.py b/examples/noise.py index 6e71b24..e97df57 100644 --- a/examples/noise.py +++ b/examples/noise.py @@ -25,16 +25,3 @@ 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 fd12627..d7d9e8e 100644 --- a/examples/snake.py +++ b/examples/snake.py @@ -64,16 +64,3 @@ 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 -# -# -# From 34e4a35f8ec78ed53657e210344c501a257287c5 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 30 Oct 2025 16:43:35 +0100 Subject: [PATCH 51/61] clean --- examples/serve_browser_examples.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/serve_browser_examples.py b/examples/serve_browser_examples.py index b38dad6..5b13b23 100644 --- a/examples/serve_browser_examples.py +++ b/examples/serve_browser_examples.py @@ -47,7 +47,6 @@ short_version = ".".join(str(i) for i in rendercanvas.version_info[:3]) wheel_name = f"rendercanvas-{short_version}-py3-none-any.whl" -# todo: dont hardcode version in html example def get_html_index(): From 0af1ad0cea4ebfda36b9f84ebb0c8b5f8f6e4f81 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 30 Oct 2025 16:46:36 +0100 Subject: [PATCH 52/61] add build to doc and example deps --- pyproject.toml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ecab45a..2363e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,15 @@ jupyter = ["jupyter_rfb>=0.4.2"] glfw = ["glfw>=1.9"] # For devs / ci lint = ["ruff", "pre-commit"] -examples = ["numpy", "wgpu", "glfw", "pyside6", "imageio", "pytest"] -docs = ["sphinx>7.2", "sphinx_rtd_theme", "sphinx-gallery", "numpy", "wgpu"] +examples = ["build", "numpy", "wgpu", "glfw", "pyside6", "imageio", "pytest"] +docs = [ + "build", + "sphinx>7.2", + "sphinx_rtd_theme", + "sphinx-gallery", + "numpy", + "wgpu", +] tests = ["pytest", "numpy", "wgpu", "glfw", "trio"] dev = ["rendercanvas[lint,tests,examples,docs]"] From 702a0a48465dde9ee3c662728971a9e6adef8953 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 30 Oct 2025 23:02:26 +0100 Subject: [PATCH 53/61] add tests for html backend --- tests/test_backends.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_backends.py b/tests/test_backends.py index 3892ab8..636bb01 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -248,6 +248,14 @@ def test_glfw_module(): assert m.names["loop"] +def test_html_module(): + m = Module("html") + + canvas_class = m.get_canvas_class() + m.check_canvas(canvas_class) + assert canvas_class.name == "HtmlRenderCanvas" + + def test_jupyter_module(): m = Module("jupyter") From b4f6d003aeef7f675fba71f338537cefc0a71b4c Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 30 Oct 2025 23:02:36 +0100 Subject: [PATCH 54/61] Fixes for setting size --- rendercanvas/html.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rendercanvas/html.py b/rendercanvas/html.py index 71d622a..904799d 100644 --- a/rendercanvas/html.py +++ b/rendercanvas/html.py @@ -179,7 +179,7 @@ def _resize_callback(entries, _=None): # If the element does not set the size with its style, the canvas' width and height are used. # On hidpi screens this'd cause the canvas size to quickly increase with factors of 2 :) # Therefore we want to make sure that the style.width and style.height are set. - lsize = ratio * psize[0], ratio * psize[1] + lsize = psize[0] / ratio, psize[1] / ratio if not el.style.width: el.style.width = f"{lsize[0]}px" if not el.style.height: @@ -480,7 +480,7 @@ def _rc_present_bitmap(self, **kwargs): self._offscreen_canvas, 0, 0, cw, ch ) - def _set_logical_size(self, width: float, height: float): + def _rc_set_logical_size(self, width: float, height: float): self._canvas_element.style.width = f"{width}px" self._canvas_element.style.height = f"{height}px" From 27f45aa26d471b092c666456753de0ffd08910d6 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 30 Oct 2025 23:26:38 +0100 Subject: [PATCH 55/61] Use flit to build wheels --- .github/workflows/ci.yml | 6 +++--- docs/conf.py | 6 ++++-- examples/serve_browser_examples.py | 11 +++-------- pyproject.toml | 4 ++-- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e321e6d..283be0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,13 +164,13 @@ jobs: - name: Install dev dependencies run: | python -m pip install --upgrade pip - pip install -U flit build twine + pip install -U flit twine - name: Create source distribution run: | - python -m build -n -s + python -m flit build --no-use-vcs --format sdist - name: Build wheel run: | - python -m build -n -w + python -m flit build --no-use-vcs --format wheel - name: Test sdist shell: bash run: | diff --git a/docs/conf.py b/docs/conf.py index edc43e8..ccf75bc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,7 +14,7 @@ import sys import shutil -from build.__main__ import main as build_main +import flit ROOT_DIR = os.path.abspath(os.path.join(__file__, "..", "..")) sys.path.insert(0, ROOT_DIR) @@ -72,11 +72,13 @@ wheel_name = f"rendercanvas-{short_version}-py3-none-any.whl" # Build the wheel -build_main(["-n", "-w", ROOT_DIR]) +toml_filename = os.path.join(ROOT_DIR, "pyproject.toml") +flit.main(["-f", toml_filename, "build", "--no-use-vcs", "--format", "wheel"]) wheel_filename = os.path.join(ROOT_DIR, "dist", wheel_name) assert os.path.isfile(wheel_filename), f"{wheel_name} does not exist" # Copy into static +print("Copy wheel to static dir") shutil.copy( wheel_filename, os.path.join(ROOT_DIR, "docs", "static", wheel_name), diff --git a/examples/serve_browser_examples.py b/examples/serve_browser_examples.py index 5b13b23..f4dc47a 100644 --- a/examples/serve_browser_examples.py +++ b/examples/serve_browser_examples.py @@ -20,14 +20,9 @@ import webbrowser from http.server import BaseHTTPRequestHandler, HTTPServer +import flit import rendercanvas -try: - from build.__main__ import main as build_main -except ImportError: - msg = "This script needs the 'build' package. Get it with `pip install build` or similar." - raise ImportError(msg) from None - # Examples to load as PyScript application py_examples = [ @@ -122,8 +117,8 @@ def get_html_index(): def build_wheel(): - # pip.main(["wheel", "-w", os.path.join(root, "dist"), root]) - build_main(["-n", "-w", root]) + toml_filename = os.path.join(root, "pyproject.toml") + flit.main(["-f", toml_filename, "build", "--no-use-vcs", "--format", "wheel"]) wheel_filename = os.path.join(root, "dist", wheel_name) assert os.path.isfile(wheel_filename), f"{wheel_name} does not exist" diff --git a/pyproject.toml b/pyproject.toml index 2363e63..dc3a7e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,9 +25,9 @@ jupyter = ["jupyter_rfb>=0.4.2"] glfw = ["glfw>=1.9"] # For devs / ci lint = ["ruff", "pre-commit"] -examples = ["build", "numpy", "wgpu", "glfw", "pyside6", "imageio", "pytest"] +examples = ["flit", "numpy", "wgpu", "glfw", "pyside6", "imageio", "pytest"] docs = [ - "build", + "flit", "sphinx>7.2", "sphinx_rtd_theme", "sphinx-gallery", From 03ca0c024a378adfbeecca5f19886b71e78f4ccc Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 31 Oct 2025 08:57:51 +0100 Subject: [PATCH 56/61] fix sphinx, i think --- docs/conf.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index ccf75bc..8737a29 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -105,6 +105,9 @@ def add_pyodide_to_examples(app): + if app.builder.name != "html": + return + gallery_dir = os.path.join(ROOT_DIR, "docs", "gallery") for fname in os.listdir(gallery_dir): @@ -125,6 +128,9 @@ def add_pyodide_to_examples(app): def add_files_to_run_pyodide_examples(app, exception): + if app.builder.name != "html": + return + gallery_build_dir = os.path.join(ROOT_DIR, "docs", "_build", "html", "gallery") # Write html file that can load pyodide examples From fc5d5daf66d714c7bbe91df4360c77e54d1ebf08 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 31 Oct 2025 09:30:18 +0100 Subject: [PATCH 57/61] Rename html backend -> pyodide backend --- docs/backends.rst | 67 +++++++++++++---------- docs/static/_pyodide_iframe.html | 3 +- examples/pyodide.html | 2 +- rendercanvas/auto.py | 11 ++-- rendercanvas/{html.py => pyodide.py} | 79 ++++++++++++++-------------- tests/test_backends.py | 6 +-- 6 files changed, 91 insertions(+), 77 deletions(-) rename rendercanvas/{html.py => pyodide.py} (89%) diff --git a/docs/backends.rst b/docs/backends.rst index 5e0d1e5..f0fb677 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -46,11 +46,11 @@ 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) + * - ``pyodide`` + - | ``PyodideRenderCanvas`` (toplevel) | ``RenderCanvas`` (alias) | ``loop`` (an ``AsyncioLoop``) - - | A canvas that runs in a web browser, using Pyodide. + - | Backend for Python running in the browser (via Pyodide). There are also three loop-backends. These are mainly intended for use with the glfw backend: @@ -272,40 +272,49 @@ subclass implementing a remote frame-buffer. There are also some `wgpu examples Support for HTMLCanvas in Pyodide --------------------------------- -When Python is running in the browser using Pyodide, the auto backend selects the ``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. + +When Python is running in the browser using Pyodide, the auto backend selects +the ``rendercanvas.pyodide.PyodideRenderCanvas`` class. This backend requires no +additional dependencies. It expects a HTMLCanvasElement to be present in the +DOM. By default it connects to the canvas with id "rendercanvas", but a +different id or element can also be provided. .. code-block:: html + - ... - - + ... + + @@ -313,6 +322,8 @@ It expects a HTMLCanvasElement to be present in the DOM. It requires no addition Currently only presenting a bitmap is supported, as shown in the examples :doc:`noise.py ` and :doc:`snake.py `. Support for wgpu is a work in progress. +TODO also mention pyscript + .. _env_vars: diff --git a/docs/static/_pyodide_iframe.html b/docs/static/_pyodide_iframe.html index e4528d6..1fd2b28 100644 --- a/docs/static/_pyodide_iframe.html +++ b/docs/static/_pyodide_iframe.html @@ -2,7 +2,8 @@ - + + Rendercanvas example.py in Pyodide diff --git a/examples/pyodide.html b/examples/pyodide.html index ad34400..7b567a9 100644 --- a/examples/pyodide.html +++ b/examples/pyodide.html @@ -44,7 +44,6 @@ green_data[..., 1] = 255 green_data[..., 3] = 255 - @canvas_red.request_draw def animate_red(): context_red.set_bitmap(red_data) @@ -53,6 +52,7 @@ def animate_green(): context_green.set_bitmap(green_data) + # The loop.run() is not required in Pyodide, but you can include it so that the code works on both desktop and Pyodide. loop.run() ` diff --git a/rendercanvas/auto.py b/rendercanvas/auto.py index ae16226..d4bd160 100644 --- a/rendercanvas/auto.py +++ b/rendercanvas/auto.py @@ -29,8 +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 + elif backend_name == "pyodide": + from . import pyodide as module else: # no-cover raise ImportError("Unknown rendercanvas backend: '{backend_name}'") return module @@ -83,8 +83,8 @@ def backends_generator(): """Generator that iterates over all sub-generators.""" for gen in [ backends_by_env_vars, - backends_by_jupyter, backends_by_browser, + backends_by_jupyter, backends_by_imported_modules, backends_by_trying_in_order, ]: @@ -208,10 +208,11 @@ def backends_by_trying_in_order(): def backends_by_browser(): - """If python runs in a web browser, we use the html backend.""" + """If python runs in a web browser, we use the pyodide backend.""" # https://pyodide.org/en/stable/usage/faq.html#how-to-detect-that-code-is-run-with-pyodide + # Technically, we could also be in microPython/RustPython/etc. For now, we only target Pyodide. if sys.platform == "emscripten": - yield "html", "running in a web browser" + yield "pyodide", "running in a web browser" # Load! diff --git a/rendercanvas/html.py b/rendercanvas/pyodide.py similarity index 89% rename from rendercanvas/html.py rename to rendercanvas/pyodide.py index 904799d..80303bb 100644 --- a/rendercanvas/html.py +++ b/rendercanvas/pyodide.py @@ -1,11 +1,12 @@ """ -Support to run rendercanvas on the webbrowser via Pyodide. +Support to run rendercanvas in a 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. +User code must provide a canvas that is in the dom, by passing the canvas +element or its id. By default it searcges an element with id "rendercanvas". It +is not required to set the default sdl2 canvas as the Pyodide docs describe. """ -__all__ = ["HtmlRenderCanvas", "RenderCanvas", "loop"] +__all__ = ["PyodideRenderCanvas", "RenderCanvas", "loop"] import sys import time @@ -61,14 +62,14 @@ def buttons_mask_to_tuple(mask) -> tuple[int, ...]: # The canvas group manages canvases of the type we define below. In general we don't have to implement anything here. -class HtmlCanvasGroup(BaseCanvasGroup): +class PyodideCanvasGroup(BaseCanvasGroup): pass -class HtmlRenderCanvas(BaseRenderCanvas): - """An html canvas providing a render canvas.""" +class PyodideRenderCanvas(BaseRenderCanvas): + """An HTMLCanvasElement providing a render canvas.""" - _rc_canvas_group = HtmlCanvasGroup(loop) + _rc_canvas_group = PyodideCanvasGroup(loop) def __init__( self, @@ -198,7 +199,7 @@ def _resize_callback(entries, _=None): # Note: there is no concept of an element being 'closed' in the DOM. - def _html_pointer_down(ev): + def _js_pointer_down(ev): nonlocal last_buttons focus_element.focus({"preventScroll": True, "focusVisble": False}) el.setPointerCapture(ev.pointerId) @@ -220,7 +221,7 @@ def _html_pointer_down(ev): ev.preventDefault() self.submit_event(event) - def _html_pointer_lost(ev): + def _js_pointer_lost(ev): nonlocal last_buttons # This happens on pointer-up or pointer-cancel. We threat them the same. modifiers = tuple([v for k, v in KEY_MOD_MAP.items() if getattr(ev, k)]) @@ -239,7 +240,7 @@ def _html_pointer_lost(ev): } self.submit_event(event) - def _html_pointer_move(ev): + def _js_pointer_move(ev): # If this pointer is not down, but other pointers are, don't emit an event. if pointers and ev.pointerId not in pointers: return @@ -258,7 +259,7 @@ def _html_pointer_move(ev): } self.submit_event(event) - def _html_pointer_enter(ev): + def _js_pointer_enter(ev): # If this pointer is not down, but other pointers are, don't emit an event. if pointers and ev.pointerId not in pointers: return @@ -277,7 +278,7 @@ def _html_pointer_enter(ev): } self.submit_event(event) - def _html_pointer_leave(ev): + def _js_pointer_leave(ev): # If this pointer is not down, but other pointers are, don't emit an event. if pointers and ev.pointerId not in pointers: return @@ -296,7 +297,7 @@ def _html_pointer_leave(ev): } self.submit_event(event) - def _html_double_click(ev): + def _js_double_click(ev): modifiers = tuple([v for k, v in KEY_MOD_MAP.items() if getattr(ev, k)]) buttons = buttons_mask_to_tuple(ev.buttons) event = { @@ -313,7 +314,7 @@ def _html_double_click(ev): ev.preventDefault() self.submit_event(event) - def _html_wheel(ev): + def _js_wheel(ev): if window.document.activeElement.js_id != focus_element.js_id: return scales = [1 / window.devicePixelRatio, 16, 600] # pixel, line, page @@ -333,7 +334,7 @@ def _html_wheel(ev): ev.preventDefault() self.submit_event(event) - def _html_key_down(ev): + def _js_key_down(ev): if ev.repeat: return # don't repeat keys modifiers = tuple([v for k, v in KEY_MOD_MAP.items() if getattr(ev, k)]) @@ -350,7 +351,7 @@ def _html_key_down(ev): # we need events like arrow keys, backspace, and delete, with modifiers, and with repeat. # Also see comment in jupyter_rfb - def _html_key_up(ev): + def _js_key_up(ev): modifiers = tuple([v for k, v in KEY_MOD_MAP.items() if getattr(ev, k)]) event = { "event_type": "key_up", @@ -360,7 +361,7 @@ def _html_key_up(ev): } self.submit_event(event) - def _html_char(ev): + def _js_char(ev): event = { "event_type": "char", "data": ev.data, @@ -373,29 +374,29 @@ def _html_char(ev): if not ev.isComposing: focus_element.value = "" # Prevent the text box from growing - add_event_listener(el, "pointerdown", _html_pointer_down) - add_event_listener(el, "lostpointercapture", _html_pointer_lost) - add_event_listener(el, "pointermove", _html_pointer_move) - add_event_listener(el, "pointerenter", _html_pointer_enter) - add_event_listener(el, "pointerleave", _html_pointer_leave) - add_event_listener(el, "dblclick", _html_double_click) - add_event_listener(el, "wheel", _html_wheel) - add_event_listener(focus_element, "keydown", _html_key_down) # or document? - add_event_listener(focus_element, "keyup", _html_key_up) - add_event_listener(focus_element, "input", _html_char) + add_event_listener(el, "pointerdown", _js_pointer_down) + add_event_listener(el, "lostpointercapture", _js_pointer_lost) + add_event_listener(el, "pointermove", _js_pointer_move) + add_event_listener(el, "pointerenter", _js_pointer_enter) + add_event_listener(el, "pointerleave", _js_pointer_leave) + add_event_listener(el, "dblclick", _js_double_click) + add_event_listener(el, "wheel", _js_wheel) + add_event_listener(focus_element, "keydown", _js_key_down) # or document? + add_event_listener(focus_element, "keyup", _js_key_up) + add_event_listener(focus_element, "input", _js_char) def unregister_events(): self._resize_observer.disconnect() - remove_event_listener(el, "pointerdown", _html_pointer_down) - remove_event_listener(el, "lostpointercapture", _html_pointer_lost) - remove_event_listener(el, "pointermove", _html_pointer_move) - remove_event_listener(el, "pointerenter", _html_pointer_enter) - remove_event_listener(el, "pointerleave", _html_pointer_leave) - remove_event_listener(el, "dblclick", _html_double_click) - remove_event_listener(el, "wheel", _html_wheel) - remove_event_listener(focus_element, "keydown", _html_key_down) - remove_event_listener(focus_element, "keyup", _html_key_up) - remove_event_listener(focus_element, "input", _html_char) + remove_event_listener(el, "pointerdown", _js_pointer_down) + remove_event_listener(el, "lostpointercapture", _js_pointer_lost) + remove_event_listener(el, "pointermove", _js_pointer_move) + remove_event_listener(el, "pointerenter", _js_pointer_enter) + remove_event_listener(el, "pointerleave", _js_pointer_leave) + remove_event_listener(el, "dblclick", _js_double_click) + remove_event_listener(el, "wheel", _js_wheel) + remove_event_listener(focus_element, "keydown", _js_key_down) + remove_event_listener(focus_element, "keyup", _js_key_up) + remove_event_listener(focus_element, "input", _js_char) self._unregister_events = unregister_events @@ -519,4 +520,4 @@ def _rc_set_cursor(self, cursor: str): # Make available under a name that is the same for all backends loop = loop # must set loop variable to pass meta tests -RenderCanvas = HtmlRenderCanvas +RenderCanvas = PyodideRenderCanvas diff --git a/tests/test_backends.py b/tests/test_backends.py index 636bb01..c090ecc 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -248,12 +248,12 @@ def test_glfw_module(): assert m.names["loop"] -def test_html_module(): - m = Module("html") +def test_pyodide_module(): + m = Module("pyodide") canvas_class = m.get_canvas_class() m.check_canvas(canvas_class) - assert canvas_class.name == "HtmlRenderCanvas" + assert canvas_class.name == "PyodideRenderCanvas" def test_jupyter_module(): From 98269043580f384c94a2c6051b33d6f27b0ff28d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 31 Oct 2025 10:31:55 +0100 Subject: [PATCH 58/61] Fix that pointer up event did not always receive the right button --- rendercanvas/pyodide.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/rendercanvas/pyodide.py b/rendercanvas/pyodide.py index 80303bb..2aafda5 100644 --- a/rendercanvas/pyodide.py +++ b/rendercanvas/pyodide.py @@ -138,7 +138,7 @@ def _setup_events(self): focus_element_container.appendChild(focus_element) pointers = {} - last_buttons = [] + last_buttons = () # Prevent context menu def _on_context_menu(ev): @@ -200,17 +200,22 @@ def _resize_callback(entries, _=None): # Note: there is no concept of an element being 'closed' in the DOM. def _js_pointer_down(ev): + # When points is down, set focus to the focus-element, and capture the pointing device. + # Because we capture the event, there will be no other events when buttons are pressed down, + # although they will end up in the 'buttons'. The lost/release will only get fired when all buttons + # are released/lost. Which is why we look up the original button in our `pointers` list. nonlocal last_buttons focus_element.focus({"preventScroll": True, "focusVisble": False}) el.setPointerCapture(ev.pointerId) + button = MOUSE_BUTTON_MAP.get(ev.button, ev.button) + pointers[ev.pointerId] = (button,) + last_buttons = buttons = tuple(pointer[0] for pointer in pointers.values()) modifiers = tuple([v for k, v in KEY_MOD_MAP.items() if getattr(ev, k)]) - last_buttons = buttons = buttons_mask_to_tuple(ev.buttons) - pointers[ev.pointerId] = ev event = { "event_type": "pointer_down", "x": ev.offsetX, "y": ev.offsetY, - "button": MOUSE_BUTTON_MAP.get(ev.button, ev.button), + "button": button, "buttons": buttons, "modifiers": modifiers, "ntouches": 0, # TODO later: maybe via https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent @@ -222,16 +227,21 @@ def _js_pointer_down(ev): self.submit_event(event) def _js_pointer_lost(ev): - nonlocal last_buttons # This happens on pointer-up or pointer-cancel. We threat them the same. + # According to the spec, the .button is -1, so we retrieve the button from the stored pointer. + nonlocal last_buttons + last_buttons = () + down_tuple = pointers.pop(ev.pointerId, None) + button = MOUSE_BUTTON_MAP.get(ev.button, ev.button) + if down_tuple is not None: + button = down_tuple[0] + buttons = buttons_mask_to_tuple(ev.buttons) modifiers = tuple([v for k, v in KEY_MOD_MAP.items() if getattr(ev, k)]) - last_buttons = buttons = buttons_mask_to_tuple(ev.buttons) - pointers.pop(ev.pointerId, None) event = { "event_type": "pointer_up", "x": ev.offsetX, "y": ev.offsetY, - "button": MOUSE_BUTTON_MAP.get(ev.button, ev.button), + "button": button, "buttons": buttons, "modifiers": modifiers, "ntouches": 0, @@ -242,10 +252,11 @@ def _js_pointer_lost(ev): def _js_pointer_move(ev): # If this pointer is not down, but other pointers are, don't emit an event. + nonlocal last_buttons if pointers and ev.pointerId not in pointers: return modifiers = tuple([v for k, v in KEY_MOD_MAP.items() if getattr(ev, k)]) - buttons = buttons_mask_to_tuple(ev.buttons) + last_buttons = buttons = buttons_mask_to_tuple(ev.buttons) event = { "event_type": "pointer_move", "x": ev.offsetX, From 677db3c164f4a0db75d673c051bad0104049ab65 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 31 Oct 2025 10:53:35 +0100 Subject: [PATCH 59/61] more tweaks and fix docs --- docs/backends.rst | 43 ++++++++++++++++++++++++++++++------------ docs/conf.py | 5 ++--- examples/pyscript.html | 12 +++++------- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/docs/backends.rst b/docs/backends.rst index f0fb677..cbac946 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -50,7 +50,8 @@ The table below gives an overview of the names in the different ``rendercanvas`` - | ``PyodideRenderCanvas`` (toplevel) | ``RenderCanvas`` (alias) | ``loop`` (an ``AsyncioLoop``) - - | Backend for Python running in the browser (via Pyodide). + - | Backend when Python is running in the browser, + | via Pyodide or PyScript. There are also three loop-backends. These are mainly intended for use with the glfw backend: @@ -270,14 +271,39 @@ subclass implementing a remote frame-buffer. There are also some `wgpu examples canvas # Use as cell output -Support for HTMLCanvas in Pyodide ---------------------------------- +Support for Pyodide +------------------- When Python is running in the browser using Pyodide, the auto backend selects the ``rendercanvas.pyodide.PyodideRenderCanvas`` class. This backend requires no -additional dependencies. It expects a HTMLCanvasElement to be present in the +additional dependencies. Currently only presenting a bitmap is supported, as +shown in the examples :doc:`noise.py ` and :doc:`snake.py`. +Support for wgpu is underway. + +An HTMLCanvasElement to be assumed to be present in the DOM. By default it connects to the canvas with id "rendercanvas", but a -different id or element can also be provided. +different id or element can also be provided using ``RenderCanvas(canvas_element)``. + +An example using PyScript (which uses Pyodide): + +.. code-block:: html + + + + + + + + + +
    + + + + + +An example using Pyodide directly: .. code-block:: html @@ -288,7 +314,6 @@ different id or element can also be provided. - ...

    - This example demonstrates using PyScript. It needs to be acced through - a web-server for it to access the Python file. + This example demonstrates using PyScript. It needs to be acced through + a web-server for it to access the Python file.

    - This uses the latest release from rendercanvas. To run this with the dev version, - run the serve_pyodide.py script and select the example from there. + This uses the latest release from rendercanvas. To run this with the dev version, + run the serve_pyodide.py script and select the example from there.


    - From bd01cd912e1e5dad0d25677d444eccffc3ebff63 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 31 Oct 2025 11:20:44 +0100 Subject: [PATCH 60/61] Various tweaks --- docs/conf.py | 2 +- examples/pyodide.html | 15 +++++++++++---- examples/pyscript.html | 8 +++++--- examples/serve_browser_examples.py | 3 +-- rendercanvas/pyodide.py | 11 +++++++++-- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 3a64be4..94eebad 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -157,7 +157,7 @@ def add_files_to_run_pyodide_examples(app, exception): "gallery_dirs": "gallery", "backreferences_dir": "gallery/backreferences", "doc_module": ("rendercanvas",), - "image_scrapers": (), + # "image_scrapers": (), "remove_config_comments": True, "examples_dirs": "../examples/", "ignore_pattern": r"serve_browser_examples\.py", diff --git a/examples/pyodide.html b/examples/pyodide.html index 7b567a9..580b8b6 100644 --- a/examples/pyodide.html +++ b/examples/pyodide.html @@ -9,10 +9,17 @@

    - This example demonstrates using Pyodide directly. It's a bit more involved that PyScript, - but it shows more directly what happens. Further, this example shows how to deal with - multiple canvases on the same page; the first is made red, the second is made green. - Both canvases are also resized from the Python code. + This example demonstrates using Pyodide directly. It's a bit more + involved that PyScript, but it shows more directly what happens. + Further, this example shows how to deal with multiple canvases on the + same page; the first is made red, the second is made green. Both + canvases are also resized from the Python code. +

    +

    + This example is standalone (can be opened in the browser with a server). + To run this with the development version of rendercanvas, run the + serve_browser_examples.py script and select the example from + there.

    diff --git a/examples/pyscript.html b/examples/pyscript.html index 84a96de..edbc8c4 100644 --- a/examples/pyscript.html +++ b/examples/pyscript.html @@ -19,12 +19,14 @@

    Loading...

    - This example demonstrates using PyScript. It needs to be acced through + This example demonstrates using PyScript. It needs to be loaded through a web-server for it to access the Python file.

    - This uses the latest release from rendercanvas. To run this with the dev version, - run the serve_pyodide.py script and select the example from there. + This uses the latest release from rendercanvas. To run this with the + development version of rendercanvas, run the + serve_browser_examples.py script and select the example from + there.

    diff --git a/examples/serve_browser_examples.py b/examples/serve_browser_examples.py index f4dc47a..bb8be16 100644 --- a/examples/serve_browser_examples.py +++ b/examples/serve_browser_examples.py @@ -1,10 +1,9 @@ """ -A little script that serves browser-based examples. +A little script that serves browser-based example, using a wheel from the local rendercanvas. * Examples that run rendercanvas fully in the browser in Pyodide / PyScript. * Coming soon: examples that run on the server, with a client in the browser. - What this script does: * Build the .whl for rendercanvas, so Pyodide can install the dev version. diff --git a/rendercanvas/pyodide.py b/rendercanvas/pyodide.py index 2aafda5..3fbd67d 100644 --- a/rendercanvas/pyodide.py +++ b/rendercanvas/pyodide.py @@ -93,8 +93,10 @@ def __init__( ) self._canvas_element = canvas_element - # More variables - self._js_array = Uint8ClampedArray.new(0) # buffer to store pixel data + # We need a buffer to store pixel data, until we figure out how we can map a Python memoryview to a JS ArrayBuffer without making a copy. + self._js_array = Uint8ClampedArray.new(0) + + # We use an offscreen canvas when the bitmap texture does not match the physical pixels. You should see it as a GPU texture. self._offscreen_canvas = None # If size or title are not given, set them to None, so they are left as-is. This is usually preferred in html docs. @@ -107,6 +109,8 @@ def __init__( self._final_canvas_init() def _setup_events(self): + # Idea: Implement this event logic in JavaScript, so we can re-use it across all backends that render in the browser. + el = self._canvas_element el.tabIndex = -1 @@ -468,6 +472,9 @@ def _rc_present_bitmap(self, **kwargs): # Create image data image_data = ImageData.new(array_uint8_clamped, w, h) + # Idea: use wgpu or webgl to upload to a texture and then render that. + # I'm pretty sure the below does essentially the same thing, but I am not sure about the ammount of overhead. + # Now present the image data. # For this we can blit the image into the canvas (i.e. no scaling). We can only use this is the image size matches # the canvas size (in physical pixels). Otherwise we have to scale the image. For that we can use an ImageBitmap and From f3bfaf0f5151fa24270e63febaefeaf8b66b3048 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 31 Oct 2025 11:26:25 +0100 Subject: [PATCH 61/61] enable future wgpu support --- rendercanvas/pyodide.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/rendercanvas/pyodide.py b/rendercanvas/pyodide.py index 3fbd67d..0558060 100644 --- a/rendercanvas/pyodide.py +++ b/rendercanvas/pyodide.py @@ -425,11 +425,11 @@ def _rc_get_present_methods(self): "formats": ["rgba-u8"], }, # wgpu-specific presentation. The wgpu.backends.pyodide.GPUCanvasContext must be able to consume this. - # Turned off for now - # "screen": { - # "platform": "pyodide", - # "native_canvas_attribute": "_canvas_element", - # }, + # Most importantly, it will need to access the gpu context. I want to avoid storing a heavy object in this dict, so let's just store the name of the attribute. + "screen": { + "platform": "browser", + "native_canvas_attribute": "_canvas_element", + }, } def _rc_request_draw(self):