pyodide_patch_runners.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. """This module provides a way to render hello imgui and immapp applications in the browser using pyodide.
  2. It works by monkey patching the `hello_imgui.run` and `immapp.run` functions to use a custom implementation
  3. that integrates with the browser's requestAnimationFrame API.
  4. """
  5. from dataclasses import dataclass
  6. from typing import Any, Callable
  7. from imgui_bundle import hello_imgui, immapp
  8. from enum import Enum
  9. from pyodide.ffi import create_proxy # type: ignore
  10. import js # type: ignore
  11. import gc
  12. import logging
  13. logger = logging.getLogger("pyodide_imgui_render")
  14. logger.setLevel(logging.WARNING) # Avoid noise in Fiatlight's log window
  15. def _log(msg: str) -> None:
  16. logger.info(msg)
  17. class _JsAnimationRenderer:
  18. """Make it possible to call a python function to do rendering at each javascript frame."""
  19. render_fn: Callable[[], None] # A python function that performs rendering
  20. stop_requested: bool # A flag to request the animation loop to stop
  21. main_loop_proxy: js.Proxy # A JavaScript proxy to the main_loop method that is called at each frame
  22. stop_callback: Callable[[], None] | None # Callback to call when stopping (to trigger teardown)
  23. def __init__(self, render_fn: Callable[[], None], stop_callback: Callable[[], None] | None = None):
  24. self.render_fn = render_fn
  25. self.stop_requested = False
  26. self.stop_callback = stop_callback
  27. self.main_loop_proxy = create_proxy(self.main_loop)
  28. def main_loop(self, _timestamp: Any) -> None:
  29. if self.stop_requested:
  30. if self.main_loop_proxy:
  31. _log("_JsAnimationRenderer: received stop_requested => destroying main_loop_proxy")
  32. self.main_loop_proxy.destroy()
  33. self.main_loop_proxy = None
  34. return
  35. try:
  36. if not self.stop_requested:
  37. self.render_fn()
  38. # Check if the application requested exit (like AbstractRunner::Run does)
  39. # This happens when user clicks close button or calls app_shall_exit = True
  40. if hello_imgui.get_runner_params().app_shall_exit:
  41. _log("_JsAnimationRenderer: app_shall_exit detected, calling stop_callback")
  42. self.request_stop()
  43. # Trigger teardown via callback
  44. if self.stop_callback:
  45. self.stop_callback()
  46. return
  47. except Exception as e:
  48. self.request_stop()
  49. raise e
  50. # Only schedule the next frame if not stopping/tearing down
  51. if not self.stop_requested:
  52. js.requestAnimationFrame(self.main_loop_proxy)
  53. def start(self) -> None:
  54. _log("_JsAnimationRenderer.start()")
  55. self.stop_requested = False
  56. js.requestAnimationFrame(self.main_loop_proxy)
  57. def request_stop(self) -> None:
  58. _log("_JsAnimationRenderer:stop() => set stop_requested=True")
  59. self.stop_requested = True
  60. @dataclass
  61. class _RenderLifeCycleFunctions:
  62. setup: Callable[[], None]
  63. render: Callable[[], None]
  64. tear_down: Callable[[], None]
  65. class _HelloImGuiOrImmApp(Enum):
  66. HELLO_IMGUI = 1
  67. IMMAPP = 2
  68. def _wants_latex(args: Any, kwargs: Any) -> bool:
  69. """Detect whether the caller asked for LaTeX rendering across the
  70. three immapp.run() calling conventions.
  71. - gui-function form: kwargs["with_latex"]
  72. - RunnerParams / SimpleRunnerParams form: addons_params.with_latex,
  73. passed as args[1] or kwargs["addons_params"]
  74. """
  75. if kwargs.get("with_latex"):
  76. return True
  77. addons = None
  78. if len(args) >= 2:
  79. addons = args[1]
  80. elif "addons_params" in kwargs:
  81. addons = kwargs["addons_params"]
  82. if addons is not None and getattr(addons, "with_latex", False):
  83. return True
  84. return False
  85. def _arg_to_render_lifecycle_functions(himgui_or_immapp: _HelloImGuiOrImmApp, *args: Any, **kwargs: Any) -> _RenderLifeCycleFunctions:
  86. """Converts the arguments to the correct render lifecycle functions,
  87. depending on the type of arguments passed and whether it is a hello_imgui or immapp application."""
  88. if himgui_or_immapp == _HelloImGuiOrImmApp.HELLO_IMGUI:
  89. render_module = hello_imgui.manual_render
  90. elif himgui_or_immapp == _HelloImGuiOrImmApp.IMMAPP:
  91. render_module = immapp.manual_render # type: ignore
  92. else:
  93. raise ValueError("Invalid value for himgui_or_immapp")
  94. _log(f"{len(args)=} args: {args} kwargs: {kwargs}")
  95. use_runner_params = (len(args) >= 1 and isinstance(args[0], hello_imgui.RunnerParams)) or "runner_params" in kwargs
  96. use_simple_params = (len(args) >= 1 and isinstance(args[0], hello_imgui.SimpleRunnerParams)) or "simple_params" in kwargs
  97. use_gui_function = (len(args) >= 1 and callable(args[0])) or "gui_function" in kwargs
  98. if use_runner_params:
  99. _log("overload with RunnerParams")
  100. fn_setup = lambda: render_module.setup_from_runner_params(*args, **kwargs) # noqa: E731
  101. elif use_simple_params:
  102. _log("overload with SimpleRunnerParams")
  103. fn_setup = lambda: render_module.setup_from_simple_runner_params(*args, **kwargs) # noqa: E731
  104. elif use_gui_function:
  105. _log("overload with callable")
  106. fn_setup = lambda:render_module.setup_from_gui_function(*args, **kwargs) # noqa: E731
  107. else:
  108. raise ValueError("Invalid arguments")
  109. fn_render = render_module.render
  110. fn_tear_down = render_module.tear_down
  111. functions = _RenderLifeCycleFunctions(fn_setup, fn_render, fn_tear_down)
  112. return functions
  113. class _ManualRenderJs:
  114. """Manages the ManualRender lifecycle (from HelloImGui or ImmApp) and integrates with _JsAnimationRenderer."""
  115. js_animation_renderer: _JsAnimationRenderer | None = None
  116. is_running: bool = False
  117. render_lifecycle_functions: _RenderLifeCycleFunctions | None = None
  118. def _stop(self) -> None:
  119. """Stops the current rendering loop and tears down the renderer."""
  120. _log("_ManualRenderJs._stop() called")
  121. if not self.is_running:
  122. _log("_ManualRenderJs.stop -> Not running, nothing to stop.")
  123. return
  124. if self.js_animation_renderer is not None:
  125. _log("_ManualRenderJs.stop -> Stopping js_animation_renderer")
  126. self.js_animation_renderer.request_stop()
  127. self.js_animation_renderer = None
  128. try:
  129. assert(self.render_lifecycle_functions is not None)
  130. self.render_lifecycle_functions.tear_down()
  131. self.render_lifecycle_functions = None
  132. _log("_ManualRenderJs._stop() -> HelloImGuiRunnerJs: Renderer torn down successfully.")
  133. except Exception as e:
  134. import traceback
  135. js.console.error(f"_ManualRenderJs._stop() -> _ManualRenderJs: Error during Renderer teardown: {e}\n{traceback.format_exc()}")
  136. finally:
  137. self.is_running = False
  138. # Force garbage collection to free resources
  139. gc.collect()
  140. def _run(self, himgui_or_immapp: _HelloImGuiOrImmApp, *args: Any, **kwargs: Any) -> None:
  141. _log(f"_ManualRenderJs._run() called with {himgui_or_immapp}, args: {args}, kwargs: {kwargs}")
  142. if self.is_running:
  143. _log("_ManualRenderJs._run() -> Stopping existing renderer before starting a new one.")
  144. self._stop()
  145. self.is_running = True
  146. self.render_lifecycle_functions = _arg_to_render_lifecycle_functions(himgui_or_immapp, *args, **kwargs)
  147. self.render_lifecycle_functions.setup()
  148. # Pass _stop as callback so animation renderer can trigger teardown when app_shall_exit
  149. self.js_animation_renderer = _JsAnimationRenderer(
  150. self.render_lifecycle_functions.render,
  151. stop_callback=self._stop
  152. )
  153. self.js_animation_renderer.start()
  154. _log("_ManualRenderJs._run() -> Animation started (non-blocking)")
  155. async def _run_async(self, himgui_or_immapp: _HelloImGuiOrImmApp, *args: Any, **kwargs: Any) -> None:
  156. """Async version that can be awaited to wait until GUI exits."""
  157. import asyncio
  158. _log(f"_ManualRenderJs._run_async() called with {himgui_or_immapp}, args: {args}, kwargs: {kwargs}")
  159. # Start the GUI (non-blocking)
  160. self._run(himgui_or_immapp, *args, **kwargs)
  161. # Wait until stopped (either by stop_requested or app_shall_exit)
  162. # Teardown is automatic via stop_callback
  163. _log("_ManualRenderJs._run_async() -> Waiting for GUI to exit")
  164. while self.is_running:
  165. await asyncio.sleep(0.016) # ~60 FPS check rate
  166. _log("_ManualRenderJs._run_async() -> GUI exited (teardown already done via callback)")
  167. def run_immapp(self, *args: Any, **kwargs: Any) -> None:
  168. """Run an immapp GUI in Pyodide (fire-and-forget).
  169. In Pyodide, run() starts the GUI and returns immediately since browsers
  170. cannot block. The GUI runs until the user closes it or sets app_shall_exit = True.
  171. For async control (waiting for GUI to exit), use run_async() instead.
  172. If ``with_latex=True`` is requested, the LaTeX math fonts are
  173. downloaded from a CDN before the renderer starts (one-time per
  174. session, ~876 KB). Desktop builds bundle the fonts in the wheel
  175. and never enter this path; Pyodide builds exclude the fonts to
  176. keep the wheel small (see ``IMGUI_BUNDLE_SLIM_PYODIDE_WHEEL=1``
  177. in the Pyodide build script + ``pyproject.toml`` override).
  178. """
  179. if _wants_latex(args, kwargs):
  180. import asyncio
  181. # Tear down any previous renderer synchronously, so its animation
  182. # frames stop firing while we await the LaTeX font download.
  183. # Otherwise the old lambda keeps ticking against freshly-rebound
  184. # globals from the new exec (playground reuses one namespace),
  185. # producing spurious AttributeErrors.
  186. # Note: when not using latex, self._run() will also call self._stop() if needed
  187. if self.is_running:
  188. self._stop()
  189. async def _delayed_start() -> None:
  190. try:
  191. from imgui_bundle._pyodide_latex_fonts import ensure_fonts_async
  192. await ensure_fonts_async()
  193. except Exception as e: # noqa: BLE001
  194. # Log here for visibility; the C++ wrapper has its own
  195. # missing-fonts safety net (imgui_md_wrapper.cpp:
  196. # EnsureMicroTeXInitialized) that falls back to rendering
  197. # the LaTeX source as plain text.
  198. js.console.error(
  199. f"imgui_bundle: failed to fetch LaTeX fonts ({e}). "
  200. f"Formulas will be shown as plain text."
  201. )
  202. self._run(_HelloImGuiOrImmApp.IMMAPP, *args, **kwargs)
  203. asyncio.ensure_future(_delayed_start())
  204. else:
  205. self._run(_HelloImGuiOrImmApp.IMMAPP, *args, **kwargs)
  206. def run_hello_imgui(self, *args: Any, **kwargs: Any) -> None:
  207. """Run a hello_imgui GUI in Pyodide (fire-and-forget).
  208. In Pyodide, run() starts the GUI and returns immediately since browsers
  209. cannot block. The GUI runs until the user closes it or sets app_shall_exit = True.
  210. For async control (waiting for GUI to exit), use run_async() instead.
  211. """
  212. self._run(_HelloImGuiOrImmApp.HELLO_IMGUI, *args, **kwargs)
  213. async def run_immapp_async(self, *args: Any, **kwargs: Any) -> None:
  214. """Async version of run_immapp that waits until GUI exits.
  215. If ``with_latex=True``, awaits the LaTeX font download (one-time
  216. per session) before starting the renderer.
  217. """
  218. if _wants_latex(args, kwargs):
  219. # Stop the previous renderer up front (see run_immapp).
  220. if self.is_running:
  221. self._stop()
  222. try:
  223. from imgui_bundle._pyodide_latex_fonts import ensure_fonts_async
  224. await ensure_fonts_async()
  225. except Exception as e: # noqa: BLE001
  226. # See run_immapp() above for the rationale: log + fall through.
  227. # The C++ wrapper handles missing fonts via its own fallback.
  228. js.console.error(
  229. f"imgui_bundle: failed to fetch LaTeX fonts ({e}). "
  230. f"Formulas will be shown as plain text."
  231. )
  232. await self._run_async(_HelloImGuiOrImmApp.IMMAPP, *args, **kwargs)
  233. async def run_hello_imgui_async(self, *args: Any, **kwargs: Any) -> None:
  234. """Async version of run_hello_imgui that waits until GUI exits."""
  235. await self._run_async(_HelloImGuiOrImmApp.HELLO_IMGUI, *args, **kwargs)
  236. _MANUAL_RENDER_JS: _ManualRenderJs | None = None
  237. def stop_active_renderer() -> None:
  238. """Stop the currently running renderer, if any. No-op if nothing is running.
  239. Intended for the playground's demo-switching JS to call *before* exec'ing
  240. a new demo's code. Without this call, the old animation lambda keeps
  241. ticking against module globals (e.g. `gui`, `AppState`) that the new
  242. exec freshly rebinds — producing surprising AttributeErrors and a
  243. cascading teardown failure (see the inline comment around `_run_async`'s
  244. latex path for the same race in another shape).
  245. Safe to call repeatedly: subsequent calls when nothing is running do
  246. nothing. Safe to call before this module's runner has ever started.
  247. """
  248. global _MANUAL_RENDER_JS
  249. if _MANUAL_RENDER_JS is None:
  250. return
  251. if not _MANUAL_RENDER_JS.is_running:
  252. return
  253. _MANUAL_RENDER_JS._stop()
  254. def pyodide_do_patch_runners() -> None:
  255. # Instantiate global runners
  256. global _MANUAL_RENDER_JS
  257. # print("pyodide_do_patch_runners()")
  258. _log("pyodide_do_patch_runners: Version 12")
  259. _MANUAL_RENDER_JS = _ManualRenderJs()
  260. # Monkey patch the hello_imgui.run and immapp.run functions
  261. # In Pyodide, run() is fire-and-forget (returns immediately) since browsers cannot block
  262. hello_imgui.run = _MANUAL_RENDER_JS.run_hello_imgui
  263. immapp.run = _MANUAL_RENDER_JS.run_immapp
  264. # run_with_markdown is just run() with with_markdown=True baked in.
  265. # It must also be patched, otherwise it calls the blocking C++ Run() which crashes Pyodide.
  266. def _run_with_markdown_pyodide(gui_function: Any, *, with_markdown_options: Any = None, **kwargs: Any) -> None:
  267. kwargs["with_markdown"] = True
  268. if with_markdown_options is not None:
  269. kwargs["with_markdown_options"] = with_markdown_options
  270. _MANUAL_RENDER_JS.run_immapp(gui_function, **kwargs)
  271. immapp.run_with_markdown = _run_with_markdown_pyodide # type: ignore[assignment]
  272. # Add async versions for waiting until GUI exits
  273. immapp.run_async = _MANUAL_RENDER_JS.run_immapp_async
  274. hello_imgui.run_async = _MANUAL_RENDER_JS.run_hello_imgui_async