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/.gitignore b/.gitignore index 3503076..fa230cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Special for this repo nogit/ docs/gallery/ +docs/static/*.whl docs/sg_execution_times.rst examples/screenshots/ diff --git a/docs/backends.rst b/docs/backends.rst index 13248cb..45a0f57 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -46,6 +46,12 @@ The table below gives an overview of the names in the different ``rendercanvas`` | ``loop`` - | Create a standalone canvas using wx, or | integrate a render canvas in a wx application. + * - ``pyodide`` + - | ``PyodideRenderCanvas`` (toplevel) + | ``RenderCanvas`` (alias) + | ``loop`` (an ``AsyncioLoop``) + - | 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: @@ -168,7 +174,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 @@ -265,6 +271,80 @@ subclass implementing a remote frame-buffer. There are also some `wgpu examples canvas # Use as cell output +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. 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 is assumed to be present in the +DOM. By default it connects to the canvas with id "canvas", but a +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 + + + + + + + + + + + + + .. _env_vars: diff --git a/docs/conf.py b/docs/conf.py index 42759c7..d2f1faa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,7 +12,9 @@ import os import sys +import shutil +import flit ROOT_DIR = os.path.abspath(os.path.join(__file__, "..", "..")) sys.path.insert(0, ROOT_DIR) @@ -20,10 +22,10 @@ os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] = "true" -# Load wglibu so autodoc can query docstrings +# Load wgpu 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 ----------------------------------------------------- @@ -40,10 +42,10 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ + "sphinx_rtd_theme", "sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx.ext.intersphinx", - "sphinx_rtd_theme", "sphinx_gallery.gen_gallery", ] @@ -64,8 +66,88 @@ master_doc = "index" +# -- Build wheel so Pyodide 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 +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), +) + + # -- Sphinx Gallery ----------------------------------------------------- +iframe_placeholder_rst = """ +.. only:: html + + Interactive example + =================== + + This uses Pyodide. If this does not work, your browser may not have sufficient support for wasm/pyodide/wgpu (check your browser dev console). + + .. raw:: html + + +""" + +python_files = {} + + +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): + filename = os.path.join(gallery_dir, fname) + if not fname.endswith(".py"): + continue + with open(filename, "rb") as f: + py = f.read().decode() + if fname in ["drag.py", "noise.py", "snake.py"]: + # todo: later we detect by using a special comment in the py file + print("Adding Pyodide example to", fname) + fname_rst = fname.replace(".py", ".rst") + # Update rst file + rst = iframe_placeholder_rst.replace("example.py", fname) + with open(os.path.join(gallery_dir, fname_rst), "ab") as f: + f.write(rst.encode()) + python_files[fname] = py + + +def add_files_to_run_pyodide_examples(app, exception): + if app.builder.name != "html": + return + + gallery_build_dir = os.path.join(app.outdir, "gallery") + + # Write html file that can load pyodide examples + with open( + os.path.join(ROOT_DIR, "docs", "static", "_pyodide_iframe.html"), "rb" + ) as f: + html = f.read().decode() + html = html.replace('"rendercanvas"', f'"../_static/{wheel_name}"') + with open(os.path.join(gallery_build_dir, "pyodide.html"), "wb") as f: + f.write(html.encode()) + + # Write the python files + for fname, py in python_files.items(): + print("Writing", fname) + with open(os.path.join(gallery_build_dir, fname), "wb") as f: + f.write(py.encode()) + + # Suppress "cannot cache unpickable configuration value" for sphinx_gallery_conf # See https://github.com/sphinx-doc/sphinx/issues/12300 suppress_warnings = ["config.cache"] @@ -75,9 +157,10 @@ "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", } # -- Options for HTML output ------------------------------------------------- @@ -92,3 +175,8 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["static"] html_css_files = ["custom.css"] + + +def setup(app): + app.connect("builder-inited", add_pyodide_to_examples) + app.connect("build-finished", add_files_to_run_pyodide_examples) diff --git a/docs/contextapi.rst b/docs/contextapi.rst index 252ea08..f5bbf16 100644 --- a/docs/contextapi.rst +++ b/docs/contextapi.rst @@ -24,7 +24,7 @@ then present the result to the screen. For this, the canvas provides one or more └─────────┘ └────────┘ This means that for the context to be able to present to any canvas, it must -support *both* the 'image' and 'screen' present-methods. If the context prefers +support *both* the 'bitmap' and 'screen' present-methods. If the context prefers presenting to the screen, and the canvas supports that, all is well. Similarly, if the context has a bitmap to present, and the canvas supports the bitmap-method, there's no problem. @@ -44,7 +44,7 @@ on the CPU. All GPU API's have ways to do this. download from gpu to cpu If the context has a bitmap to present, and the canvas only supports presenting -to screen, you can usse a small utility: the ``BitmapPresentAdapter`` takes a +to screen, you can use a small utility: the ``BitmapPresentAdapter`` takes a bitmap and presents it to the screen. .. code-block:: @@ -58,7 +58,7 @@ bitmap and presents it to the screen. This way, contexts can be made to work with all canvas backens. -Canvases may also provide additionaly present-methods. If a context knows how to +Canvases may also provide additionally present-methods. If a context knows how to use that present-method, it can make use of it. Examples could be presenting diff images or video streams. diff --git a/docs/start.rst b/docs/start.rst index a2817d7..598548e 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -79,12 +79,12 @@ Async A render canvas can be used in a fully async setting using e.g. Asyncio or Trio, or in an event-drived framework like Qt. If you like callbacks, ``loop.call_later()`` always works. If you like async, use ``loop.add_task()``. Event handlers can always be async. -If you make use of async functions (co-routines), and want to keep your code portable accross +If you make use of async functions (co-routines), and want to keep your code portable across different canvas backends, restrict your use of async features to ``sleep`` and ``Event``; -these are the only features currently implemened in our async adapter utility. +these are the only features currently implemented in our async adapter utility. We recommend importing these from :doc:`rendercanvas.utils.asyncs ` or use ``sniffio`` to detect the library that they can be imported from. -On the other hand, if you know your code always runs on the asyncio loop, you can fully make use of ``asyncio``. Dito for Trio. +On the other hand, if you know your code always runs on the asyncio loop, you can fully make use of ``asyncio``. Ditto for Trio. If you use Qt and get nervous from async code, no worries, when running on Qt, ``asyncio`` is not even imported. You can regard most async functions as syntactic sugar for pieces of code chained with ``call_later``. That's more or less how our async adapter works :) diff --git a/docs/static/_pyodide_iframe.html b/docs/static/_pyodide_iframe.html new file mode 100644 index 0000000..6e9d8d0 --- /dev/null +++ b/docs/static/_pyodide_iframe.html @@ -0,0 +1,41 @@ + + + + + + Rendercanvas example.py in Pyodide + + + + + +

Loading...

+
+ + + + + \ No newline at end of file diff --git a/docs/static/custom.css b/docs/static/custom.css index 0fd546b..8b3f0d9 100644 --- a/docs/static/custom.css +++ b/docs/static/custom.css @@ -1,4 +1,10 @@ div.sphx-glr-download, div.sphx-glr-download-link-note { display: none; +} + +div.document iframe { + width: 100%; + height: 520px; + border: none; } \ No newline at end of file diff --git a/examples/drag.py b/examples/drag.py index 03a052b..7a63786 100644 --- a/examples/drag.py +++ b/examples/drag.py @@ -70,6 +70,8 @@ def on_pointer_down(event): bx, by = block[:2] if bx - hs < x < bx + hs and by - hs < y < by + hs: dragging = i, (bx, by), (x, y) + block[2] = block_size + 6 + canvas.set_cursor("pointer") break @@ -106,8 +108,12 @@ def on_pointer_up(event): global dragging if event["button"] == 1: + if dragging is not None: + blocks[dragging[0]][2] = block_size dragging = None + canvas.set_cursor("default") + @canvas.add_event_handler("key_down") def on_key(event): @@ -116,7 +122,12 @@ def on_key(event): blocks[:] = [block.copy() for block in initial_blocks] elif key == " ": blocks.append( - [block_size // 2 + 10, block_size // 2 + 10, (255, 255, 255, 255)] + [ + block_size // 2 + 10, + block_size // 2 + 10, + block_size, + (255, 255, 255, 255), + ] ) diff --git a/examples/events.py b/examples/events.py index 262aad5..7602486 100644 --- a/examples/events.py +++ b/examples/events.py @@ -2,7 +2,7 @@ Events ------ -A simple example to demonstrate events. +A simple example to demonstrate events. Events are printed to the console. """ from rendercanvas.auto import RenderCanvas, loop diff --git a/examples/pyodide.html b/examples/pyodide.html new file mode 100644 index 0000000..67a1619 --- /dev/null +++ b/examples/pyodide.html @@ -0,0 +1,81 @@ + + + + + + RenderCanvas Pyodide example + + + + +

+ This example demonstrates using Pyodide directly. It's a bit more + involved than 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. +

+ + + +

+ + + + + \ No newline at end of file diff --git a/examples/pyscript.html b/examples/pyscript.html new file mode 100644 index 0000000..173b32c --- /dev/null +++ b/examples/pyscript.html @@ -0,0 +1,38 @@ + + + + + + RenderCanvas PyScript example + + + + + + +

Loading...

+
+ + +

+ 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 + development version of rendercanvas, run the + serve_browser_examples.py script and select the example from + there. +

+ + +
+ + + + \ No newline at end of file diff --git a/examples/serve_browser_examples.py b/examples/serve_browser_examples.py new file mode 100644 index 0000000..1d28faf --- /dev/null +++ b/examples/serve_browser_examples.py @@ -0,0 +1,222 @@ +""" +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. +* 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 +import sys +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer + +import flit +import rendercanvas + + +# Examples to load as PyScript application +py_examples = [ + "drag.html", + "noise.html", + "snake.html", + "events.html", +] + +# Examples that are already html +html_examples = [ + "pyodide.html", + "pyscript.html", +] + + +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" + + +def get_html_index(): + """Create a landing page.""" + + py_examples_list = [f"
  • {name}
  • " for name in py_examples] + html_examples_list = [ + f"
  • {name}
  • " for name in html_examples + ] + + html = """ + + + + RenderCanvas PyScript examples + + + + + Rebuild the wheel

    + """ + + 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() + + +# An html template to show examples using pyscript. +pyscript_template = """ + + + + + example.py via PyScript + + + + + Back to list

    + +

    + docstring +

    + +

    Loading...

    +
    + + + + + + + +""" + + +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(): + 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" + + +def get_docstring_from_py_file(fname): + filename = os.path.join(root, "examples", fname) + docstate = 0 + doc = "" + with open(filename, "rb") as f: + while True: + line = f.readline().decode() + if docstate == 0: + if line.lstrip().startswith('"""'): + docstate = 1 + else: + if docstate == 1 and line.lstrip().startswith(("---", "===")): + docstate = 2 + doc = "" + elif '"""' in line: + doc += line.partition('"""')[0] + break + else: + doc += line + + return doc.replace("\n\n", "

    ") + + +class MyHandler(BaseHTTPRequestHandler): + def do_GET(self): + 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): + 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 py_examples: + pyname = name.replace(".html", ".py") + html = pyscript_template.replace("example.py", pyname) + html = html.replace('"rendercanvas"', f'"./{wheel_name}"') + html = html.replace("docstring", get_docstring_from_py_file(pyname)) + 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}"') + html = html.replace( + "", "Back to list

    " + ) + self.respond(200, html, "text/html") + else: + self.respond(404, "example not found") + elif self.path.endswith(".py"): + filename = os.path.join(root, "examples", self.path.strip("/")) + 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() diff --git a/examples/snake.py b/examples/snake.py index d7d9e8e..4238829 100644 --- a/examples/snake.py +++ b/examples/snake.py @@ -12,7 +12,7 @@ from rendercanvas.auto import RenderCanvas, loop -canvas = RenderCanvas(present_method=None, update_mode="continuous") +canvas = RenderCanvas(present_method=None, size=(640, 480), update_mode="continuous") context = canvas.get_context("bitmap") diff --git a/pyproject.toml b/pyproject.toml index ecab45a..dc3a7e9 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 = ["flit", "numpy", "wgpu", "glfw", "pyside6", "imageio", "pytest"] +docs = [ + "flit", + "sphinx>7.2", + "sphinx_rtd_theme", + "sphinx-gallery", + "numpy", + "wgpu", +] tests = ["pytest", "numpy", "wgpu", "glfw", "trio"] dev = ["rendercanvas[lint,tests,examples,docs]"] diff --git a/rendercanvas/_events.py b/rendercanvas/_events.py index 400ca5e..541d306 100644 --- a/rendercanvas/_events.py +++ b/rendercanvas/_events.py @@ -76,8 +76,8 @@ def my_handler(event): .. code-block:: py - @canvas.add_event_handler("pointer_up", "pointer_down") def - my_handler(event): + @canvas.add_event_handler("pointer_up", "pointer_down") + def my_handler(event): print(event) Catch 'm all: diff --git a/rendercanvas/auto.py b/rendercanvas/auto.py index a1987e8..d4bd160 100644 --- a/rendercanvas/auto.py +++ b/rendercanvas/auto.py @@ -29,6 +29,8 @@ def _load_backend(backend_name): from . import wx as module elif backend_name == "offscreen": from . import offscreen as module + elif backend_name == "pyodide": + from . import pyodide as module else: # no-cover raise ImportError("Unknown rendercanvas backend: '{backend_name}'") return module @@ -81,6 +83,7 @@ def backends_generator(): """Generator that iterates over all sub-generators.""" for gen in [ backends_by_env_vars, + backends_by_browser, backends_by_jupyter, backends_by_imported_modules, backends_by_trying_in_order, @@ -204,6 +207,14 @@ def backends_by_trying_in_order(): yield backend_name, f"{libname} can be imported" +def backends_by_browser(): + """If python runs in a web browser, we use the 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 "pyodide", "running in a web browser" + + # Load! module = select_backend() RenderCanvas = cast(type[BaseRenderCanvas], module.RenderCanvas) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 4bd7ea8..e62f9c0 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -122,8 +122,8 @@ def select_loop(cls, loop: BaseLoop) -> None: def __init__( self, *args, - size: Tuple[float, float] = (640, 480), - title: str = "$backend", + size: Tuple[float, float] | None = (640, 480), + title: str | None = "$backend", update_mode: UpdateModeEnum = "ondemand", min_fps: float = 0.0, max_fps: float = 30.0, @@ -191,8 +191,12 @@ def _final_canvas_init(self): del self.__kwargs_for_later # Apply if not isinstance(self, WrapperRenderCanvas): - self.set_logical_size(*kwargs["size"]) # type: ignore - self.set_title(kwargs["title"]) # type: ignore + size = kwargs["size"] + if size is not None: + self.set_logical_size(*size) # type: ignore + title = kwargs["title"] + if title is not None: + self.set_title(title) # type: ignore def __del__(self): # On delete, we call the custom destroy method. @@ -465,7 +469,7 @@ def _draw_frame_and_present(self): # "draw event" that we requested, or as part of a forced draw. # Cannot draw to a closed canvas. - if self._rc_get_closed(): + if self._rc_get_closed() or self._draw_frame is None: return # Note: could check whether the known physical size is > 0. @@ -667,7 +671,7 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): raise NotImplementedError() def _rc_set_logical_size(self, width: float, height: float): - """Set the logical size. May be ignired when it makes no sense. + """Set the logical size. May be ignored when it makes no sense. The default implementation does nothing. """ diff --git a/rendercanvas/pyodide.py b/rendercanvas/pyodide.py new file mode 100644 index 0000000..928a80c --- /dev/null +++ b/rendercanvas/pyodide.py @@ -0,0 +1,551 @@ +""" +Support to run rendercanvas in a webbrowser via Pyodide. + +User code must provide a canvas that is in the dom, by passing the canvas +element or its id. By default it selects an element with id "canvas". It +is not required to set the default sdl2 canvas as the Pyodide docs describe. +""" + +__all__ = ["PyodideRenderCanvas", "RenderCanvas", "loop"] + +import re +import sys +import time +import ctypes + +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.") + +from pyodide.ffi import create_proxy, to_js +from pyodide.ffi.wrappers import add_event_listener, remove_event_listener +from js import ( + document, + ImageData, + Uint8ClampedArray, + window, + ResizeObserver, + OffscreenCanvas, + navigator, +) + +KEYMAP = { + "Ctrl": "Control", + "Del": "Delete", + "Esc": "Escape", +} + +KEY_MOD_MAP = { + "altKey": "Alt", + "ctrlKey": "Control", + "metaKey": "Meta", + "shiftKey": "Shift", +} + +MOUSE_BUTTON_MAP = { + -1: 0, # no button + 0: 1, # left + 1: 3, # middle/wheel + 2: 2, # right + 3: 4, # backwards + 4: 5, # forwards +} + + +def buttons_mask_to_tuple(mask) -> tuple[int, ...]: + bin(mask) + res = () + for i, v in enumerate(bin(mask)[:1:-1]): + if v == "1": + res += (MOUSE_BUTTON_MAP.get(i, i),) + return res + + +looks_like_mobile = bool( + re.search(r"mobi|android|iphone|ipad|ipod|tablet", str(navigator.userAgent).lower()) +) + + +# The canvas group manages canvases of the type we define below. In general we don't have to implement anything here. +class PyodideCanvasGroup(BaseCanvasGroup): + pass + + +class PyodideRenderCanvas(BaseRenderCanvas): + """An HTMLCanvasElement providing a render canvas.""" + + _rc_canvas_group = PyodideCanvasGroup(loop) + + def __init__( + self, + canvas_element: str = "canvas", + *args, + **kwargs, + ): + # Resolve and check the canvas element + 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 + + # 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. + kwargs["size"] = kwargs.get("size", None) + kwargs["title"] = kwargs.get("title", None) + + # Finalize init + super().__init__(*args, **kwargs) + self._setup_events() + 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 + + # Obtain container to put our hidden focus element. + # Putting the focus_element as a child of the canvas prevents chrome from emitting input events. + focus_element_container_id = "rendercanvas-focus-element-container" + focus_element_container = document.getElementById(focus_element_container_id) + if not focus_element_container: + focus_element_container = document.createElement("div") + focus_element_container.setAttribute("id", focus_element_container_id) + focus_element_container.style.position = "absolute" + focus_element_container.style.top = "0" + focus_element_container.style.left = "-9999px" + document.body.appendChild(focus_element_container) + + # Create an element to which we transfer focus, so we can capture key events and prevent global shortcuts + self._focus_element = focus_element = document.createElement("input") + focus_element.type = "text" + focus_element.tabIndex = -1 + focus_element.autocomplete = "off" + focus_element.autocorrect = "off" + focus_element.autocapitalize = "off" + focus_element.spellcheck = False + focus_element.style.width = "1px" + focus_element.style.height = "1px" + focus_element.style.padding = "0" + focus_element.style.opacity = 0 + focus_element.style.pointerEvents = "none" + focus_element_container.appendChild(focus_element) + + pointers = {} + last_buttons = () + + # Prevent context menu + def _on_context_menu(ev): + if not ev.shiftKey: + ev.preventDefault() + ev.stopPropagation() + return False + + el.oncontextmenu = create_proxy(_on_context_menu) + + def _resize_callback(entries, _=None): + # The physical size is easy. The logical size can be much more tricky + # to obtain due to all the CSS stuff. But the base class will just calculate that + # from the physical size and the pixel ratio. + + # Select entry + our_entries = [entry for entry in entries if entry.target.js_id == el.js_id] + if not our_entries: + return + entry = entries[0] + + ratio = window.devicePixelRatio + + if entry.devicePixelContentBoxSize: + psize = ( + entry.devicePixelContentBoxSize[0].inlineSize, + entry.devicePixelContentBoxSize[0].blockSize, + ) + else: # some browsers don't support the above + if entry.contentBoxSize: + lsize = ( + entry.contentBoxSize[0].inlineSize, + entry.contentBoxSize[0].blockSize, + ) + else: + lsize = (entry.contentRect.width, entry.contentRect.height) + psize = (int(lsize[0] * ratio), int(lsize[1] * ratio)) + + # 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 = psize[0] / ratio, psize[1] / ratio + if not el.style.width: + el.style.width = f"{lsize[0]}px" + if not el.style.height: + el.style.height = f"{lsize[1]}px" + + # Set the canvas to the match its physical size on screen + el.width = psize[0] + el.height = psize[1] + + # Notify the base class, so it knows our new size + self._set_size_info(psize, window.devicePixelRatio) + + self._resize_callback_proxy = create_proxy(_resize_callback) + self._resize_observer = ResizeObserver.new(self._resize_callback_proxy) + self._resize_observer.observe(el) + + # 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 + if not looks_like_mobile: + focus_element.focus({"preventScroll": True, "focusVisible": 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)]) + event = { + "event_type": "pointer_down", + "x": ev.offsetX, + "y": ev.offsetY, + "button": button, + "buttons": buttons, + "modifiers": modifiers, + "ntouches": 0, # TODO later: maybe via https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent + "touches": {}, + "time_stamp": time.time(), + } + if not ev.altKey: + ev.preventDefault() + self.submit_event(event) + + def _js_pointer_lost(ev): + # This happens on pointer-up or pointer-cancel. We treat 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)]) + event = { + "event_type": "pointer_up", + "x": ev.offsetX, + "y": ev.offsetY, + "button": button, + "buttons": buttons, + "modifiers": modifiers, + "ntouches": 0, + "touches": {}, + "time_stamp": time.time(), + } + self.submit_event(event) + + 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)]) + last_buttons = buttons = buttons_mask_to_tuple(ev.buttons) + event = { + "event_type": "pointer_move", + "x": ev.offsetX, + "y": ev.offsetY, + "button": MOUSE_BUTTON_MAP.get(ev.button, ev.button), + "buttons": buttons, + "modifiers": modifiers, + "ntouches": 0, + "touches": {}, + "time_stamp": time.time(), + } + self.submit_event(event) + + 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 + modifiers = tuple([v for k, v in KEY_MOD_MAP.items() if getattr(ev, k)]) + buttons = buttons_mask_to_tuple(ev.buttons) + event = { + "event_type": "pointer_enter", + "x": ev.offsetX, + "y": ev.offsetY, + "button": MOUSE_BUTTON_MAP.get(ev.button, ev.button), + "buttons": buttons, + "modifiers": modifiers, + "ntouches": 0, + "touches": {}, + "time_stamp": time.time(), + } + self.submit_event(event) + + 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 + modifiers = tuple([v for k, v in KEY_MOD_MAP.items() if getattr(ev, k)]) + buttons = buttons_mask_to_tuple(ev.buttons) + event = { + "event_type": "pointer_leave", + "x": ev.offsetX, + "y": ev.offsetY, + "button": MOUSE_BUTTON_MAP.get(ev.button, ev.button), + "buttons": buttons, + "modifiers": modifiers, + "ntouches": 0, + "touches": {}, + "time_stamp": time.time(), + } + self.submit_event(event) + + 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 = { + "event_type": "double_click", + "x": ev.offsetX, + "y": ev.offsetY, + "button": MOUSE_BUTTON_MAP.get(ev.button, ev.button), + "buttons": buttons, + "modifiers": modifiers, + # no touches here + "time_stamp": time.time(), + } + if not ev.altKey: + ev.preventDefault() + self.submit_event(event) + + def _js_wheel(ev): + if window.document.activeElement.js_id != focus_element.js_id: + return + scales = [1 / window.devicePixelRatio, 16, 600] # pixel, line, page + scale = scales[ev.deltaMode] + modifiers = tuple([v for k, v in KEY_MOD_MAP.items() if getattr(ev, k)]) + event = { + "event_type": "wheel", + "x": ev.offsetX, + "y": ev.offsetY, + "dx": ev.deltaX * scale, + "dy": ev.deltaY * scale, + "buttons": last_buttons, + "modifiers": modifiers, + "time_stamp": time.time(), + } + if not ev.altKey: + ev.preventDefault() + self.submit_event(event) + + 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)]) + event = { + "event_type": "key_down", + "modifiers": modifiers, + "key": KEYMAP.get(ev.key, ev.key), + "time_stamp": time.time(), + } + # No need for stopPropagation or preventDefault because we are in a text-input. + self.submit_event(event) + + # NOTE: to allow text-editing functionality *inside* a framebuffer, e.g. via imgui or something similar, + # we need events like arrow keys, backspace, and delete, with modifiers, and with repeat. + # Also see comment in jupyter_rfb + + 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", + "modifiers": modifiers, + "key": KEYMAP.get(ev.key, ev.key), + "time_stamp": time.time(), + } + self.submit_event(event) + + def _js_char(ev): + event = { + "event_type": "char", + "data": ev.data, + "is_composing": ev.isComposing, + "input_type": ev.inputType, + # "repeat": getattr(ev, "repeat", False), # n.a. + "time_stamp": time.time(), + } + self.submit_event(event) + if not ev.isComposing: + focus_element.value = "" # Prevent the text box from growing + + 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", _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 + + def _rc_gui_poll(self): + 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): + return { + # Generic presentation + "bitmap": { + "formats": ["rgba-u8"], + }, + # wgpu-specific presentation. The wgpu.backends.pyodide.GPUCanvasContext must be able to consume this. + # 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): + window.requestAnimationFrame( + create_proxy(lambda _: 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 recommended in general. + self._draw_frame_and_present() + + def _rc_present_bitmap(self, **kwargs): + data = kwargs.get("data") + + # Convert to memoryview. It probably already is. + m = memoryview(data) + h, w = m.shape[:2] + + # Convert to a JS ImageData object + if True: + # Make sure that the array matches the number of pixels + if self._js_array.length != m.nbytes: + self._js_array = Uint8ClampedArray.new(m.nbytes) + # Copy pixels into the array. + self._js_array.assign(m) + array_uint8_clamped = self._js_array + else: + # Convert memoryview to a JS array without making a copy. Does not work yet. + # Pyodide does not support memoryview very well, so we convert to a ctypes array first. + # Some options: + # * Use pyodide.ffi.PyBuffer, but this name cannot be imported. See https://github.com/pyodide/pyodide/issues/5972 + # * Use ``ptr = ctypes.addressof(ctypes.c_char.from_buffer(buf))`` and then ``Uint8ClampedArray.new(full_wasm_buffer, ptr, nbytes)``, + # but for now we don't seem to be able to get access to the raw wasm data. + # * Use to_js(). For now this makes a copy (maybe that changes someday?). + c = (ctypes.c_uint8 * m.nbytes).from_buffer(data) # No copy + array_uint8 = to_js(c) # Makes a copy, and somehow mangles the data?? + array_uint8_clamped = Uint8ClampedArray.new(array_uint8.buffer) # no-copy + # 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 amount 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 + # draw that with CanvasRenderingContext2D.drawImage() or ImageBitmapRenderingContext.transferFromImageBitmap(), + # but creating an ImageBitmap is async, which complicates things. So we use an offscreen canvas as an in-between step. + cw, ch = self._canvas_element.width, self._canvas_element.height + if w == cw and h == ch: + # Quick blit + self._canvas_element.getContext("2d").putImageData(image_data, 0, 0) + else: + # Make sure that the offscreen canvas matches the data size + if self._offscreen_canvas is None: + self._offscreen_canvas = OffscreenCanvas.new(w, h) + if self._offscreen_canvas.width != w or self._offscreen_canvas.height != h: + self._offscreen_canvas.width = w + self._offscreen_canvas.height = h + # Blit to the offscreen canvas. + # This effectively uploads the image to a GPU texture (represented by the offscreen canvas). + self._offscreen_canvas.getContext("2d").putImageData(image_data, 0, 0) + # Then we draw the offscreen texture into the real texture, scaling is applied. + # Do we want a smooth image or nearest-neighbour? Depends on the situation. + # We should decide what we want backends to do, and maybe have a way for users to chose. + ctx = self._canvas_element.getContext("2d") + ctx.imageSmoothingEnabled = False + ctx.drawImage(self._offscreen_canvas, 0, 0, cw, ch) + + 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" + + def _rc_close(self): + # Closing is a bit weird in the browser ... + + # Mark as closed + canvas_element = self._canvas_element + if canvas_element is None: + return # already closed + self._canvas_element = None + + # Disconnect events + if self._unregister_events: + self._unregister_events() + self._unregister_events = None + + # Remove the focus element from the dom. + self._focus_element.remove() + + # Removing the element from the page. One can argue whether you want this or not. + canvas_element.remove() + + def _rc_get_closed(self): + return self._canvas_element is None + + def _rc_set_title(self, title: str): + # A canvas element doesn't have a title directly. + # We assume that when the canvas sets a title it's the only one, and we set the title of the document. + # Maybe we want a mechanism to prevent this at some point, we'll see. + document.title = title + + def _rc_set_cursor(self, cursor: str): + self._canvas_element.style.cursor = cursor + + +# Make available under a name that is the same for all backends +loop = loop # must set loop variable to pass meta tests +RenderCanvas = PyodideRenderCanvas diff --git a/tests/test_backends.py b/tests/test_backends.py index 3892ab8..c090ecc 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_pyodide_module(): + m = Module("pyodide") + + canvas_class = m.get_canvas_class() + m.check_canvas(canvas_class) + assert canvas_class.name == "PyodideRenderCanvas" + + def test_jupyter_module(): m = Module("jupyter")