浏览代码

Merge pull request #1095 from zauberzeug/events

Improve event registration
Falko Schindler 1 年之前
父节点
当前提交
d9ab5e7132

+ 1 - 1
examples/custom_vue_component/main.py

@@ -9,7 +9,7 @@ ui.markdown('''
 Click to increment its value.
 ''')
 with ui.card():
-    counter = Counter('Clicks', on_change=lambda msg: ui.notify(f'The value changed to {msg["args"]}.'))
+    counter = Counter('Clicks', on_change=lambda e: ui.notify(f'The value changed to {e.args}.'))
 
 
 ui.button('Reset', on_click=counter.reset).props('small outline')

+ 4 - 4
examples/local_file_picker/local_file_picker.py

@@ -1,8 +1,8 @@
 import platform
 from pathlib import Path
-from typing import Dict, Optional
+from typing import Optional
 
-from nicegui import ui
+from nicegui import events, ui
 
 
 class local_file_picker(ui.dialog):
@@ -70,8 +70,8 @@ class local_file_picker(ui.dialog):
             })
         self.grid.update()
 
-    def handle_double_click(self, msg: Dict) -> None:
-        self.path = Path(msg['args']['data']['path'])
+    def handle_double_click(self, e: events.GenericEventArguments) -> None:
+        self.path = Path(e.args['data']['path'])
         if self.path.is_dir():
             self.update_grid()
         else:

+ 1 - 1
examples/single_page_app/router.py

@@ -42,6 +42,6 @@ class Router():
 
     def frame(self) -> ui.element:
         self.content = ui.element('router_frame') \
-            .on('open', lambda msg: self.open(msg['args'])) \
+            .on('open', lambda e: self.open(e.args)) \
             .use_component('router_frame')
         return self.content

+ 3 - 7
nicegui/element.py

@@ -1,7 +1,6 @@
 from __future__ import annotations
 
 import re
-import warnings
 from copy import deepcopy
 from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union
 
@@ -222,14 +221,10 @@ class Element(Visibility):
         :param trailing_events: whether to trigger the event handler after the last event occurrence (default: `True`)
         """
         if handler:
-            if args and '*' in args:
-                url = f'https://github.com/zauberzeug/nicegui/issues/644'
-                warnings.warn(DeprecationWarning(f'Event args "*" is deprecated, omit this parameter instead ({url})'))
-                args = None
             listener = EventListener(
                 element_id=self.id,
                 type=type,
-                args=args,
+                args=[args] if args and isinstance(args[0], str) else args,
                 handler=handler,
                 throttle=throttle,
                 leading_events=leading_events,
@@ -243,7 +238,8 @@ class Element(Visibility):
     def _handle_event(self, msg: Dict) -> None:
         listener = self._event_listeners[msg['listener_id']]
         storage.request_contextvar.set(listener.request)
-        events.handle_event(listener.handler, msg, sender=self)
+        args = events.GenericEventArguments(sender=self, client=self.client, args=msg['args'])
+        events.handle_event(listener.handler, args)
 
     def update(self) -> None:
         """Update the element on the client side."""

+ 2 - 2
nicegui/elements/button.py

@@ -35,7 +35,7 @@ class Button(TextElement, DisableableElement, BackgroundColorElement):
             self._props['icon'] = icon
 
         if on_click:
-            self.on('click', lambda _: handle_event(on_click, ClickEventArguments(sender=self, client=self.client)))
+            self.on('click', lambda _: handle_event(on_click, ClickEventArguments(sender=self, client=self.client)), [])
 
     def _text_to_model_text(self, text: str) -> None:
         self._props['label'] = text
@@ -43,6 +43,6 @@ class Button(TextElement, DisableableElement, BackgroundColorElement):
     async def clicked(self) -> None:
         """Wait until the button is clicked."""
         event = asyncio.Event()
-        self.on('click', event.set)
+        self.on('click', event.set, [])
         await self.client.connected()
         await event.wait()

+ 4 - 5
nicegui/elements/color_picker.py

@@ -1,8 +1,7 @@
-from typing import Any, Callable, Dict
-
-from nicegui.events import ColorPickEventArguments, handle_event
+from typing import Any, Callable
 
 from ..element import Element
+from ..events import ColorPickEventArguments, GenericEventArguments, handle_event
 from .menu import Menu
 
 
@@ -16,8 +15,8 @@ class ColorPicker(Menu):
         """
         super().__init__(value=value)
         with self:
-            def handle_change(msg: Dict):
-                handle_event(on_pick, ColorPickEventArguments(sender=self, client=self.client, color=msg['args']))
+            def handle_change(e: GenericEventArguments):
+                handle_event(on_pick, ColorPickEventArguments(sender=self, client=self.client, color=e.args))
             self.q_color = Element('q-color').on('change', handle_change)
 
     def set_color(self, color: str) -> None:

+ 0 - 1
nicegui/elements/date.py

@@ -5,7 +5,6 @@ from .mixins.value_element import ValueElement
 
 
 class Date(ValueElement, DisableableElement):
