diff --git a/tests/requirements.txt b/tests/requirements.txt
index 93c09e8..9f6c7d8 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -1,4 +1,5 @@
pytest
+pytest-asyncio
seleniumbase
pixelmatch
Pillow
diff --git a/tests/test_component.py b/tests/test_component.py
new file mode 100644
index 0000000..5164e23
--- /dev/null
+++ b/tests/test_component.py
@@ -0,0 +1,130 @@
+import asyncio
+import pytest
+
+from trame_client.widgets.core import TrameComponent
+from trame.decorators import change, controller, trigger
+from trame.app import get_server
+
+from trame.ui.html import DivLayout
+from trame.widgets import html
+
+CLIENT_TYPE = "vue3"
+
+
+class TestComponent(TrameComponent):
+ def __init__(self, server):
+ self._steps = []
+ super().__init__(server)
+
+ def validate(self):
+ assert self._steps == [
+ "a changed to 1",
+ "a changed to 2",
+ "ctrl.b() # set",
+ "ctrl.c() # add",
+ ]
+
+ @change("a")
+ def on_a(self, a, **_):
+ self._steps.append(f"a changed to {a}")
+
+ @controller.set("b")
+ def on_ctrl_b(self):
+ self._steps.append("ctrl.b() # set")
+
+ @controller.add("c")
+ def on_ctrl_c(self):
+ self._steps.append("ctrl.c() # add")
+
+ @trigger("hello")
+ def on_trigger(self): ...
+
+
+class WidgetComponent(html.Div):
+ def __init__(self, **kwargs):
+ self._steps = []
+ super().__init__(**kwargs)
+ self.state.setdefault("a", 1)
+
+ with self:
+ html.Label("Hello World {{ a }}")
+
+ def validate(self):
+ assert self._steps == [
+ "a changed to 1",
+ "a changed to 2",
+ "ctrl.b() # set",
+ "ctrl.c() # add",
+ ]
+
+ @change("a")
+ def on_a(self, a, **_):
+ self._steps.append(f"a changed to {a}")
+
+ @controller.set("b")
+ def on_ctrl_b(self):
+ self._steps.append("ctrl.b() # set")
+
+ @controller.add("c")
+ def on_ctrl_c(self):
+ self._steps.append("ctrl.c() # add")
+
+ @trigger("hello")
+ def on_trigger(self): ...
+
+
+@pytest.mark.asyncio
+async def test_component():
+ server = get_server("test_component", client_type=CLIENT_TYPE)
+ server.state.a = 1
+
+ component = TestComponent(server)
+
+ server.state.ready()
+ await asyncio.sleep(0.1)
+
+ with server.state:
+ server.state.a = 2
+
+ component.ctrl.b()
+ component.ctrl.c()
+
+ assert server.trigger_name(component.on_trigger) == "hello"
+
+ await asyncio.sleep(0.1)
+
+ component.validate()
+
+
+@pytest.mark.asyncio
+async def test_widget_as_component():
+ server = get_server("test_widget_as_component", client_type=CLIENT_TYPE)
+
+ with DivLayout(server) as layout:
+ WidgetComponent(ctx_name="test_comp")
+
+ assert (
+ layout.html
+ == """
+
+
+
+
"""
+ )
+
+ server.state.ready()
+ await asyncio.sleep(0.1)
+
+ with server.state:
+ server.state.a = 2
+
+ server.controller.b()
+ server.controller.c()
+
+ assert server.trigger_name(server.context.test_comp.on_trigger) == "hello"
+
+ await asyncio.sleep(0.1)
+
+ server.context.test_comp.validate()
diff --git a/trame_client/utils/formatter.py b/trame_client/utils/formatter.py
index 9b8e730..8a89a7c 100644
--- a/trame_client/utils/formatter.py
+++ b/trame_client/utils/formatter.py
@@ -41,7 +41,7 @@ def to_pretty_html(html_content: str) -> str:
color = COLOR_PALETTE[int(indent / indent_step) % len(COLOR_PALETTE)]
output_lines.append(
- f"{color}{' '*indent}{line.replace(' >', '>')}{BgColors.ENDC}"
+ f"{color}{' ' * indent}{line.replace(' >', '>')}{BgColors.ENDC}"
)
if delta > 0:
indent += compute_indent(line)
diff --git a/trame_client/widgets/core.py b/trame_client/widgets/core.py
index 2cbb648..c35cb6e 100644
--- a/trame_client/widgets/core.py
+++ b/trame_client/widgets/core.py
@@ -1,5 +1,7 @@
import sys
import logging
+import inspect
+
from ..utils.defaults import TrameDefault
from ..utils.formatter import to_pretty_html
@@ -67,6 +69,10 @@
logger = logging.getLogger(__name__)
+def can_be_decorated(x):
+ return inspect.ismethod(x) or inspect.isfunction(x)
+
+
def py2js_key(key):
return key.replace("_", "-")
@@ -211,7 +217,102 @@ def __call__(self, layout=None, **kwargs):
HTML_CTX.add_child(self)
-class AbstractElement:
+class TrameComponent:
+ """
+ Base trame class that has access to a trame server instance
+ on which we provide simple accessor and method decoration capabilities.
+ """
+
+ def __init__(self, server, ctx_name=None, **_):
+ """
+ Initialize TrameComponent with its server.
+
+ Keyword arguments:
+ server -- the server to link to (default None)
+ ctx_name -- name to use to bind current instance to server.context (default None)
+ """
+ self._server = server
+
+ if ctx_name:
+ self.ctx[ctx_name] = self
+
+ self._bind_annotated_methods()
+
+ @property
+ def server(self):
+ """Return the associated trame server instance"""
+ return self._server
+
+ @property
+ def state(self):
+ """Return the associated server state"""
+ return self.server.state
+
+ @property
+ def ctrl(self):
+ """Return the associated server controller"""
+ return self.server.controller
+
+ @property
+ def ctx(self):
+ """Return the associated server context"""
+ return self.server.context
+
+ def _bind_annotated_methods(self):
+ # Look for method decorator
+ for k in inspect.getmembers(self.__class__, can_be_decorated):
+ fn = getattr(self, k[0])
+
+ # Handle @state.change
+ s_translator = self.state.translator
+ if "_trame_state_change" in fn.__dict__:
+ state_change_names = fn.__dict__["_trame_state_change"]
+ logger.debug(
+ f"state.change({[f'{s_translator.translate_key(v)}' for v in state_change_names]})({k[0]})"
+ )
+ self.state.change(*[f"{v}" for v in state_change_names])(fn)
+
+ # Handle @trigger
+ if "_trame_trigger_names" in fn.__dict__:
+ trigger_names = fn.__dict__["_trame_trigger_names"]
+ for trigger_name in trigger_names:
+ logger.debug(f"trigger({trigger_name})({k[0]})")
+ self.server.trigger(f"{trigger_name}")(fn)
+
+ # Handle @ctrl.[add, once, add_task, set]
+ if "_trame_controller" in fn.__dict__:
+ actions = fn.__dict__["_trame_controller"]
+ for action in actions:
+ name = action.get("name")
+ method = action.get("method")
+ decorate = getattr(self.ctrl, method)
+ logger.debug(f"ctrl.{method}({name})({k[0]})")
+ decorate(name)(fn)
+
+ def _unbind_annotated_methods(self):
+ # Look for method decorator
+ for k in inspect.getmembers(self.__class__, can_be_decorated):
+ fn = getattr(self, k[0])
+
+ # Handle @state.change
+ methods_to_detach = {}
+ if "_trame_state_change" in fn.__dict__:
+ methods_to_detach.add(fn)
+
+ if methods_to_detach:
+ for fn_list in self.state._change_callbacks.values():
+ to_remove = set(fn_list) | methods_to_detach
+ for fn in to_remove:
+ fn_list.remove(fn)
+
+ # Handle @trigger
+ # TODO
+
+ # Handle @ctrl
+ # TODO
+
+
+class AbstractElement(TrameComponent):
"""
A Vue component which can integrate with the rest of trame
@@ -266,12 +367,19 @@ class AbstractElement:
>>> print(html.Template(raw_attrs=["v-slot:item.1", 'class="bg-red"', '@click.stop="a=2"']))
...
+
+ Context Name:
+
+ :param ctx_name: name to attach instance to server.context if provided
+
"""
_next_id = 1
_debug = "--debug" in sys.argv or "-d" in sys.argv
- def __init__(self, _elem_name, children=None, raw_attrs=None, **kwargs):
+ def __init__(
+ self, _elem_name, children=None, raw_attrs=None, ctx_name=None, **kwargs
+ ):
AbstractElement._next_id += 1
self._id = AbstractElement._next_id
self._server = kwargs.get("trame_server")
@@ -304,6 +412,8 @@ def __init__(self, _elem_name, children=None, raw_attrs=None, **kwargs):
# Add ourself to context if any
HTML_CTX.add_child(self)
+ super().__init__(self._server, ctx_name=ctx_name)
+
def _attr_str(self):
return " ".join(self._attributes.values())
@@ -332,30 +442,10 @@ def register_directive(py_name, js_name=None):
# App associated to HTML element
# -------------------------------------------------------------------------
- @property
- def server(self):
- """Return the associated server"""
- return self._server
-
def set_server(self, v):
"""Update the associated server"""
self._server = v
- @property
- def state(self):
- """Return the associated server state"""
- return self.server.state
-
- @property
- def ctrl(self):
- """Return the associated server controller"""
- return self.server.controller
-
- @property
- def ctx(self):
- """Return the associated server context"""
- return self.server.context
-
# -------------------------------------------------------------------------
# Buildin API
# -------------------------------------------------------------------------
diff --git a/trame_client/widgets/trame.py b/trame_client/widgets/trame.py
index 7bd3d40..38eb2c5 100644
--- a/trame_client/widgets/trame.py
+++ b/trame_client/widgets/trame.py
@@ -193,7 +193,7 @@ def __init__(
else:
extracts.append("value")
- self._attributes["slot"] = f'v-slot="{{ { ", ".join(extracts) } }}"'
+ self._attributes["slot"] = f'v-slot="{{ {", ".join(extracts)} }}"'
# -----------------------------------------------------------------------------