diff --git a/notebooks/introduction.ipynb b/notebooks/introduction.ipynb index 304c932..22b3360 100644 --- a/notebooks/introduction.ipynb +++ b/notebooks/introduction.ipynb @@ -182,7 +182,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Let's consider a ReactPy component that responds to and displays changes from an `ipywidgets.IntSlider`. The ReactPy component will need to accept an `IntSlider` instance as one of its arguments, convert it to a component with `from_widget`, declare state that will track the slider's value, and register a lister that will update that state via the slider's `IntSlider.observe()` method using an [\"effect\"](https://reactpy.dev/docs/reference/hooks-api.html#use-effect):" + "Let's consider a ReactPy component that mirrors an `ipywidgets.IntSlider` - that is, it displays a slider that moves when the `IntSlider` does and when moved alters the `IntSlider`. To accomplish this, the ReactPy component will need to accept an `IntSlider` instance as one of its arguments, convert it to a component with `from_widget`, and access the attributes it expects to change or that need to be changed via a `use_trait` method on the converted widget:" ] }, { @@ -193,27 +193,26 @@ }, "outputs": [], "source": [ - "from reactpy import use_effect\n", "from reactpy_jupyter import from_widget\n", "\n", "\n", "@component\n", - "def SliderObserver(slider):\n", - " slider_component = from_widget(slider)\n", - " value, set_value = use_state(0)\n", - "\n", - " @use_effect\n", - " def register_observer():\n", - " def handle_change(change):\n", - " set_value(change[\"new\"])\n", - "\n", - " # observe the slider's value\n", - " slider.observe(handle_change, \"value\")\n", - " # unobserve the slider's value if this component is no longer displayed\n", - " return lambda: slider.unobserve(handle_change, \"value\")\n", - "\n", + "def MirrorSlider(slider_widget):\n", + " slider_component = from_widget(slider_widget)\n", + " value, set_value = slider_component.use_trait(\"value\")\n", " return html.div(\n", - " slider_component, html.p(f\"ReactPy observes the value to be: \", value)\n", + " html.h3(\"Jupyter Slider\"),\n", + " # slider_component,\n", + " html.h3(\"ReactPy Slider\"),\n", + " html.input(\n", + " {\n", + " \"type\": \"range\",\n", + " \"min\": slider_widget.min,\n", + " \"max\": slider_widget.max,\n", + " \"value\": value,\n", + " \"on_change\": lambda event: set_value(event[\"target\"][\"value\"]),\n", + " }\n", + " ),\n", " )" ] }, @@ -235,7 +234,7 @@ "source": [ "from ipywidgets import IntSlider\n", "\n", - "SliderObserver(IntSlider(readout=False))" + "MirrorSlider(IntSlider(readout=False))" ] }, { @@ -258,9 +257,9 @@ "from reactpy_jupyter import to_widget\n", "\n", "slider = IntSlider(readout=False)\n", - "slider_observer_widget = to_widget(SliderObserver(slider))\n", + "slider_observer_widget = to_widget(MirrorSlider(slider))\n", "\n", - "Box([slider, slider_observer_widget])" + "Box([slider_observer_widget, slider_observer_widget])" ] }, { diff --git a/reactpy_jupyter/__init__.py b/reactpy_jupyter/__init__.py index 60ea3a6..098894d 100644 --- a/reactpy_jupyter/__init__.py +++ b/reactpy_jupyter/__init__.py @@ -5,8 +5,9 @@ # Distributed under the terms of the Modified BSD License. from . import jupyter_server_extension +from .component_widget import run, set_import_source_base_url, to_widget +from .hooks import use_trait from .import_resources import setup_import_resources -from .layout_widget import run, set_import_source_base_url, to_widget from .monkey_patch import execute_patch from .widget_component import from_widget @@ -14,12 +15,13 @@ __all__ = ( "from_widget", + "jupyter_server_extension", "load_ipython_extension", - "unload_ipython_extension", - "to_widget", "run", "set_import_source_base_url", - "jupyter_server_extension", + "to_widget", + "unload_ipython_extension", + "use_trait", ) diff --git a/reactpy_jupyter/layout_widget.py b/reactpy_jupyter/component_widget.py similarity index 92% rename from reactpy_jupyter/layout_widget.py rename to reactpy_jupyter/component_widget.py index 4a7de1a..52d6d85 100644 --- a/reactpy_jupyter/layout_widget.py +++ b/reactpy_jupyter/component_widget.py @@ -35,38 +35,38 @@ def run(constructor: Callable[[], ComponentType]) -> DisplayHandle | None: This function is meant to be similarly to ``reactpy.run``. """ - return ipython_display(LayoutWidget(constructor())) + return ipython_display(ComponentWidget(constructor())) _P = ParamSpec("_P") @overload -def to_widget(value: Callable[_P, ComponentType]) -> Callable[_P, LayoutWidget]: +def to_widget(value: Callable[_P, ComponentType]) -> Callable[_P, ComponentWidget]: ... @overload -def to_widget(value: ComponentType) -> LayoutWidget: +def to_widget(value: ComponentType) -> ComponentWidget: ... def to_widget( value: Callable[_P, ComponentType] | ComponentType -) -> Callable[_P, LayoutWidget] | LayoutWidget: +) -> Callable[_P, ComponentWidget] | ComponentWidget: """Turn a component into a widget or a component construtor into a widget constructor""" if isinstance(value, ComponentType): - return LayoutWidget(value) + return ComponentWidget(value) @wraps(value) - def wrapper(*args: Any, **kwargs: Any) -> LayoutWidget: - return LayoutWidget(value(*args, **kwargs)) + def wrapper(*args: Any, **kwargs: Any) -> ComponentWidget: + return ComponentWidget(value(*args, **kwargs)) return wrapper -class LayoutWidget(anywidget.AnyWidget): +class ComponentWidget(anywidget.AnyWidget): """A widget for displaying ReactPy elements""" _esm = ESM diff --git a/reactpy_jupyter/hooks.py b/reactpy_jupyter/hooks.py new file mode 100644 index 0000000..0356607 --- /dev/null +++ b/reactpy_jupyter/hooks.py @@ -0,0 +1,28 @@ +from typing import Any + +from reactpy import use_effect, use_state +from reactpy.types import State +from traitlets import HasTraits + + +def use_trait(obj: HasTraits, name: str) -> State[Any]: + """Hook to use the attribute of a HasTraits object as a state variable + + This works on Jupyter Widgets, for example. + """ + value, set_value = use_state(lambda: getattr(obj, name)) + + @use_effect + def register_observer(): + def handle_change(change): + set_value(change["new"]) + + # observe the slider's value + obj.observe(handle_change, "value") + # unobserve the slider's value if this component is no longer displayed + return lambda: obj.unobserve(handle_change, "value") + + def set_trait(new_value: Any) -> None: + setattr(obj, name, new_value) + + return State(value, set_trait) diff --git a/reactpy_jupyter/import_resources.py b/reactpy_jupyter/import_resources.py index e57d0bb..642f368 100644 --- a/reactpy_jupyter/import_resources.py +++ b/reactpy_jupyter/import_resources.py @@ -10,11 +10,11 @@ import requests from notebook import notebookapp +from .component_widget import set_import_source_base_url from .jupyter_server_extension import ( REACTPY_RESOURCE_BASE_PATH, REACTPY_WEB_MODULES_DIR, ) -from .layout_widget import set_import_source_base_url logger = logging.getLogger(__name__) diff --git a/reactpy_jupyter/monkey_patch.py b/reactpy_jupyter/monkey_patch.py index a60452e..1ffdf34 100644 --- a/reactpy_jupyter/monkey_patch.py +++ b/reactpy_jupyter/monkey_patch.py @@ -1,25 +1,8 @@ -from typing import Any -from weakref import finalize - from reactpy.core.component import Component -from reactpy_jupyter.layout_widget import to_widget - -# we can't track the widgets by adding them as a hidden attribute to the component -# because Component has __slots__ defined -LIVE_WIDGETS: dict[int, Any] = {} +from reactpy_jupyter.widget_component import WidgetComponent def execute_patch() -> None: """Monkey patch ReactPy's Component class to display as a Jupyter widget""" - - def _repr_mimebundle_(self: Component, *a, **kw) -> None: - self_id = id(self) - if self_id not in LIVE_WIDGETS: - widget = LIVE_WIDGETS[self_id] = to_widget(self) - finalize(self, lambda: LIVE_WIDGETS.pop(self_id, None)) - else: - widget = LIVE_WIDGETS[self_id] - return widget._repr_mimebundle_(*a, **kw) - - Component._repr_mimebundle_ = _repr_mimebundle_ + Component._repr_mimebundle_ = WidgetComponent._repr_mimebundle_ diff --git a/reactpy_jupyter/widget_component.py b/reactpy_jupyter/widget_component.py index fd96971..61550e0 100644 --- a/reactpy_jupyter/widget_component.py +++ b/reactpy_jupyter/widget_component.py @@ -1,28 +1,61 @@ from __future__ import annotations -from typing import Callable +from typing import Any, Callable +from weakref import finalize from attr import dataclass from ipywidgets import Widget -from reactpy import component, create_context, html, use_context, use_effect -from reactpy.types import Context, VdomDict +from reactpy import create_context, html, use_context, use_effect +from reactpy.types import Context, Key, State, VdomDict + +import reactpy_jupyter +from reactpy_jupyter.hooks import use_trait as _use_trait + +# we can't track the widgets by adding them as a hidden attribute to the component +# because Component has __slots__ defined +LIVE_WIDGETS: dict[int, Any] = {} inner_widgets_context: Context[InnerWidgets | None] = create_context(None) -@component -def from_widget(source: Widget) -> VdomDict: - inner_widgets = use_context(inner_widgets_context) +def from_widget(source: Widget, key: Key | None = None) -> WidgetComponent: + return WidgetComponent(source, key) + + +class WidgetComponent: + """implements reactpy.types.ComponentType""" + + def __init__(self, widget: Widget, key: Key | None) -> None: + self.widget = widget + self.type = type(widget) + self.key = key + + def use_trait(self, name: str) -> State[Any]: + return _use_trait(self.widget, name) + + def render(self) -> VdomDict: + inner_widgets = use_context(inner_widgets_context) + + @use_effect + def add_widget(): + inner_widgets.add(self.widget) + return lambda: inner_widgets.remove(self.widget) - @use_effect - def add_widget(): - inner_widgets.add(source) - return lambda: inner_widgets.remove(source) + if inner_widgets is None: + raise RuntimeError( + "Jupyter component must be rendered inside a JupyterLayout" + ) - if inner_widgets is None: - raise RuntimeError("Jupyter component must be rendered inside a JupyterLayout") + return html.span({"class": f"widget-model-id-{self.widget.model_id}"}) - return html.span({"class": f"widget-model-id-{source.model_id}"}) + def _repr_mimebundle_(self, *args: Any, **kwargs: Any) -> None: + self_id = id(self) + if self_id not in LIVE_WIDGETS: + widget = LIVE_WIDGETS[self_id] = reactpy_jupyter.to_widget(self) + finalize(self, lambda: LIVE_WIDGETS.pop(self_id, None)) + else: + widget = LIVE_WIDGETS[self_id] + return widget._repr_mimebundle_(*args, **kwargs) @dataclass