diff --git a/bindings/imgui_bundle/__init__.py b/bindings/imgui_bundle/__init__.py index d2b31eae..926fa26a 100644 --- a/bindings/imgui_bundle/__init__.py +++ b/bindings/imgui_bundle/__init__.py @@ -1,6 +1,6 @@ # Part of ImGui Bundle - MIT License - Copyright (c) 2022-2023 Pascal Thomet - https://github.com/pthom/imgui_bundle import os -from imgui_bundle._imgui_bundle import __bundle_submodules__ # type: ignore +from imgui_bundle._imgui_bundle import __bundle_submodules__, __bundle_pyodide__ # type: ignore from imgui_bundle._imgui_bundle import __version__, compilation_time from typing import Union, Tuple, List @@ -144,9 +144,15 @@ def has_submodule(submodule_name): _glfw_set_search_path() from imgui_bundle import glfw_utils as glfw_utils # noqa: E402 - # +# Pyodide: patch hello_imgui.run and immapp.run to work with Pyodide +# +if __bundle_pyodide__: + from imgui_bundle.pyodide_patch_runners import pyodide_do_patch_runners + pyodide_do_patch_runners() + +# # Override assets folder # THIS_DIR = os.path.dirname(__file__) diff --git a/bindings/imgui_bundle/pyodide_patch_runners.py b/bindings/imgui_bundle/pyodide_patch_runners.py new file mode 100644 index 00000000..e5a891d0 --- /dev/null +++ b/bindings/imgui_bundle/pyodide_patch_runners.py @@ -0,0 +1,163 @@ +"""This module provides a way to render hello imgui and immapp applications in the browser using pyodide. +It works by monkey patching the `hello_imgui.run` and `immapp.run` functions to use a custom implementation +that integrates with the browser's requestAnimationFrame API. +""" +from dataclasses import dataclass +from typing import Callable +from imgui_bundle import hello_imgui, immapp +from enum import Enum +from pyodide.ffi import create_proxy # type: ignore +import js # type: ignore +import gc +import logging + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger("pyodide_imgui_render") +logger.setLevel(logging.INFO) + +def _log(msg: str): + # logger.info(msg) + # js.console.log("pyodide_imgui_render: " + msg) + pass + + +class _JsAnimationRenderer: + """Make it possible to call a python function to do rendering at each javascript frame.""" + render_fn: Callable[[], None] # A python function that performs rendering + stop_requested: bool # A flag to request the animation loop to stop + main_loop_proxy: js.Proxy # A javascript proxy to the main_loop method that is called at each frame + + def __init__(self, render_fn: Callable[[], None]): + self.render_fn = render_fn + self.stop_requested = False + self.main_loop_proxy = create_proxy(self.main_loop) + + def main_loop(self, _timestamp): + if self.stop_requested: + if self.main_loop_proxy: + _log("_JsAnimationRenderer: received stop_requested => destroying main_loop_proxy") + self.main_loop_proxy.destroy() + self.main_loop_proxy = None + return + try: + self.render_fn() + except Exception as e: + self.request_stop() + raise e + + # Schedule the next frame + js.requestAnimationFrame(self.main_loop_proxy) + + def start(self): + _log("_JsAnimationRenderer.start()") + self.stop_requested = False + js.requestAnimationFrame(self.main_loop_proxy) + + def request_stop(self): + _log("_JsAnimationRenderer:stop() => set stop_requested=True") + self.stop_requested = True + + +@dataclass +class _RenderLifeCycleFunctions: + setup: Callable[[], None] = None + render: Callable[[], None] = None + tear_down: Callable[[], None] = None + + +class _HelloImGuiOrImmApp(Enum): + HELLO_IMGUI = 1 + IMMAPP = 2 + + +def _arg_to_render_lifecycle_functions(himgui_or_immapp: _HelloImGuiOrImmApp, *args, **kwargs) -> _RenderLifeCycleFunctions: + """Converts the arguments to the correct render lifecycle functions, + depending on the type of arguments passed and whether it is a hello_imgui or immapp application.""" + functions = _RenderLifeCycleFunctions() + + if himgui_or_immapp == _HelloImGuiOrImmApp.HELLO_IMGUI: + render_module = hello_imgui.manual_render + elif himgui_or_immapp == _HelloImGuiOrImmApp.IMMAPP: + render_module = immapp.manual_render + else: + raise ValueError("Invalid value for himgui_or_immapp") + + _log(f"{len(args)=} args: {args} kwargs: {kwargs}") + use_runner_params = (len(args) >= 1 and isinstance(args[0], hello_imgui.RunnerParams)) or "runner_params" in kwargs + use_simple_params = (len(args) >= 1 and isinstance(args[0], hello_imgui.SimpleRunnerParams)) or "simple_params" in kwargs + use_gui_function = (len(args) >= 1 and callable(args[0])) or "gui_function" in kwargs + + if use_runner_params: + _log("overload with RunnerParams") + functions.setup = lambda: render_module.setup_from_runner_params(*args, **kwargs) + elif use_simple_params: + _log("overload with SimpleRunnerParams") + functions.setup = lambda: render_module.setup_from_simple_runner_params(*args, **kwargs) + elif use_gui_function: + _log("overload with callable") + functions.setup = lambda:render_module.setup_from_gui_function(*args, **kwargs) + else: + raise ValueError("Invalid arguments") + + functions.render = render_module.render + + functions.tear_down = render_module.tear_down + + return functions + + +class _ManualRenderJs: + """Manages the ManualRender lifecycle (from HelloImGui or ImmApp) and integrates with _JsAnimationRenderer.""" + js_animation_renderer: _JsAnimationRenderer | None = None + is_running: bool = False + render_lifecycle_functions: _RenderLifeCycleFunctions | None = None + + def _stop(self): + """Stops the current rendering loop and tears down the renderer.""" + if not self.is_running: + return + if self.js_animation_renderer is not None: + _log("_ManualRenderJs: Stopping js_animation_renderer") + self.js_animation_renderer.request_stop() + self.js_animation_renderer = None + + try: + self.render_lifecycle_functions.tear_down() + self.render_lifecycle_functions = None + _log("_ManualRenderJs: HelloImGuiRunnerJs: Renderer torn down successfully.") + except Exception as e: + import traceback + js.console.error(f"_ManualRenderJs: Error during Renderer teardown: {e}\n{traceback.format_exc()}") + finally: + # Force garbage collection to free resources + gc.collect() + + def _run(self, himgui_or_immapp: _HelloImGuiOrImmApp, *args, **kwargs): + if self.is_running: + self._stop() + self.is_running = True + + self.render_lifecycle_functions = _arg_to_render_lifecycle_functions(himgui_or_immapp, *args, **kwargs) + self.render_lifecycle_functions.setup() + self.js_animation_renderer = _JsAnimationRenderer(self.render_lifecycle_functions.render) + self.js_animation_renderer.start() + + def run_immapp(self, *args, **kwargs): + self._run(_HelloImGuiOrImmApp.IMMAPP, *args, **kwargs) + + def run_hello_imgui(self, *args, **kwargs): + self._run(_HelloImGuiOrImmApp.HELLO_IMGUI, *args, **kwargs) + + +_MANUAL_RENDER_JS: _ManualRenderJs | None = None + + +def pyodide_do_patch_runners(): + # Instantiate global runners + global _MANUAL_RENDER_JS + print("pyodide_do_patch_runners()") + _log("_MANUAL_RENDER_JS: Version 1") + _MANUAL_RENDER_JS = _ManualRenderJs() + # Monkey patch the hello_imgui.run and immapp.run function to use the js version + hello_imgui.run = _MANUAL_RENDER_JS.run_hello_imgui + immapp.run = _MANUAL_RENDER_JS.run_immapp diff --git a/external/bindings_generation/cpp/pybind_imgui_bundle.cpp b/external/bindings_generation/cpp/pybind_imgui_bundle.cpp index d1a03ac1..ed567102 100644 --- a/external/bindings_generation/cpp/pybind_imgui_bundle.cpp +++ b/external/bindings_generation/cpp/pybind_imgui_bundle.cpp @@ -162,6 +162,12 @@ void py_init_module_imgui_bundle(nb::module_& m) #endif m.attr("__bundle_submodules__") = gAllSubmodules; + +#ifdef IMGUI_BUNDLE_BUILD_PYODIDE + m.attr("__bundle_pyodide__") = true; +#else + m.attr("__bundle_pyodide__") = false; +#endif }