Skip to content

Commit

Permalink
refactor!: make widget composable by allowing children
Browse files Browse the repository at this point in the history
BREAKING CHANGE: this is a breaking change because of renaming
set_value to setValue on the frontend side.

This commit makes several changes, instead of having a single
ReactWidget we now have a Widget (with no default value trait)
and a ValueWidget (with a default value trait).

Furthermore, by specificing _module and _type instead of _esm
we can now render any React component from any ES module,
or even standard html components like <div> or <span>.

The main (wrapper) component is now created in the model, which
makes it easier to obtain the components of children. Once the
main wrapper component is created, the while children tree is also
resolved, and a synchroneous render can be made in one go.
  • Loading branch information
maartenbreddels committed Dec 27, 2023
1 parent 3011c5b commit 26125ef
Show file tree
Hide file tree
Showing 8 changed files with 497 additions and 184 deletions.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ Take any [Material UI example](https://mui.com/material-ui/react-rating/), copy/
import ipyreact


class ConfettiWidget(ipyreact.ReactWidget):
class ConfettiWidget(ipyreact.ValueWidget):
_esm = """
import confetti from "canvas-confetti";
import * as React from "react";
export default function({value, set_value, debug}) {
return <button onClick={() => confetti() && set_value(value + 1)}>
export default function({value, setValue, debug}) {
return <button onClick={() => confetti() && setValue(value + 1)}>
{value || 0} times confetti
</button>
};"""
Expand Down Expand Up @@ -90,8 +90,8 @@ Then use the `%%react` magic to directly write jsx/tsx in your notebook:
import confetti from "canvas-confetti";
import * as React from "react";

export default function({value, set_value, debug}) {
return <button onClick={() => confetti() && set_value(value + 1)}>
export default function({value, setValue, debug}) {
return <button onClick={() => confetti() && setValue(value + 1)}>
{value || 0} times confetti
</button>
};
Expand Down
2 changes: 1 addition & 1 deletion ipyreact/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# Distributed under the terms of the Modified BSD License.

from ._version import __version__
from .widget import ReactWidget
from .widget import ReactWidget, ValueWidget, Widget


def _jupyter_labextension_paths():
Expand Down
74 changes: 57 additions & 17 deletions ipyreact/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@
TODO: Add module docstring
"""

import typing as t
import warnings
from pathlib import Path

import anywidget
from traitlets import Any, Bool, Dict, Int, List, Unicode
from ipywidgets import ValueWidget as ValueWidgetClassic
from ipywidgets import Widget, widget_serialization
from traitlets import Any, Bool, Dict, Int, List, Unicode, observe

from ._frontend import module_name, module_version

HERE = Path(__file__).parent


class ReactWidget(anywidget.AnyWidget):
class Widget(anywidget.AnyWidget):
"""TODO: Add docstring here"""

_model_name = Unicode("ReactModel").tag(sync=True)
Expand All @@ -27,11 +31,17 @@ class ReactWidget(anywidget.AnyWidget):
_view_name = Unicode("ReactView").tag(sync=True)
_view_module = Unicode(module_name).tag(sync=True)
_view_module_version = Unicode(module_version).tag(sync=True)
value = Any(None, allow_none=True).tag(sync=True)
debug = Bool(False).tag(sync=True)
name = Unicode(None, allow_none=True).tag(sync=True)
react_version = Int(18).tag(sync=True)
props = Dict({}, allow_none=True).tag(sync=True)
children = List(t.cast(t.List[t.Union[Widget, str]], [])).tag(sync=True, **widget_serialization)

# this stays on the python side
events = Dict({})
# this is send of the frontend (keys of events)
_event_names = List(Unicode(), allow_none=True).tag(sync=True)
_debug = Bool(False).tag(sync=True)
_type = Unicode(None, allow_none=True).tag(sync=True)
_module = Unicode(None, allow_none=True).tag(sync=True)
_react_version = Int(18).tag(sync=True)
_cdn = Unicode("https://esm.sh/").tag
_import_map = Dict({}).tag(sync=True)
_import_map_default = {
Expand All @@ -42,33 +52,63 @@ class ReactWidget(anywidget.AnyWidget):
},
"scopes": {},
}
_esm = HERE / Path("basic.tsx")
_esm = ""
# _esm = HERE / Path("basic.tsx")

def __init__(self, **kwargs) -> None:
_esm = kwargs.pop("_esm", None)
if _esm is not None:
extra_traits = {}
if isinstance(_esm, str):
extra_traits["_esm"] = Unicode(str(_esm)).tag(sync=True)
self.add_traits(**extra_traits)
_import_map = kwargs.pop("_import_map", {})
_import_map = {
"imports": {**self._import_map_default["imports"], **_import_map.get("imports", {})},
"scopes": {**self._import_map_default["scopes"], **_import_map.get("scopes", {})},
}
kwargs["_import_map"] = _import_map
_ignore = ["on_msg", "on_displayed", "on_trait_change", "on_widget_constructed"]
_event_names = [
method_name[3:]
for method_name in dir(self)
if method_name.startswith("on_") and method_name not in _ignore
]
super().__init__(**{"_event_names": _event_names, **kwargs})
events = kwargs.pop("events", {})
for method_name in dir(self):
if method_name.startswith("event_") and method_name not in _ignore:
event_name = method_name[len("event_") :]
method = getattr(self, method_name)
if method_name not in events:
events[event_name] = method
_event_names = list(events)
super().__init__(**{"_event_names": _event_names, "events": events, **kwargs})
self.on_msg(self._handle_event)

def _handle_event(self, _, content, buffers):
if "event_name" in content.keys():
event_name = content.get("event_name", "")
data = content.get("data", {})
method = getattr(self, "on_" + event_name)
event_hander = self.events.get(event_name, None)
if event_hander is None:
return
if "data" not in content:
method()
event_hander()
else:
if buffers:
method(data, buffers)
event_hander(data, buffers)
else:
method(data)
event_hander(data)

@observe("events")
def _events(self, change):
self.event_names = list(change["new"].keys())


class ValueWidget(Widget, ValueWidgetClassic):
# the ValueWidget from ipywidgets does not add sync=True to the value trait
value = Any(help="The value of the widget.").tag(sync=True)


# this is deprecated
class ReactWidget(ValueWidget):
def __init__(self, **kwargs) -> None:
warnings.warn(
"ReactWidget is deprecated, use Widget or ValueWidget instead", DeprecationWarning
)
super().__init__(**kwargs)
32 changes: 32 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,35 @@ export async function loadScript(type: string, src: string) {
};
});
}

// based on https://stackoverflow.com/a/58416333/5397207
function pickSerializable(object: any, depth = 0, max_depth = 2) {
// change max_depth to see more levels, for a touch event, 2 is good
if (depth > max_depth) return "Object";

const obj: any = {};
for (let key in object) {
let value = object[key];
if (value instanceof Window) value = "Window";
else if (value && value.getModifierState)
value = pickSerializable(value, depth + 1, max_depth);
else {
// test if serializable
try {
JSON.stringify(value);
} catch (e) {
value = "Object";
}
}
obj[key] = value;
}

return obj;
}

export function eventToObject(event: any) {
if (event instanceof Event || (event && event.getModifierState)) {
return pickSerializable(event);
}
return event;
}
Loading

0 comments on commit 26125ef

Please sign in to comment.