Skip to content

Commit

Permalink
add a use_trait hook attached to widget wrapper
Browse files Browse the repository at this point in the history
  • Loading branch information
rmorshea committed May 20, 2023
1 parent 506def4 commit 9031259
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 78 deletions.
161 changes: 128 additions & 33 deletions notebooks/introduction.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,27 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 1,
"metadata": {
"tags": []
},
"outputs": [],
"outputs": [
{
"data": {
"application/vnd.jupyter.widget-view+json": {
"model_id": "901546cd31e04580810d8358cbf46d72",
"version_major": 2,
"version_minor": 0
},
"text/plain": [
"LayoutWidget(Layout(ContextProvider(<function context at 0x7fb48b0a3600>)))"
]
},
"execution_count": 1,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from reactpy import component, html\n",
"\n",
Expand All @@ -55,11 +71,27 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 2,
"metadata": {
"tags": []
},
"outputs": [],
"outputs": [
{
"data": {
"application/vnd.jupyter.widget-view+json": {
"model_id": "ee078bce581341f7826d8578cc03f971",
"version_major": 2,
"version_minor": 0
},
"text/plain": [
"LayoutWidget(Layout(ContextProvider(<function context at 0x7fb48b0a3600>)))"
]
},
"execution_count": 2,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from reactpy import component, html\n",
"\n",
Expand Down Expand Up @@ -109,11 +141,27 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 3,
"metadata": {
"tags": []
},
"outputs": [],
"outputs": [
{
"data": {
"application/vnd.jupyter.widget-view+json": {
"model_id": "3e5123eaa6fe49fcb94f2527ec7665e8",
"version_major": 2,
"version_minor": 0
},
"text/plain": [
"LayoutWidget(Layout(ContextProvider(<function context at 0x7fb48b0a3600>)))"
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import json\n",
"from pathlib import Path\n",
Expand Down Expand Up @@ -164,9 +212,25 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 4,
"metadata": {},
"outputs": [],
"outputs": [
{
"data": {
"application/vnd.jupyter.widget-view+json": {
"model_id": "cb6e7a22534d4db6b1c6b826689cf739",
"version_major": 2,
"version_minor": 0
},
"text/plain": [
"LayoutWidget(Layout(ContextProvider(<function context at 0x7fb48b0a3600>)))"
]
},
"execution_count": 4,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from reactpy_jupyter import from_widget\n",
"from ipywidgets import IntSlider\n",
Expand All @@ -182,38 +246,37 @@
"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:"
]
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 10,
"metadata": {
"tags": []
},
"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 @@ -227,15 +290,31 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 11,
"metadata": {
"tags": []
},
"outputs": [],
"outputs": [
{
"data": {
"application/vnd.jupyter.widget-view+json": {
"model_id": "48a4b16d9b7149fe9bbc8cbf5c20bd6c",
"version_major": 2,
"version_minor": 0
},
"text/plain": [
"LayoutWidget(Layout(ContextProvider(<function context at 0x7fb48b0a3600>)))"
]
},
"execution_count": 11,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from ipywidgets import IntSlider\n",
"\n",
"SliderObserver(IntSlider(readout=False))"
"MirrorSlider(IntSlider(readout=False))"
]
},
{
Expand All @@ -248,19 +327,35 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 9,
"metadata": {
"tags": []
},
"outputs": [],
"outputs": [
{
"data": {
"application/vnd.jupyter.widget-view+json": {
"model_id": "4fc27c4a9ae04351b140ca4bcb15e5be",
"version_major": 2,
"version_minor": 0
},
"text/plain": [
"Box(children=(LayoutWidget(Layout(ContextProvider(<function context at 0x7fb48b0a3600>))), LayoutWidget(Layout…"
]
},
"execution_count": 9,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from ipywidgets import Box\n",
"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_

0 comments on commit 9031259

Please sign in to comment.