-    EVENT_ARGS = None
 
     def __init__(self,
                  value: Optional[str] = None,

+ 12 - 12
nicegui/elements/interactive_image.py

@@ -1,10 +1,10 @@
 from __future__ import annotations
 
 from pathlib import Path
-from typing import Any, Callable, Dict, List, Optional, Union
+from typing import Any, Callable, List, Optional, Union
 
 from ..dependencies import register_vue_component
-from ..events import MouseEventArguments, handle_event
+from ..events import GenericEventArguments, MouseEventArguments, handle_event
 from .mixins.content_element import ContentElement
 from .mixins.source_element import SourceElement
 
@@ -40,21 +40,21 @@ class InteractiveImage(SourceElement, ContentElement):
         self._props['cross'] = cross
         self.use_component('interactive_image')
 
-        def handle_mouse(msg: Dict) -> None:
+        def handle_mouse(e: GenericEventArguments) -> None:
             if on_mouse is None:
                 return
             arguments = MouseEventArguments(
                 sender=self,
                 client=self.client,
-                type=msg['args'].get('mouse_event_type'),
-                image_x=msg['args'].get('image_x'),
-                image_y=msg['args'].get('image_y'),
-                button=msg['args'].get('button', 0),
-                buttons=msg['args'].get('buttons', 0),
-                alt=msg['args'].get('alt', False),
-                ctrl=msg['args'].get('ctrl', False),
-                meta=msg['args'].get('meta', False),
-                shift=msg['args'].get('shift', False),
+                type=e.args.get('mouse_event_type'),
+                image_x=e.args.get('image_x'),
+                image_y=e.args.get('image_y'),
+                button=e.args.get('button', 0),
+                buttons=e.args.get('buttons', 0),
+                alt=e.args.get('alt', False),
+                ctrl=e.args.get('ctrl', False),
+                meta=e.args.get('meta', False),
+                shift=e.args.get('shift', False),
             )
             return handle_event(on_mouse, arguments)
         self.on('mouse', handle_mouse)

+ 8 - 8
nicegui/elements/joystick.py

@@ -1,9 +1,9 @@
 from pathlib import Path
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, Optional
 
 from ..dependencies import register_library, register_vue_component
 from ..element import Element
-from ..events import JoystickEventArguments, handle_event
+from ..events import GenericEventArguments, JoystickEventArguments, handle_event
 
 register_vue_component('joystick', Path(__file__).parent / 'joystick.vue')
 register_library('nipplejs', Path(__file__).parent / 'lib' / 'nipplejs' / 'nipplejs.js')
@@ -38,13 +38,13 @@ class Joystick(Element):
                                                           client=self.client,
                                                           action='start'))
 
-        def handle_move(msg: Dict) -> None:
+        def handle_move(e: GenericEventArguments) -> None:
             if self.active:
                 handle_event(on_move, JoystickEventArguments(sender=self,
                                                              client=self.client,
                                                              action='move',
-                                                             x=float(msg['args']['data']['vector']['x']),
-                                                             y=float(msg['args']['data']['vector']['y'])))
+                                                             x=float(e.args['data']['vector']['x']),
+                                                             y=float(e.args['data']['vector']['y'])))
 
         def handle_end() -> None:
             self.active = False
@@ -52,6 +52,6 @@ class Joystick(Element):
                                                         client=self.client,
                                                         action='end'))
 
-        self.on('start', handle_start)
-        self.on('move', handle_move, args=['data'], throttle=throttle),
-        self.on('end', handle_end)
+        self.on('start', handle_start, [])
+        self.on('move', handle_move, ['data'], throttle=throttle),
+        self.on('end', handle_end, [])

+ 14 - 13
nicegui/elements/keyboard.py

@@ -1,12 +1,13 @@
 from pathlib import Path
-from typing import Any, Callable, Dict, List
+from typing import Any, Callable, List
 
 from typing_extensions import Literal
 
 from ..binding import BindableProperty
 from ..dependencies import register_vue_component
 from ..element import Element
-from ..events import KeyboardAction, KeyboardKey, KeyboardModifiers, KeyEventArguments, handle_event
+from ..events import (GenericEventArguments, KeyboardAction, KeyboardKey, KeyboardModifiers, KeyEventArguments,
+                      handle_event)
 
 register_vue_component('keyboard', Path(__file__).parent / 'keyboard.js')
 
@@ -38,25 +39,25 @@ class Keyboard(Element):
         self.on('key', self.handle_key)
         self.use_component('keyboard')
 
