From c81ab5090c646fccb176e50ca4f74a4b15c58e39 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 16 Sep 2025 02:09:45 +0200 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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.