Skip to content
Draft
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:

docs:
name: Docs
needs: [release]
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand All @@ -48,11 +49,21 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.12
python-version: 3.12
- name: Install dev dependencies
run: |
python -m pip install --upgrade pip
pip install -U -e .[docs]
- name: Download assets
uses: actions/download-artifact@v4
with:
name: dist
path: dist
- name: move wheel into static
run: |
mkdir -p docs/static
mv dist/* docs/static
ls -la docs/static
- name: Build docs
run: |
cd docs
Expand Down
50 changes: 48 additions & 2 deletions docs/backends.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ The table below gives an overview of the names in the different ``rendercanvas``
| ``loop``
- | Create a standalone canvas using wx, or
| integrate a render canvas in a wx application.

* - ``html``
- | ``HTMLRenderCanvas`` (toplevel)
| ``RenderCanvas`` (alias)
| ``loop`` (an ``AsyncioLoop``)
- | A canvas that runs in a web browser, using Pyodide.


There are also three loop-backends. These are mainly intended for use with the glfw backend:

Expand Down Expand Up @@ -168,7 +173,7 @@ Alternatively, you can select the specific qt library to use, making it easy to
loop.run() # calls app.exec_()


It is technically possible to e.g. use a ``glfw`` canvas with the Qt loop. However, this is not recommended because Qt gets confused in the precense of other windows and may hang or segfault.
It is technically possible to e.g. use a ``glfw`` canvas with the Qt loop. However, this is not recommended because Qt gets confused in the presence of other windows and may hang or segfault.
But the other way around, running a Qt canvas in e.g. the trio loop, works fine:

.. code-block:: py
Expand Down Expand Up @@ -264,6 +269,47 @@ subclass implementing a remote frame-buffer. There are also some `wgpu examples

canvas # Use as cell output

Support for HTMLCanvas in Pyodide
---------------------------------
When RenderCanvas runs in the browser using Pyodide the auto backend selects ``rendercanvas.html.HTMLRenderCanvas`` class.
It expects a HTMLCanvasElement to be present in the DOM. It requires no additional dependencies, as rendercanvas can be installed from micropip.

.. code-block:: html

<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.28.2/full/pyodide.js"></script>
</head>
<body>
...
<canvas id="canvas" width="640" height="480"></canvas>
<script type="text/javascript">
async function main(){
pythonCode = `
# Use python script as normally
from rendercanvas.auto import RenderCanvas, loop
canvas = RenderCanvas(title="Example")
Copy link
Contributor

@Korijn Korijn Sep 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would argue that providing a constructor like this would be more conventional for the web:

Suggested change
canvas = RenderCanvas(title="Example")
canvas_el = document.getElementById("canvas")
canvas = RenderCanvas(canvas_el, title="Example")

Since often there are multiple canvas elements on the page, users should be able to control which is used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the goal was to keep the python code portable between auto backends. So passing a string to __init__() would work even on backends where this kwarg isn't used like glfw and the user doesn't need to use any pyodide specific code in python. (Once we have a wgpu-py version for browser, most examples should just work without changes to shadertoy, pygfx or fastplotlib etc).

I also losely followed the idea of https://pyodide.org/en/stable/usage/sdl.html#setting-canvas where they provide a specific API to accessing the canvas, although I not using it.

Maybe I can write a little multi canvas example to see if my approach works.

I have zero webdev experience, so my design decisions are directed to the python devs wanting to write their python code (like myself).

Copy link
Contributor

@Korijn Korijn Sep 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have zero webdev experience, so my design decisions are directed to the python devs wanting to write their python code (like myself).

I hear you, but just because you can use python the language, doesn't mean you can "ignore" the environment it's running in! I don't mind what kind of API you choose (I value portability as well) as long as the user can control which <canvas> is used.

I imagine python devs turning to browsers will often do so because they want to use the browser's capabilities to build the UI they have in mind. It's easy to envision applications with multiple canvases embedded in a richer UI.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it shouldn't be impossible to support both.
canvas_el: [str|HTMLCanvasElement] = "canvas"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would work even on backends where this kwarg isn't used like glfw

apparently kwargs don't get ignored when a different auto backend is selected because the base class calls super.__init__(*args, *kwargs). We could use the title arg as I am not sure if that has a use in the browser, but that seems janky.

Copy link
Member

@almarklein almarklein Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For resizing, have a look at window.ResizeObserve: I use this code in another project to get the physical size (it's PScript, so you need to convert to Python/JS depending on where it runs):

        # Inside the  ResizeObserve callback ... 
        entry = entries.find(lambda entry: entry.target is self.node)
        if entry.devicePixelContentBoxSize:
            # Best if we have the physical pixels ...
            psize = [
                entry.devicePixelContentBoxSize[0].inlineSize,
                entry.devicePixelContentBoxSize[0].blockSize,
            ]
        else:
            # ... but not all browsers support that (see issue #423) ...
            if entry.contentBoxSize:
                lsize = [
                    entry.contentBoxSize[0].inlineSize,
                    entry.contentBoxSize[0].blockSize,
                ]
            else:  # even more backward compat
                lsize = [entry.contentRect.width, entry.contentRect.height]
            ratio = get_pixel_ratio()
            psize = Math.floor(lsize[0] * ratio), Math.floor(lsize[1] * ratio)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general the standard reference material for browser APIs is on MDN, in this case see this page for example usage: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

finally found a bit of time to implement this - also tried to make the demo page react and it seems to work. Altough I am not sure if this matches real webframeworks

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure we can hook things up in a clean way, but I will need to sit down and play a bit to get it right.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feel free to commit into this branch or similar if you have the capacity. I am very much out of my depth with the web stuff and also don't have too much time currently (settling into new job and new university) to sit down for some long evenings and figure it out.

context = canvas.get_context("bitmap")
bitmap = memoryview(b"HTMLRenderCanvas"[::-1]).cast("B", shape=(2, 2, 4))
context.set_bitmap(bitmap)
canvas.force_draw()
`
// load Pyodide and install Rendercanvas
let pyodide = await loadPyodide();
await pyodide.loadPackage("micropip");
const micropip = pyodide.pyimport("micropip");
await micropip.install("rendercanvas");
// have to call as runPythonAsync
pyodide.runPythonAsync(pythonCode);
}
main();
</script>
</body>
</html>


Currently only presenting a bitmap is supported, as shown in the examples :doc:`noise.py <gallery/noise>` and :doc:`snake.py <gallery/snake>`.


.. _env_vars:
Expand Down
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----------------------------------------------------
Expand Down
6 changes: 3 additions & 3 deletions docs/contextapi.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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::
Expand All @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions docs/start.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 :)
Expand Down
35 changes: 35 additions & 0 deletions docs/static/_pyodide_iframe.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!doctype html>
<html>
<head>
<!-- Interactive HTMLRenderCanvas via Pyodide:<br> -->
<script src="https://cdn.jsdelivr.net/pyodide/v0.28.2/full/pyodide.js"></script>
</head>
<body>
<canvas id="canvas" width="640" height="480" style="background-color: red;"></canvas><br>
<!-- TODO: redirect stdout prints into something visible without console? -->
<script type="text/javascript">
async function main(){
let example_name = window.parent.location.href.split("/").slice(-1)[0].split("#").splice(0)[0].replace(".html", "");
// TODO: get the script from docs dir? docs/gallery/script.py or the .zip?
// for now get the example from main because the local example is hidden behind a hash in _downloads/hash/example_name.py (but it still exists)
pythonCode = await (await fetch(`https://raw.githubusercontent.com/pygfx/rendercanvas/refs/heads/main/examples/${example_name}.py`)).text();
let pyodide = await loadPyodide();
await pyodide.loadPackage("micropip");
const micropip = pyodide.pyimport("micropip");
await micropip.install('numpy'); // can we figure out if we need it, or do we always get it?

// TODO: learn js to implement the other options...
// so we should get the rendercanvas wheel from three locations:
// 1) local wheel from ../dist/*whl? (for local development)
// 2) from the CI artefacts (moves into _static in the job) (for PR branch)
// 3) latest from pypi, when the artefacts are gone - for like stable/main?
// await micropip.install('rendercanvas');
await micropip.install('rendercanvas-2.2.1-py3-none-any.whl'); // from html/_static/ dir like a PR branch doc build...

// Run the Python code async because some calls are async it seems.
pyodide.runPythonAsync(pythonCode);
}
main();
</script>
</body>
</html>
5 changes: 5 additions & 0 deletions docs/static/custom.css
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions examples/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
# <iframe src="../_static/_pyodide_iframe.html"></iframe>
#
37 changes: 37 additions & 0 deletions examples/local_browser.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!-- adapted from: https://traineq.org/imgui_bundle_online/projects/min_bundle_pyodide_app/demo_heart.source.txt -->
<!doctype html>
<html>
<head>
RenderCanvas HTML canvas via Pyodide:<br>
<script src="https://cdn.jsdelivr.net/pyodide/v0.28.2/full/pyodide.js"></script>
</head>
<body>
<canvas id="canvas" width="640" height="480" style="background-color: lightgrey;"></canvas><br>
some text below the canvas!
<script type="text/javascript">
async function main(){

// fetch the file locally for easier scripting
// --allow-file-access-from-files or local webserver
// TODO: replace the actual code here (unless you have the module)
pythonCode = await (await fetch("snake.py")).text();
// pythonCode = await (await fetch("events.py")).text();
// pythonCode = await (await fetch("noise.py")).text();


// 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();
</script>
</body>
</html>
106 changes: 106 additions & 0 deletions examples/multicanvas_browser.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<!doctype html>
<html>
<head>
How to distinguish multiple canvas elements?:<br>
<script src="https://cdn.jsdelivr.net/pyodide/v0.28.2/full/pyodide.js"></script>
</head>
<body>
<!-- declare your canvas elements with a unique id tag -->
<canvas id="redCanvas" width="320" height="240" style="background-color: red;"></canvas>
<canvas id="greenCanvas" width="320" height="240" style="background-color: green;"></canvas>
<canvas id="blueCanvas" width="320" height="240" style="background-color: blue;"></canvas>
<br>
First canvas updates when the pointer hovers, second canvas changes direction while keypress, third canvas updates when you click!

<script type="text/javascript">
async function main(){
pythonCode = `
from rendercanvas.auto import RenderCanvas, loop
import numpy as np

# use the canvas_el argument to select the canvas by its id tag
canvas_red = RenderCanvas(canvas_el="#redCanvas", size=(320, 240), update_mode="ondemand")
canvas_blue = RenderCanvas(canvas_el="#blueCanvas", size=(320, 240), update_mode="ondemand")

# or have the element via your own code
from js import document
green_canvas_el = document.getElementById("greenCanvas")
canvas_green = RenderCanvas(canvas_el=green_canvas_el, size=(320, 240), update_mode="continuous")

context_red = canvas_red.get_context("bitmap")
context_green = canvas_green.get_context("bitmap")
context_blue = canvas_blue.get_context("bitmap")

red_data = np.random.uniform(127, 255, size=(24, 32, 4)).astype(np.uint8)
red_data[..., 0] = 255

green_data = np.random.uniform(0, 255, size=(24, 32, 4)).astype(np.uint8)
green_data[..., 1] = 255
green_data[..., 3] = 255 # solid alpha for this one

blue_data = np.random.uniform(127, 255, size=(24, 32, 4)).astype(np.uint8)
blue_data[..., 2] = 255

@canvas_red.add_event_handler("pointer_enter")
def on_pointer_enter_red(event):
canvas_red.set_update_mode("continuous")

@canvas_red.add_event_handler("pointer_leave")
def on_pointer_leave_red(event):
canvas_red.set_update_mode("ondemand")

@canvas_green.add_event_handler("key_down")
def on_key_down_green(event):
green_data[0, 0, 3] = 254 # storing my bit flag in the data

@canvas_green.add_event_handler("key_up")
def on_key_up_green(event):
green_data[0, 0, 3] = 255

@canvas_blue.add_event_handler("pointer_down")
def on_pointer_click_blue(event):
pos = (int(event["y"]//10), int(event["x"]//10))
blue_data[pos[0]-1:pos[0]+2, pos[1]-1:pos[1]+2, 0:2] += 128
canvas_blue.request_draw() # trigger the draw with the click event too!

@canvas_red.request_draw
def animate_red():
red_data[..., 1] += 10
red_data[..., 2] += 19
context_red.set_bitmap(red_data)

@canvas_green.request_draw
def animate_green():
if green_data[0, 0, 3] < 255:
# move downwards
green_data[1:, :, [0,2]] = green_data[:-1, :, [0,2]]
green_data[0, :, [0,2]] = green_data[-1, :, [0,2]]
else:
# move upwards
green_data[:-1, :, [0,2]] = green_data[1:, :, [0,2]]
green_data[-1, :, [0,2]] = green_data[0, :, [0,2]]
context_green.set_bitmap(green_data)

def animate_blue():
context_blue.set_bitmap(blue_data)

canvas_blue.request_draw(animate_blue)

loop.run()
`
// 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();
</script>
</body>
</html>
13 changes: 13 additions & 0 deletions examples/noise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
# <iframe src="../_static/_pyodide_iframe.html"></iframe>
#
Loading
Loading