-    def handle_key(self, msg: Dict) -> None:
+    def handle_key(self, e: GenericEventArguments) -> None:
         if not self.active:
             return
 
         action = KeyboardAction(
-            keydown=msg['args']['action'] == 'keydown',
-            keyup=msg['args']['action'] == 'keyup',
-            repeat=msg['args']['repeat'],
+            keydown=e.args['action'] == 'keydown',
+            keyup=e.args['action'] == 'keyup',
+            repeat=e.args['repeat'],
         )
         modifiers = KeyboardModifiers(
-            alt=msg['args']['altKey'],
-            ctrl=msg['args']['ctrlKey'],
-            meta=msg['args']['metaKey'],
-            shift=msg['args']['shiftKey'],
+            alt=e.args['altKey'],
+            ctrl=e.args['ctrlKey'],
+            meta=e.args['metaKey'],
+            shift=e.args['shiftKey'],
         )
         key = KeyboardKey(
-            name=msg['args']['key'],
-            code=msg['args']['code'],
-            location=msg['args']['location'],
+            name=e.args['key'],
+            code=e.args['code'],
+            location=e.args['location'],
         )
         arguments = KeyEventArguments(
             sender=self,

+ 1 - 2
nicegui/elements/menu.py

@@ -7,7 +7,6 @@ from .mixins.value_element import ValueElement
 
 
 class Menu(ValueElement):
-    LOOPBACK = False
 
     def __init__(self, *, value: bool = False) -> None:
         """Menu
@@ -56,4 +55,4 @@ class MenuItem(TextElement):
             if auto_close:
                 assert isinstance(self.menu, Menu)
                 self.menu.close()
-        self.on('click', handle_click)
+        self.on('click', handle_click, [])

+ 7 - 8
nicegui/elements/mixins/value_element.py

@@ -1,15 +1,14 @@
-from typing import Any, Callable, Dict, List, Optional
+from typing import Any, Callable, List, Optional
 
 from typing_extensions import Self
 
 from ...binding import BindableProperty, bind, bind_from, bind_to
 from ...element import Element
-from ...events import ValueChangeEventArguments, handle_event
+from ...events import GenericEventArguments, ValueChangeEventArguments, handle_event
 
 
 class ValueElement(Element):
     VALUE_PROP: str = 'model-value'
-    EVENT_ARGS: Optional[List[str]] = ['value']
     LOOPBACK: bool = True
     value = BindableProperty(on_change=lambda sender, value: sender.on_value_change(value))
 
@@ -26,11 +25,11 @@ class ValueElement(Element):
         self._send_update_on_value_change = True
         self.change_handler = on_value_change
 
-        def handle_change(msg: Dict) -> None:
+        def handle_change(e: GenericEventArguments) -> None:
             self._send_update_on_value_change = self.LOOPBACK
-            self.set_value(self._msg_to_value(msg))
+            self.set_value(self._event_args_to_value(e))
             self._send_update_on_value_change = True
-        self.on(f'update:{self.VALUE_PROP}', handle_change, self.EVENT_ARGS, throttle=throttle)
+        self.on(f'update:{self.VALUE_PROP}', handle_change, [None], throttle=throttle)
 
     def bind_value_to(self,
                       target_object: Any,
@@ -100,8 +99,8 @@ class ValueElement(Element):
         args = ValueChangeEventArguments(sender=self, client=self.client, value=self._value_to_event_value(value))
         handle_event(self.change_handler, args)
 
-    def _msg_to_value(self, msg: Dict) -> Any:
-        return msg['args']
+    def _event_args_to_value(self, e: GenericEventArguments) -> Any:
+        return e.args
 
     def _value_to_model_value(self, value: Any) -> Any:
         return value

+ 4 - 3
nicegui/elements/number.py

@@ -1,5 +1,6 @@
 from typing import Any, Callable, Dict, Optional
 
+from ..events import GenericEventArguments
 from .mixins.disableable_element import DisableableElement
 from .mixins.validation_element import ValidationElement
 
@@ -56,7 +57,7 @@ class Number(ValidationElement, DisableableElement):
             self._props['prefix'] = prefix
         if suffix is not None:
             self._props['suffix'] = suffix
-        self.on('blur', self.sanitize)
+        self.on('blur', self.sanitize, [])
 
     @property
     def min(self) -> float:
@@ -89,8 +90,8 @@ class Number(ValidationElement, DisableableElement):
         value = min(value, self.max)
         self.set_value(float(self.format % value) if self.format else value)
 
-    def _msg_to_value(self, msg: Dict) -> Any:
-        return float(msg['args']) if msg['args'] else None
+    def _event_args_to_value(self, e: GenericEventArguments) -> Any:
+        return float(e.args) if e.args else None
 
     def _value_to_model_value(self, value: Any) -> Any:
         if value is None:

+ 3 - 2
nicegui/elements/radio.py

@@ -1,5 +1,6 @@
 from typing import Any, Callable, Dict, List, Optional, Union
 
+from ..events import GenericEventArguments
 from .choice_element import ChoiceElement
 from .mixins.disableable_element import DisableableElement
 
@@ -22,8 +23,8 @@ class Radio(ChoiceElement, DisableableElement):
         """
         super().__init__(tag='q-option-group', options=options, value=value, on_change=on_change)
 
-    def _msg_to_value(self, msg: Dict) -> Any:
-        return self._values[msg['args']]
+    def _event_args_to_value(self, e: GenericEventArguments) -> Any:
+        return self._values[e.args]
 
     def _value_to_model_value(self, value: Any) -> Any:
         return self._values.index(value) if value in self._values else None

+ 11 - 11
nicegui/elements/scene.py

@@ -5,7 +5,7 @@ from typing import Any, Callable, Dict, List, Optional, Union
 from .. import binding, globals
 from ..dependencies import register_library, register_vue_component
 from ..element import Element
-from ..events import SceneClickEventArguments, SceneClickHit, handle_event
+from ..events import GenericEventArguments, SceneClickEventArguments, SceneClickHit, handle_event
 from ..helpers import KWONLY_SLOTS
 from .scene_object3d import Object3D
 
@@ -90,9 +90,9 @@ class Scene(Element):
         self.use_library('STLLoader')
         self.use_library('tween')
 
-    def handle_init(self, msg: Dict) -> None:
+    def handle_init(self, e: GenericEventArguments) -> None:
         self.is_initialized = True
-        with globals.socket_id(msg['args']):
+        with globals.socket_id(e.args):
             self.move_camera(duration=0)
             for object in self.objects.values():
                 object.send()
@@ -102,23 +102,23 @@ class Scene(Element):
             return
         super().run_method(name, *args)
 
-    def handle_click(self, msg: Dict) -> None:
+    def handle_click(self, e: GenericEventArguments) -> None:
         arguments = SceneClickEventArguments(
             sender=self,
             client=self.client,
-            click_type=msg['args']['click_type'],
-            button=msg['args']['button'],
-            alt=msg['args']['alt_key'],
-            ctrl=msg['args']['ctrl_key'],
-            meta=msg['args']['meta_key'],
-            shift=msg['args']['shift_key'],
+            click_type=e.args['click_type'],
+            button=e.args['button'],
+            alt=e.args['alt_key'],
+            ctrl=e.args['ctrl_key'],
+            meta=e.args['meta_key'],
+            shift=e.args['shift_key'],
             hits=[SceneClickHit(
                 object_id=hit['object_id'],
                 object_name=hit['object_name'],
                 x=hit['point']['x'],
                 y=hit['point']['y'],
                 z=hit['point']['z'],
-            ) for hit in msg['args']['hits']],
+            ) for hit in e.args['hits']],
         )
         handle_event(self.on_click, arguments)
 

