Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add a use_trait hook attached to widget wrapper #33

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 19 additions & 20 deletions notebooks/introduction.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
]
},
{
Expand All @@ -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",
" )"
]
},
Expand All @@ -235,7 +234,7 @@
"source": [
"from ipywidgets import IntSlider\n",
"\n",
"SliderObserver(IntSlider(readout=False))"
"MirrorSlider(IntSlider(readout=False))"
]
},
{
Expand All @@ -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])"
]
},
{
Expand Down
10 changes: 6 additions & 4 deletions reactpy_jupyter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,23 @@
# 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

__version__ = "0.9.5" # DO NOT MODIFY

__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",
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions reactpy_jupyter/hooks.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion reactpy_jupyter/import_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down
21 changes: 2 additions & 19 deletions reactpy_jupyter/monkey_patch.py
Original file line number Diff line number Diff line change
@@ -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_
59 changes: 46 additions & 13 deletions reactpy_jupyter/widget_component.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down