diff --git a/.gitignore b/.gitignore
index 950f261c0..db73e6623 100644
--- a/.gitignore
+++ b/.gitignore
@@ -138,3 +138,8 @@ dmypy.json
# diffs from visual regression tests
examples/desktop/diffs/*.png
docs/source/_gallery/
+
+
+# not sure why this spammed my diff
+docs/source/api/
+docs/source/_static/*.whl
diff --git a/docs/source/_static/_pyodide_iframe.html b/docs/source/_static/_pyodide_iframe.html
new file mode 100644
index 000000000..6b60815ab
--- /dev/null
+++ b/docs/source/_static/_pyodide_iframe.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+ Fastplotlib examples in the browser using Pyodide
+
+
+
+
+
+
+
+
Output:
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/source/_static/style.css b/docs/source/_static/style.css
index e69de29bb..8cb58c477 100644
--- a/docs/source/_static/style.css
+++ b/docs/source/_static/style.css
@@ -0,0 +1,5 @@
+section[id^="interactive-example"] iframe {
+ width: 100%;
+ height: 520px;
+ border: none;
+}
diff --git a/docs/source/conf.py b/docs/source/conf.py
index ead9f05c4..c4fd9b7b7 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -44,6 +44,94 @@
"sphinx_gallery.gen_gallery",
]
+# note this is largely copied from the pygfx PR branch: https://github.com/pygfx/pygfx/pull/1273
+# -- Build wheel so Pyodide examples can use exactly this version of fpl -----------------------------------------------------
+import subprocess
+import shutil
+
+short_version = ".".join(str(i) for i in fastplotlib.version_info[:3])
+wheel_name = f"fastplotlib-{short_version}-py3-none-any.whl"
+
+# Build the wheel
+subprocess.run([sys.executable, "-m", "build", "-nw"], cwd=ROOT_DIR)
+wheel_filename = os.path.join(ROOT_DIR, "dist", wheel_name)
+assert os.path.isfile(wheel_filename), f"{wheel_name} does not exist"
+
+# Copy into static
+# TODO: you can use --outdir on the build command directly. also use the html_static_path in this namespace
+print("Copy wheel to _static dir")
+shutil.copy(
+ wheel_filename,
+ os.path.join(ROOT_DIR, "docs", "source", "_static", wheel_name),
+)
+
+# -- Sphix Gallery -----------------------------------------------------
+
+## pyodide demos, adapted from wgpu, adapted from rendercanvas... might make sense to put this in the scaper?
+iframe_placeholder_rst = """
+.. only:: html
+
+ Interactive example
+ -------------------
+
+ Try this example in your browser using Pyodide. Might not work with all examples and all devices. Check the output and your browser's console for details.
+
+ .. raw:: html
+
+
+"""
+python_files = {}
+
+# I have a feeling this import might be really sketchy to have on CI... but hey - this is a hack for a hack
+from examples.server_browser_examples import patch_imageio_for_pyodide
+
+
+def add_pyodide_to_examples(app):
+ if app.builder.name != "html":
+ return
+
+ gallery_dir = ROOT_DIR / "docs" / "source" / "_gallery"
+ example_files = gallery_dir.glob("**/*.py")
+
+ for py_file in example_files:
+ fname = py_file.name
+ with open(py_file, "rb") as f:
+ py = f.read().decode()
+ py = patch_imageio_for_pyodide(py)
+ if fname:
+ print("Adding Pyodide example to", fname)
+ fname_rst = py_file.with_suffix(".rst")
+ # Update rst file
+ rst = iframe_placeholder_rst.replace("example.py", fname)
+ # we likely don't want append here?
+ with open(fname_rst, "ab") as f:
+ # TODO: skip if it already ends with the placeholder? otherwise the append will keep on appending (we have to hook this into the gen_rst to skip if possible?)
+ f.write(rst.encode())
+ python_files[py_file.relative_to(gallery_dir)] = 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", "source", "_static", "_pyodide_iframe.html"), "rb"
+ ) as f:
+ html = f.read().decode()
+ html = html.replace('"fastplotlib"', 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())
+
+
+
sphinx_gallery_conf = {
"gallery_dirs": "_gallery",
"notebook_extensions": {}, # remove the download notebook button
@@ -137,3 +225,11 @@
"rendercanvas": ("https://rendercanvas.readthedocs.io/stable/", None),
# "fastplotlib": ("https://www.fastplotlib.org/", None),
}
+
+html_css_files = [
+ "style.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/examples/serve_browser_examples.py b/examples/serve_browser_examples.py
new file mode 100644
index 000000000..a5ba1cfc1
--- /dev/null
+++ b/examples/serve_browser_examples.py
@@ -0,0 +1,291 @@
+"""
+Serve browser examples locally.
+===============================
+
+
+A little script to serve pygfx examples on localhost so you can try them in the browser.
+This is an iteration of the same script in rendercanvas and wgpu-py although some changes:
+
+* swapped in pathlib for os.path (mostly)
+* avoided pyscript for now, but likely a good idea to use for webworker recovery etc.
+
+Files are loaded from disk on each request, so you can leave the server running
+and just update examples, update pygfx and build the wheel, etc.
+"""
+
+import os
+import sys
+import webbrowser
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from pathlib import Path
+
+import flit
+
+import fastplotlib
+
+
+# sphinx_gallery_pygfx_docs = 'hidden'
+# sphinx_gallery_pygfx_test = 'off'
+
+# "hosted" upstream deps, will break in the future
+wgpu_wheel = "https://wgpu-py--753.org.readthedocs.build/en/753/_static/wgpu-0.31.0-py3-none-any.whl" # very hacky way to serve this but it does work...
+uharfbuzz_wheel = "https://pygfx--1273.org.readthedocs.build/1273/_static/uharfbuzz-0.54.1-cp310-abi3-pyodide_2025_0_wasm32.whl" # try to get it from the github release, so we don't need to include it...
+pygfx_wheel = "https://pygfx--1273.org.readthedocs.build/1273/_static/pygfx-0.16.0-py3-none-any.whl"
+
+# the pygfx wheel will be listed after this. it might be possible to still get deps from pyproject.toml
+fpl_deps = [wgpu_wheel, uharfbuzz_wheel, pygfx_wheel, "imgui-bundle", "hsluv", "pylinalg", "jinja2", "httpx", "trimesh", "gltflib", "imageio"]
+
+root = Path(__file__).parent.parent.absolute()
+
+short_version = ".".join(str(i) for i in fastplotlib.version_info[:3])
+wheel_name = f"fastplotlib-{short_version}-py3-none-any.whl"
+
+example_files = list((root / "examples").glob("**/*.py"))
+
+def get_html_index():
+ """Create a landing page."""
+
+ examples_list = [f"
" for name in example_files]
+
+ html = """
+
+
+
+ fastplotlib browser examples
+
+
+
+
+ Rebuild the wheel
+ """
+
+ html += "List of examples that might run in Pyodide:\n"
+ html += f"
{''.join(examples_list)}
\n\n"
+
+ html += "\n\n"
+ return html
+
+
+html_index = get_html_index()
+
+# TODO: a pyodide example for the compute examples (so we can capture output?)
+# modified from _pyodide_iframe.html from rendercanvas, and then again from pygfx
+pyodide_compute_template = """
+
+
+
+
+ {example_script} via Pyodide
+
+
+
+
+
+
+ Back to list
+
+
+ {docstring}
+
+
+
+
Output:
+
+
+
+
+
+"""
+
+
+imageio_patch = """
+from pyodide.http import pyfetch
+from pyodide.ffi import run_sync
+
+async def _imread_async(filename, **kwargs):
+ # pyodide working version of iio.imread, uses fetch and waiting on it, also replaced the "imageio:"
+ filename = str(filename) # maybe breaks some Path stuff?
+ if filename.startswith("imageio:"):
+ filename = filename.replace("imageio:", "https://raw.githubusercontent.com/imageio/imageio-binaries/master/images/")
+
+ if "." in filename:
+ kwargs["extension"] = "." + filename.rsplit(".", 1)[-1]
+
+ response = await pyfetch(filename)
+ res = iio.imread((await response.bytes()), **kwargs)
+ return res
+
+def _imread(filename:str, **kwargs):
+ print(f"Loading image: {filename!r} with kwargs: {kwargs}")
+ res = run_sync(_imread_async(filename, **kwargs))
+ return res
+"""
+
+def patch_imageio_for_pyodide(python_code:str) -> str:
+ if "iio.imread(" not in python_code:
+ return python_code
+
+ return imageio_patch + "\n\n" + python_code.replace("iio.imread(", "_imread(")
+
+
+
+def build_wheel():
+ toml_filename = (root / "pyproject.toml")
+ # spews more and more with each build... might need to clear stdout or something?
+ flit.main(["-f", str(toml_filename.resolve()), "build", "--no-use-vcs", "--format", "wheel"])
+ wheel_filename = root / "dist" / wheel_name
+ assert wheel_filename.is_file(), f"{wheel_name} does not exist"
+ # also copy the wgpu wheel if it's in a repo nearby... so make it a bit less work to update both.
+
+
+def get_docstring_from_py_file(fname):
+ filename = root / "examples" / fname
+ docstate = 0
+ doc = ""
+ with open(filename, "rb") as f:
+ for line in f:
+ line = line.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", " ")
+
+
+class MyHandler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ if self.path == "/":
+ self.respond(200, html_index, "text/html")
+ elif self.path == "/build":
+ try:
+ self.respond(200, "Building wheel... ", "text/html")
+ 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"):
+ requested_path = Path(self.path)
+ filename = root / "dist" / requested_path.name
+ 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("/")
+ pyname = self.path.replace(".html", ".py").lstrip("/")
+ try:
+ doc = get_docstring_from_py_file(pyname)
+ deps = [*fpl_deps, f"/{wheel_name}"]
+ html = pyodide_compute_template.format(
+ docstring=doc,
+ example_script=pyname,
+ # todo: refactor this to a list and maybe get other deps from pyodide.loadPackagesFromImports
+ dependencies="\n".join(
+ [f"await micropip.install({dep!r});" for dep in deps]
+ ),
+ )
+ self.respond(200, html, "text/html")
+ except Exception as err:
+ self.respond(404, f"example not found: {err}")
+ 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()
+ data = patch_imageio_for_pyodide(data.decode()).encode()
+ self.respond(200, data, "text/plain")
+ else:
+ self.respond(404, "py file not found")
+
+ # TODO: we could try to mount a virtual filesystem and fill it... but I think using fetch and serving the files could work easier.
+ elif self.path.startswith("/home/data/"):
+ requested_path = Path(self.path)
+ # this is for the pyodide examples that need to load data files - we mount the examples/data folder to /home/data in pyodide, so we can serve those files here.
+ filename = root / "examples" / "data" / requested_path.relative_to("/home/data")
+ 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, "data 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.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
+ self.send_header("Access-Control-Allow-Headers", "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/fastplotlib/ui/right_click_menus/_colormap_picker.py b/fastplotlib/ui/right_click_menus/_colormap_picker.py
index a80e5b2aa..53d51956e 100644
--- a/fastplotlib/ui/right_click_menus/_colormap_picker.py
+++ b/fastplotlib/ui/right_click_menus/_colormap_picker.py
@@ -79,7 +79,7 @@ def _create_texture_and_upload(self, data: np.ndarray) -> tuple[int, GPUTexture]
)
# get a view
- texture_view = texture.create_view()
+ texture_view = texture.create_view(dimension=wgpu.TextureViewDimension.d2)
# return texture ref
return self.imgui_renderer.backend.register_texture(texture_view)
diff --git a/pyproject.toml b/pyproject.toml
index 73dfd7ee3..dc5f3863d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,7 @@ keywords = [
requires-python = ">= 3.10"
dependencies = [
"numpy>=1.23.0",
- "pygfx==0.15.3",
+ "pygfx==0.16.0",
"wgpu", # Let pygfx constrain the wgpu version
"cmap>=0.1.3",
# (this comment keeps this list multiline in VSCode)