+ 9 - 10
nicegui/elements/select.py

@@ -3,8 +3,8 @@ from copy import deepcopy
 from pathlib import Path
 from typing import Any, Callable, Dict, List, Optional, Union
 
-from nicegui.dependencies import register_vue_component
-
+from ..dependencies import register_vue_component
+from ..events import GenericEventArguments
 from .choice_element import ChoiceElement
 from .mixins.disableable_element import DisableableElement
 
@@ -36,7 +36,6 @@ class Select(ChoiceElement, DisableableElement):
         """
         self.multiple = multiple
         if multiple:
-            self.EVENT_ARGS = None
             if value is None:
                 value = []
             elif not isinstance(value, list):
@@ -54,25 +53,25 @@ class Select(ChoiceElement, DisableableElement):
         self._props['multiple'] = multiple
         self._props['clearable'] = clearable
 
-    def on_filter(self, event: Dict) -> None:
+    def on_filter(self, e: GenericEventArguments) -> None:
         self.options = [
             option
             for option in self.original_options
-            if not event['args'] or re.search(event['args'], option, re.IGNORECASE)
+            if not e.args or re.search(e.args, option, re.IGNORECASE)
         ]
         self.update()
 
-    def _msg_to_value(self, msg: Dict) -> Any:
+    def _event_args_to_value(self, e: GenericEventArguments) -> Any:
         if self.multiple:
-            if msg['args'] is None:
+            if e.args is None:
                 return []
             else:
-                return [self._values[arg['value']] for arg in msg['args']]
+                return [self._values[arg['value']] for arg in e.args]
         else:
-            if msg['args'] is None:
+            if e.args is None:
                 return None
             else:
-                return self._values[msg['args']['value']]
+                return self._values[e.args['value']]
 
     def _value_to_model_value(self, value: Any) -> Any:
         if self.multiple:

+ 6 - 6
nicegui/elements/table.py

@@ -5,7 +5,7 @@ from typing_extensions import Literal
 
 from ..dependencies import register_vue_component
 from ..element import Element
-from ..events import TableSelectionEventArguments, handle_event
+from ..events import GenericEventArguments, TableSelectionEventArguments, handle_event
 from .mixins.filter_element import FilterElement
 
 register_vue_component('nicegui-table', Path(__file__).parent / 'table.js')
@@ -51,17 +51,17 @@ class Table(FilterElement):
         self._props['selection'] = selection or 'none'
         self._props['selected'] = self.selected
 
-        def handle_selection(msg: Dict) -> None:
-            if msg['args']['added']:
+        def handle_selection(e: GenericEventArguments) -> None:
+            if e.args['added']:
                 if selection == 'single':
                     self.selected.clear()
-                self.selected.extend(msg['args']['rows'])
+                self.selected.extend(e.args['rows'])
             else:
-                self.selected[:] = [row for row in self.selected if row[row_key] not in msg['args']['keys']]
+                self.selected[:] = [row for row in self.selected if row[row_key] not in e.args['keys']]
             self.update()
             arguments = TableSelectionEventArguments(sender=self, client=self.client, selection=self.selected)
             handle_event(on_select, arguments)
-        self.on('selection', handle_selection)
+        self.on('selection', handle_selection, ['added', 'rows', 'keys'])
 
         self.use_component('nicegui-table')
 

+ 3 - 2
nicegui/elements/toggle.py

@@ -1,5 +1,6 @@
 from typing import Any, Callable, Dict, List, Optional, Union
 
+from ..events import GenericEventArguments
 from .choice_element import ChoiceElement
 from .mixins.disableable_element import DisableableElement
 
@@ -22,8 +23,8 @@ class Toggle(ChoiceElement, DisableableElement):
         """
         super().__init__(tag='q-btn-toggle', options=options, value=value, on_change=on_change)
 
