diff --git a/README.md b/README.md index efa6dae..b4740c2 100755 --- a/README.md +++ b/README.md @@ -71,6 +71,60 @@ widget = VueTemplate( ) ``` +Explicit sync with $emit +------------------------ + +Every trait of a `VueTemplate` is two-way bound: the template can assign to it +directly and the change is synced back to Python. In addition, the template +can sync a trait explicitly with `$emit("update:", value)` (the same +contract as vue's `.sync` modifier / `v-model`), and send any event listed in +`events` with `$emit("", value)` instead of calling it as a method: + +```python +import traitlets +from ipyvue import VueTemplate + +class Counter(VueTemplate): + template = traitlets.Unicode(''' + + ''').tag(sync=True) + count = traitlets.Int(0).tag(sync=True) +``` + +This style makes the data flow explicit (read the value, emit the change), +matches how pure Vue components are written, and ports cleanly to vue3's +`defineModel()` semantics. + +To go fully vue-like, props declared in the template's ` + ''').tag(sync=True) + count = traitlets.Int(0).tag(sync=True) + +widget = Counter(template_props_support=True) +``` + Sponsors -------- diff --git a/examples/EmitSync.ipynb b/examples/EmitSync.ipynb new file mode 100644 index 0000000..e8de844 --- /dev/null +++ b/examples/EmitSync.ipynb @@ -0,0 +1,148 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Two ways of syncing: assign to data, or explicit $emit\n", + "\n", + "Every trait of a `VueTemplate` is two-way bound Vue data: the template can\n", + "assign to it directly and the change syncs back to Python." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import traitlets\n", + "from ipyvue import VueTemplate\n", + "\n", + "\n", + "class DataCounter(VueTemplate):\n", + " template = traitlets.Unicode(\n", + " \"\"\"\n", + " \n", + " \"\"\"\n", + " ).tag(sync=True)\n", + " count = traitlets.Int(0).tag(sync=True)\n", + "\n", + "\n", + "data_counter = DataCounter()\n", + "data_counter" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# the assignment in the template synced back to Python\n", + "data_counter.count" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The template can also sync a trait **explicitly** with\n", + "`$emit(\"update:\", value)` (the same contract as vue's `.sync`\n", + "modifier / `v-model`), and send any event listed in `events` with\n", + "`$emit(\"\", value)`.\n", + "\n", + "This style makes the data flow explicit (read the value, emit the change),\n", + "matches how pure Vue components are written, and ports cleanly to vue3's\n", + "`defineModel()` semantics." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class EmitCounter(VueTemplate):\n", + " template = traitlets.Unicode(\n", + " \"\"\"\n", + " \n", + " \"\"\"\n", + " ).tag(sync=True)\n", + " count = traitlets.Int(0).tag(sync=True)\n", + "\n", + "\n", + "emit_counter = EmitCounter()\n", + "emit_counter" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# the $emit(\"update:count\", ...) synced back to Python,\n", + "# and setting the trait still flows down into the prop\n", + "emit_counter.count = 10" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Fully vue-like: with `template_props_support`, props declared in the\n", + "template's `\n", + " ''').tag(sync=True)\n", + " count = traitlets.Int(0).tag(sync=True)\n", + "\n", + "\n", + "props_counter = PropsCounter(template_props_support=True)\n", + "props_counter" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/ipyvue/VueComponentRegistry.py b/ipyvue/VueComponentRegistry.py index 4015c79..c3b4e25 100644 --- a/ipyvue/VueComponentRegistry.py +++ b/ipyvue/VueComponentRegistry.py @@ -1,10 +1,21 @@ import os from traitlets import Unicode from ipywidgets import DOMWidget +from ipywidgets.widgets.widget import widget_serialization +from ipywidgets.widgets.widget_layout import Layout +from ipywidgets.widgets.trait_types import InstanceDict from ._version import semver class VueComponent(DOMWidget): + # model-only widget (a registry entry): an explicit layout costs a full + # Layout widget (comm_open + close) per registered component, per kernel. + # we can drop this when https://github.com/jupyter-widgets/ipywidgets/pull/3592 + # is merged + layout = InstanceDict(Layout, allow_none=True).tag( + sync=True, **widget_serialization + ) + _model_name = Unicode("VueComponentModel").tag(sync=True) _model_module = Unicode("jupyter-vue").tag(sync=True) _model_module_version = Unicode(semver).tag(sync=True) diff --git a/ipyvue/VueTemplateWidget.py b/ipyvue/VueTemplateWidget.py index 7bb524b..2b24221 100644 --- a/ipyvue/VueTemplateWidget.py +++ b/ipyvue/VueTemplateWidget.py @@ -2,6 +2,8 @@ from traitlets import Any, Bool, Unicode, List, Dict, Union, Instance, default from ipywidgets import DOMWidget from ipywidgets.widgets.widget import widget_serialization +from ipywidgets.widgets.widget_layout import Layout +from ipywidgets.widgets.trait_types import InstanceDict from .Template import Template, get_template from ._version import semver @@ -60,6 +62,9 @@ def resolve_ref(value): else: getattr(self, "vue_" + event)(data) + def _clear_event_handler(self): + self.on_msg(self._handle_event, remove=True) + def _value_to_json(x, obj): if inspect.isclass(x): @@ -89,6 +94,13 @@ def to_ref_structure(obj, path): class VueTemplate(DOMWidget, Events): + # like VueWidget: an explicit layout costs a full Layout widget (comm_open + # + close) per template widget; None means "no layout" on the vue side. + # we can drop this when https://github.com/jupyter-widgets/ipywidgets/pull/3592 + # is merged + layout = InstanceDict(Layout, allow_none=True).tag( + sync=True, **widget_serialization + ) class_component_serialization = { "from_json": widget_serialization["to_json"], @@ -127,6 +139,12 @@ class VueTemplate(DOMWidget, Events): def _default_scoped_css_support(self): return ipyvue.scoped_css_support + template_props_support = Bool(allow_none=False).tag(sync=True) + + @default("template_props_support") + def _default_template_props_support(self): + return ipyvue.template_props_support + methods = Unicode(None, allow_none=True).tag(sync=True) data = Unicode(None, allow_none=True).tag(sync=True) @@ -173,5 +191,9 @@ def on_ref_source_change(change): for traitlet in sync_ref_traitlets: create_ref_and_observe(traitlet) + def close(self): + self._clear_event_handler() + super().close() + __all__ = ["VueTemplate"] diff --git a/ipyvue/VueWidget.py b/ipyvue/VueWidget.py index 465f6a7..7562996 100644 --- a/ipyvue/VueWidget.py +++ b/ipyvue/VueWidget.py @@ -130,6 +130,9 @@ def _handle_event(self, _, content, buffers): data = content.get("data", {}) self._fire_event(event, data) + def _clear_event_handler(self): + self.on_msg(self._handle_event, remove=True) + class VueWidget(DOMWidget, Events): # we can drop this when https://github.com/jupyter-widgets/ipywidgets/pull/3592 @@ -192,5 +195,9 @@ def hide(self): self.class_list.add("d-none") + def close(self): + self._clear_event_handler() + super().close() + __all__ = ["VueWidget"] diff --git a/ipyvue/__init__.py b/ipyvue/__init__.py index 8482a96..422f792 100755 --- a/ipyvue/__init__.py +++ b/ipyvue/__init__.py @@ -27,6 +27,14 @@ def _parse_bool_env(key: str, default: bool = False) -> bool: # or changed at runtime: ipyvue.scoped_css_support = True scoped_css_support = _parse_bool_env("IPYVUE_SCOPED_CSS_SUPPORT", False) +# Honor the props declared in a VueTemplate's + """ + ).tag(sync=True) + count = traitlets.Int(0).tag(sync=True) + + +def test_template_props_honored(solara_test, page_session: playwright.sync_api.Page): + # with template_props_support, props declared in the template's + """ + + +# Watchers follow the vue API: they also receive the previous value +def test_watcher_old_value(solara_test, page_session: Page): + widget = WatcherOldValueTemplate() + + display(widget) + + element = page_session.locator("text=old: 0") + element.click() + page_session.locator("text=old: 0 new: 1").wait_for() + + +class WatcherObjectFormTemplate(vue.VueTemplate): + number = Int(0).tag(sync=True) + text = Unicode("start").tag(sync=True) + + @default("template") + def _default_vue_template(self): + return """ + + + """ + + +# Object-form watchers ({handler, deep}) are valid vue and must not crash +# when a synced trait changes from python +def test_watcher_object_form(solara_test, page_session: Page): + widget = WatcherObjectFormTemplate() + + display(widget) + + page_session.locator("text=start").wait_for() + widget.number = 3 + page_session.locator("text=object saw 0 -> 3").wait_for() + + +class WatcherObjectSyntaxTemplate(vue.VueTemplate): + number = Int(0).tag(sync=True) + text = Unicode("start").tag(sync=True) + + @default("template") + def _default_vue_template(self): + return """ + + + """ + + +# Object-syntax watchers ({handler, immediate}) on synced props follow the vue API +def test_watcher_object_syntax(solara_test, page_session: Page): + widget = WatcherObjectSyntaxTemplate() + + display(widget) + + element = page_session.locator("text=n=0 old=undefined") + element.click() + page_session.locator("text=n=1 old=0").wait_for()