Skip to content

Commit

Permalink
Add pyodide_patch_runners
Browse files Browse the repository at this point in the history
  • Loading branch information
pthom committed Dec 16, 2024
1 parent 5363c9d commit 6a7ac7d
Show file tree
Hide file tree
Showing 3 changed files with 177 additions and 2 deletions.
10 changes: 8 additions & 2 deletions bindings/imgui_bundle/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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__)
Expand Down
163 changes: 163 additions & 0 deletions bindings/imgui_bundle/pyodide_patch_runners.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions external/bindings_generation/cpp/pybind_imgui_bundle.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
}


Expand Down

0 comments on commit 6a7ac7d

Please sign in to comment.