-    def _msg_to_value(self, msg: Dict) -> Any:
-        return self._values[msg['args']]
+    def _event_args_to_value(self, e: GenericEventArguments) -> Any:
+        return self._values[e.args]
 
     def _value_to_model_value(self, value: Any) -> Any:
         return self._values.index(value) if value in self._values else None

+ 11 - 12
nicegui/elements/tree.py

@@ -1,8 +1,7 @@
-from typing import Any, Callable, Dict, List, Optional
-
-from nicegui.events import ValueChangeEventArguments, handle_event
+from typing import Any, Callable, List, Optional
 
 from ..element import Element
+from ..events import GenericEventArguments, ValueChangeEventArguments, handle_event
 
 
 class Tree(Element):
@@ -43,17 +42,17 @@ class Tree(Element):
                 self._props[name] = value
                 self.update()
 
-        def handle_selected(msg: Dict) -> None:
-            update_prop('selected', msg['args'])
-            handle_event(on_select, ValueChangeEventArguments(sender=self, client=self.client, value=msg['args']))
+        def handle_selected(e: GenericEventArguments) -> None:
+            update_prop('selected', e.args)
+            handle_event(on_select, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
         self.on('update:selected', handle_selected)
 
-        def handle_expanded(msg: Dict) -> None:
-            update_prop('expanded', msg['args'])
-            handle_event(on_expand, ValueChangeEventArguments(sender=self, client=self.client, value=msg['args']))
+        def handle_expanded(e: GenericEventArguments) -> None:
+            update_prop('expanded', e.args)
+            handle_event(on_expand, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
         self.on('update:expanded', handle_expanded)
 
-        def handle_ticked(msg: Dict) -> None:
-            update_prop('ticked', msg['args'])
-            handle_event(on_tick, ValueChangeEventArguments(sender=self, client=self.client, value=msg['args']))
+        def handle_ticked(e: GenericEventArguments) -> None:
+            update_prop('ticked', e.args)
+            handle_event(on_tick, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
         self.on('update:ticked', handle_ticked)

+ 1 - 1
nicegui/event_listener.py

@@ -12,7 +12,7 @@ class EventListener:
     id: str = field(init=False)
     element_id: int
     type: str
-    args: Optional[List[str]]
+    args: List[Optional[List[str]]]
     handler: Callable
     throttle: float
     leading_events: bool

+ 18 - 9
nicegui/events.py

@@ -1,6 +1,6 @@
 from dataclasses import dataclass
 from inspect import Parameter, signature
-from typing import TYPE_CHECKING, Any, Awaitable, BinaryIO, Callable, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Awaitable, BinaryIO, Callable, Dict, List, Optional
 
 from . import background_tasks, globals
 from .helpers import KWONLY_SLOTS
@@ -16,6 +16,18 @@ class EventArguments:
     client: 'Client'
 
 
+@dataclass(**KWONLY_SLOTS)
+class GenericEventArguments(EventArguments):
+    args: Dict[str, Any]
+
+    def __getitem__(self, key: str) -> Any:
+        if key == 'args':
+            globals.log.warning('msg["args"] is deprecated, use e.args instead '
+                                '(see https://github.com/zauberzeug/nicegui/pull/1095)')
+            return self.args
+        raise KeyError(key)
+
+
 @dataclass(**KWONLY_SLOTS)
 class ClickEventArguments(EventArguments):
     pass
@@ -268,22 +280,19 @@ class KeyEventArguments(EventArguments):
     modifiers: KeyboardModifiers
 
 
-def handle_event(handler: Optional[Callable[..., Any]],
-                 arguments: Union[EventArguments, Dict], *,
-                 sender: Optional['Element'] = None) -> None:
+def handle_event(handler: Optional[Callable[..., Any]], arguments: EventArguments) -> None:
     if handler is None:
         return
     try:
         no_arguments = not any(p.default is Parameter.empty for p in signature(handler).parameters.values())
-        sender = arguments.sender if isinstance(arguments, EventArguments) else sender
-        assert sender is not None and sender.parent_slot is not None
-        if sender.is_ignoring_events:
+        assert arguments.sender.parent_slot is not None
+        if arguments.sender.is_ignoring_events:
             return
-        with sender.parent_slot:
+        with arguments.sender.parent_slot:
             result = handler() if no_arguments else handler(arguments)
         if isinstance(result, Awaitable):
             async def wait_for_result():
-                with sender.parent_slot:
+                with arguments.sender.parent_slot:
                     await result
             if globals.loop and globals.loop.is_running():
                 background_tasks.create(wait_for_result(), name=str(handler))

+ 5 - 0
nicegui/helpers.py

@@ -27,6 +27,11 @@ mimetypes.init()
 KWONLY_SLOTS = {'kw_only': True, 'slots': True} if sys.version_info >= (3, 10) else {}
 
 
+def is_pytest() -> bool:
+    """Check if the code is running in pytest."""
+    return 'pytest' in sys.modules
+
+
 def is_coroutine_function(object: Any) -> bool:
     """Check if the object is a coroutine function.
 

+ 3 - 0
nicegui/nicegui.py

@@ -170,6 +170,9 @@ def handle_event(sid: str, msg: Dict) -> None:
     with client:
         sender = client.elements.get(msg['id'])
         if sender:
+            msg['args'] = [json.loads(arg) for arg in msg.get('args', [])]
+            if len(msg['args']) == 1:
+                msg['args'] = msg['args'][0]
             sender._handle_event(msg)
 
 

+ 27 - 4
nicegui/templates/index.html

@@ -44,6 +44,26 @@
       const loaded_components = new Set();
       const elements = {{ elements | safe }};
 
+      function stringifyEventArgs(args, event_args) {
+        const result = [];
+        args.forEach((arg, i) => {
+          if (event_args !== null && i >= event_args.length) return;
+          let filtered = {};
+          if (typeof arg !== 'object' || arg === null || Array.isArray(arg)) {
+            filtered = arg;
+          }
+          else {
+            for (let k in arg) {
+              if (event_args === null || event_args[i] === null || event_args[i].includes(k)) {
+                filtered[k] = arg[k];
+              }
+            }
+          }
+          result.push(JSON.stringify(filtered, (k, v) => v instanceof Node || v instanceof Window ? undefined : v));
+        });
+        return result;
+      }
+
       const waitingCallbacks = new Map();
       function throttle(callback, time, leading, trailing, id) {
         if (time <= 0) {
@@ -102,10 +122,13 @@
         element.events.forEach((event) => {
           let event_name = 'on' + event.type[0].toLocaleUpperCase() + event.type.substring(1);
           event.specials.forEach(s => event_name += s[0].toLocaleUpperCase() + s.substring(1));
-          let handler = (e) => {
-            const all = (typeof e !== 'object' || e === null) || !event.args;
-            const args = all ? e : Object.fromEntries(event.args.map(a => [a, e[a]]));
-            const emitter = () => window.socket.emit("event", {id: element.id, listener_id: event.listener_id, args});
+          let handler = (...args) => {
+            const data = {
+              id: element.id,
+              listener_id: event.listener_id,
+              args: stringifyEventArgs(args, event.args),
+            };
+            const emitter = () => window.socket.emit("event", data);
             throttle(emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id);
             if (element.props["loopback"] === False && event.type == "update:model-value") {
               element.props["model-value"] = args;

+ 1 - 1
tests/test_aggrid.py

@@ -52,7 +52,7 @@ def test_click_cell(screen: Screen):
         'columnDefs': [{'field': 'name'}, {'field': 'age'}],
         'rowData': [{'name': 'Alice', 'age': 18}],
     })
-    grid.on('cellClicked', lambda msg: ui.label(f'{msg["args"]["data"]["name"]} has been clicked!'))
+    grid.on('cellClicked', lambda e: ui.label(f'{e.args["data"]["name"]} has been clicked!'))
 
     screen.open('/')
     screen.click('Alice')

+ 12 - 12
tests/test_events.py

@@ -54,10 +54,10 @@ def test_click_events(screen: Screen):
 
 
 def test_generic_events(screen: Screen):
-    ui.label('click_sync_no_args').on('click', click_sync_no_args)
-    ui.label('click_sync_with_args').on('click', click_sync_with_args)
-    ui.label('click_async_no_args').on('click', click_async_no_args)
-    ui.label('click_async_with_args').on('click', click_async_with_args)
+    ui.label('click_sync_no_args').on('click', click_sync_no_args, [])
+    ui.label('click_sync_with_args').on('click', click_sync_with_args, [])
+    ui.label('click_async_no_args').on('click', click_async_no_args, [])
+    ui.label('click_async_with_args').on('click', click_async_with_args, [])
 
     screen.open('/')
     screen.click('click_sync_no_args')
@@ -89,10 +89,10 @@ def test_event_with_update_before_await(screen: Screen):
 
 def test_event_modifiers(screen: Screen):
     events = []
-    ui.input('A').on('keydown', lambda _: events.append('A'))
-    ui.input('B').on('keydown.x', lambda _: events.append('B'))
-    ui.input('C').on('keydown.once', lambda _: events.append('C'))
-    ui.input('D').on('keydown.shift.x', lambda _: events.append('D'))
+    ui.input('A').on('keydown', lambda _: events.append('A'), [])
+    ui.input('B').on('keydown.x', lambda _: events.append('B'), [])
+    ui.input('C').on('keydown.once', lambda _: events.append('C'), [])
+    ui.input('D').on('keydown.shift.x', lambda _: events.append('D'), [])
 
     screen.open('/')
     screen.selenium.find_element(By.XPATH, '//*[@aria-label="A"]').send_keys('x')
@@ -104,7 +104,7 @@ def test_event_modifiers(screen: Screen):
 
 def test_throttling(screen: Screen):
     events = []
-    ui.button('Test', on_click=lambda: events.append(1)).on('click', lambda: events.append(2), throttle=1)
+    ui.button('Test', on_click=lambda: events.append(1)).on('click', lambda: events.append(2), [], throttle=1)
 
     screen.open('/')
     screen.click('Test')
@@ -124,9 +124,9 @@ def test_throttling(screen: Screen):
 def test_throttling_variants(screen: Screen):
     events = []
     value = 0
-    ui.button('Both').on('click', lambda: events.append(value), throttle=1)
-    ui.button('Leading').on('click', lambda: events.append(value), throttle=1, trailing_events=False)
-    ui.button('Trailing').on('click', lambda: events.append(value), throttle=1, leading_events=False)
+    ui.button('Both').on('click', lambda: events.append(value), [], throttle=1)
+    ui.button('Leading').on('click', lambda: events.append(value), [], throttle=1, trailing_events=False)
+    ui.button('Trailing').on('click', lambda: events.append(value), [], throttle=1, leading_events=False)
 
     screen.open('/')
     value = 1

+ 1 - 1
website/demo.py

@@ -50,7 +50,7 @@ def demo(f: Callable) -> Callable:
             ui.markdown(f'````python\n{code}\n````')
             ui.icon('content_copy', size='xs') \
                 .classes('absolute right-2 top-10 opacity-10 hover:opacity-80 cursor-pointer') \
-                .on('click', copy_code)
+                .on('click', copy_code, [])
         with browser_window(title=getattr(f, 'tab', None),
                             classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window'):
             intersection_observer(on_intersection=f)

+ 1 - 1
website/documentation_tools.py

@@ -50,7 +50,7 @@ def subheading(text: str, *, make_menu_entry: bool = True, more_link: Optional[s
                 if await ui.run_javascript(f'!!document.querySelector("div.q-drawer__backdrop")'):
                     menu.hide()
                     ui.open(f'#{name}')
-            ui.link(text, target=f'#{name}').props('data-close-overlay').on('click', click)
+            ui.link(text, target=f'#{name}').props('data-close-overlay').on('click', click, [])
 
 
 def render_docstring(doc: str, with_params: bool = True) -> ui.html:

+ 2 - 1
website/example_card.py

@@ -8,7 +8,8 @@ def create() -> None:
         with ui.card().style(r'clip-path: polygon(0 0, 100% 0, 100% 90%, 0 100%)') \
                 .classes('pb-16 no-shadow'), ui.row().classes('no-wrap'):
             with ui.column().classes('items-center'):
-                svg.face().classes('w-16 mx-6 stroke-black stroke-2').on('click', lambda _: output.set_text("That's my face!"))
+                svg.face().classes('w-16 mx-6 stroke-black stroke-2') \
+                    .on('click', lambda _: output.set_text("That's my face!"), [])
                 ui.button('Click me!', on_click=lambda: output.set_text('Clicked')).classes('w-full')
                 ui.input('Text', value='abc', on_change=lambda e: output.set_text(e.value))
                 ui.checkbox('Check', on_change=lambda e: output.set_text('Checked' if e.value else 'Unchecked'))

+ 1 - 1
website/intersection_observer.py

@@ -14,7 +14,7 @@ class IntersectionObserver(Element):
         super().__init__('intersection_observer')
         self.on_intersection = on_intersection
         self.active = True
-        self.on('intersection', self.handle_intersection)
+        self.on('intersection', self.handle_intersection, [])
         self.use_component('intersection_observer')
 
     def handle_intersection(self, _) -> None:

+ 52 - 3
website/more_documentation/generic_events_documentation.py

@@ -15,7 +15,7 @@ def main_demo() -> None:
     Some events, like `mousemove`, are fired very often.
     To avoid performance issues, you can use the `throttle` parameter to only call the handler every `throttle` seconds ("D").
 
-    The generic event handler can be synchronous or asynchronous and optionally takes an event dictionary as argument ("E").
+    The generic event handler can be synchronous or asynchronous and optionally takes `GenericEventArguments` as argument ("E").
     You can also specify which attributes of the JavaScript or Quasar event should be passed to the handler ("F").
     This can reduce the amount of data that needs to be transferred between the server and the client.
     """
@@ -26,11 +26,60 @@ def main_demo() -> None:
         ui.button('C').on('mousemove', lambda: ui.notify('You moved on button C.'))
         ui.button('D').on('mousemove', lambda: ui.notify('You moved on button D.'), throttle=0.5)
     with ui.row():
-        ui.button('E').on('mousedown', lambda e: ui.notify(str(e)))
-        ui.button('F').on('mousedown', lambda e: ui.notify(str(e)), ['ctrlKey', 'shiftKey'])
+        ui.button('E').on('mousedown', lambda e: ui.notify(e))
+        ui.button('F').on('mousedown', lambda e: ui.notify(e), ['ctrlKey', 'shiftKey'])
 
 
 def more() -> None:
+    @text_demo('Specifying event attributes', '''
+        **A list of strings** names the attributes of the JavaScript event object:
+            ```py
+            ui.button().on('click', handle_click, ['clientX', 'clientY'])
+            ```
+
+        **An empty list** requests _no_ attributes:
+            ```py
+            ui.button().on('click', handle_click, [])
+            ```
+
+        **The value `None`** represents _all_ attributes (the default):
+            ```py
+            ui.button().on('click', handle_click, None)
+            ```
+
+        **If the event is called with multiple arguments** like QTable's "row-click" `(evt, row, index) => void`,
+            you can define a list of argument definitions:
+            ```py
+            ui.table(...).on('rowClick', handle_click, [[], ['name'], None])
+            ```
+            In this example the "row-click" event will omit all arguments of the first `evt` argument,
+            send only the "name" attribute of the `row` argument and send the full `index`.
+
+        If the retrieved list of event arguments has length 1, the argument is automatically unpacked.
+        So you can write
+        ```py
+        ui.button().on('click', lambda e: print(e.args['clientX'], flush=True))
+        ```
+        instead of
+        ```py
+        ui.button().on('click', lambda e: print(e.args[0]['clientX'], flush=True))
+        ```
+
+        Note that by default all JSON-serializable attributes of all arguments are sent.
+        This is to simplify registering for new events and discovering their attributes.
+        If bandwidth is an issue, the arguments should be limited to what is actually needed on the server.
+    ''')
+    def event_attributes() -> None:
+        columns = [
+            {'name': 'name', 'label': 'Name', 'field': 'name'},
+            {'name': 'age', 'label': 'Age', 'field': 'age'},
+        ]
+        rows = [
+            {'name': 'Alice', 'age': 42},
+            {'name': 'Bob', 'age': 23},
+        ]
+        ui.table(columns, rows, 'name').on('rowClick', ui.notify, [[], ['name'], None])
+
     @text_demo('Modifiers', '''
         You can also include [key modifiers](https://vuejs.org/guide/essentials/event-handling.html#key-modifiers>) (shown in input "A"),
         modifier combinations (shown in input "B"),

+ 3 - 5
website/more_documentation/line_plot_documentation.py

@@ -1,6 +1,4 @@
-from typing import Dict
-
-from nicegui import ui
+from nicegui import events, ui
 
 
 def main_demo() -> None:
@@ -22,11 +20,11 @@ def main_demo() -> None:
     line_checkbox = ui.checkbox('active').bind_value(line_updates, 'active')
 
     # END OF DEMO
-    def handle_change(msg: Dict) -> None:
+    def handle_change(e: events.GenericEventArguments) -> None:
         def turn_off() -> None:
             line_checkbox.set_value(False)
             ui.notify('Turning off that line plot to save resources on our live demo server. 😎')
-        line_checkbox.value = msg['args']
+        line_checkbox.value = e.args
         if line_checkbox.value:
             ui.timer(10.0, turn_off, once=True)
     line_checkbox.on('update:model-value', handle_change)

+ 3 - 3
website/more_documentation/slider_documentation.py

@@ -23,17 +23,17 @@ def more() -> None:
     def throttle_events_with_leading_and_trailing_options():
         ui.label('default')
         ui.slider(min=0, max=10, step=0.1, value=5).props('label-always') \
-            .on('update:model-value', lambda msg: ui.notify(f'{msg["args"]}'),
+            .on('update:model-value', lambda e: ui.notify(e.args),
                 throttle=1.0)
 
         ui.label('leading events only')
         ui.slider(min=0, max=10, step=0.1, value=5).props('label-always') \
-            .on('update:model-value', lambda msg: ui.notify(f'{msg["args"]}'),
+            .on('update:model-value', lambda e: ui.notify(e.args),
                 throttle=1.0, trailing_events=False)
 
         ui.label('trailing events only')
         ui.slider(min=0, max=10, step=0.1, value=5).props('label-always') \
-            .on('update:model-value', lambda msg: ui.notify(f'{msg["args"]}'),
+            .on('update:model-value', lambda e: ui.notify(e.args),
                 throttle=1.0, leading_events=False)
 
     @text_demo('Disable slider', '''

+ 4 - 4
website/more_documentation/table_documentation.py

@@ -91,7 +91,7 @@ def more() -> None:
         After emitting a `rename` event from the scoped slot, the `rename` function updates the table rows.
     ''')
     def table_with_drop_down_selection():
-        from typing import Dict
+        from nicegui import events
 
         columns = [
             {'name': 'name', 'label': 'Name', 'field': 'name'},
@@ -104,10 +104,10 @@ def more() -> None:
         ]
         name_options = ['Alice', 'Bob', 'Carol']
 
-        def rename(msg: Dict) -> None:
+        def rename(e: events.GenericEventArguments) -> None:
             for row in rows:
-                if row['id'] == msg['args']['id']:
-                    row['name'] = msg['args']['name']
+                if row['id'] == e.args['id']:
+                    row['name'] = e.args['name']
             ui.notify(f'Table.rows is now: {table.rows}')
 
         table = ui.table(columns=columns, rows=rows, row_key='name').classes('w-full')

+ 2 - 1
website/search.py

@@ -55,7 +55,8 @@ class Search:
                 self.results.clear()
                 for result in results:
                     href: str = result['item']['url']
-                    with ui.element('q-item').props(f'clickable').on('click', lambda href=href: self.open_url(href)):
+                    with ui.element('q-item').props(f'clickable') \
+                            .on('click', lambda href=href: self.open_url(href), []):
                         with ui.element('q-item-section'):
                             ui.label(result['item']['title'])
         background_tasks.create_lazy(handle_input(), name='handle_search_input')