Repository: zauberzeug/nicegui
Pull request #2756 (2024-03-23 18:23:53)

Fix jumping cursor for `ui.editor`
This PR tries to solve issue #2611 by disabling loopback _and_ disabling the "shortcut" which sets the new value directly on the client.


Patch:
diff --git a/examples/fullcalendar/fullcalendar.py b/examples/fullcalendar/fullcalendar.py
index a4de725b..a25b067d 100644
--- a/examples/fullcalendar/fullcalendar.py
+++ b/examples/fullcalendar/fullcalendar.py
@@ -13,7 +13,7 @@ class FullCalendar(Element, component='fullcalendar.js'):
         An element that integrates the FullCalendar library (https://fullcalendar.io/) to create an interactive calendar display.
 
         :param options: dictionary of FullCalendar properties for customization, such as "initialView", "slotMinTime", "slotMaxTime", "allDaySlot", "timeZone", "height", and "events".
-        :param on_click: callback function that is called when a calendar event is clicked.
+        :param on_click: callback that is called when a calendar event is clicked.
         """
         super().__init__()
         self.add_resource(Path(__file__).parent / 'lib')
diff --git a/nicegui/air.py b/nicegui/air.py
index 3844a47c..00958bca 100644
--- a/nicegui/air.py
+++ b/nicegui/air.py
@@ -162,9 +162,6 @@ class Air:
 
     async def connect(self) -> None:
         """Connect to the NiceGUI On Air server."""
-        # ensure that the connection is closed when the process is terminated
-        signal.signal(signal.SIGINT, lambda signum, frame: disconnect())
-        signal.signal(signal.SIGTERM, lambda signum, frame: disconnect())
         if self.connecting:
             return
         self.connecting = True
@@ -192,6 +189,7 @@ class Air:
 
     async def disconnect(self) -> None:
         """Disconnect from the NiceGUI On Air server."""
+        await self.relay.disconnect()
         for stream in self.streams.values():
             await stream.response.aclose()
         self.streams.clear()
diff --git a/nicegui/client.py b/nicegui/client.py
index 2a6cce0a..88fd0a2b 100644
--- a/nicegui/client.py
+++ b/nicegui/client.py
@@ -224,11 +224,11 @@ class Client:
         self.outbox.enqueue_message('download', {'src': src, 'filename': filename, 'media_type': media_type}, self.id)
 
     def on_connect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
-        """Register a callback to be called when the client connects."""
+        """Add a callback to be invoked when the client connects."""
         self.connect_handlers.append(handler)
 
     def on_disconnect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
-        """Register a callback to be called when the client disconnects."""
+        """Add a callback to be invoked when the client disconnects."""
         self.disconnect_handlers.append(handler)
 
     def handle_handshake(self) -> None:
diff --git a/nicegui/elements/button.py b/nicegui/elements/button.py
index 73f73f91..f290f28a 100644
--- a/nicegui/elements/button.py
+++ b/nicegui/elements/button.py
@@ -1,6 +1,8 @@
 import asyncio
 from typing import Any, Callable, Optional
 
+from typing_extensions import Self
+
 from ..events import ClickEventArguments, handle_event
 from .mixins.color_elements import BackgroundColorElement
 from .mixins.disableable_element import DisableableElement
@@ -35,7 +37,12 @@ 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(on_click)
+
+    def on_click(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the button is clicked."""
+        self.on('click', lambda _: handle_event(callback, ClickEventArguments(sender=self, client=self.client)), [])
+        return self
 
     def _text_to_model_text(self, text: str) -> None:
         self._props['label'] = text
diff --git a/nicegui/elements/choice_element.py b/nicegui/elements/choice_element.py
index 45d1e7bd..a4db13cb 100644
--- a/nicegui/elements/choice_element.py
+++ b/nicegui/elements/choice_element.py
@@ -25,8 +25,8 @@ class ChoiceElement(ValueElement):
     def _update_options(self) -> None:
         before_value = self.value
         self._props['options'] = [{'value': index, 'label': option} for index, option in enumerate(self._labels)]
+        self._props[self.VALUE_PROP] = self._value_to_model_value(before_value)
         if not isinstance(before_value, list):  # NOTE: no need to update value in case of multi-select
-            self._props[self.VALUE_PROP] = self._value_to_model_value(before_value)
             self.value = before_value if before_value in self._values else None
 
     def update(self) -> None:
diff --git a/nicegui/elements/color_picker.py b/nicegui/elements/color_picker.py
index 9f2e5748..48d70021 100644
--- a/nicegui/elements/color_picker.py
+++ b/nicegui/elements/color_picker.py
@@ -1,4 +1,6 @@
-from typing import Any, Callable
+from typing import Any, Callable, Optional
+
+from typing_extensions import Self
 
 from ..element import Element
 from ..events import ColorPickEventArguments, GenericEventArguments, handle_event
@@ -7,7 +9,10 @@ from .menu import Menu
 
 class ColorPicker(Menu):
 
-    def __init__(self, *, on_pick: Callable[..., Any], value: bool = False) -> None:
+    def __init__(self, *,
+                 on_pick: Optional[Callable[..., Any]] = None,
+                 value: bool = False,
+                 ) -> None:
         """Color Picker
 
         This element is based on Quasar's `QMenu <https://quasar.dev/vue-components/menu>`_ and
@@ -17,9 +22,11 @@ class ColorPicker(Menu):
         :param value: whether the menu is already opened (default: `False`)
         """
         super().__init__(value=value)
+        self._pick_handlers = [on_pick] if on_pick else []
         with self:
             def handle_change(e: GenericEventArguments):
-                handle_event(on_pick, ColorPickEventArguments(sender=self, client=self.client, color=e.args))
+                for handler in self._pick_handlers:
+                    handle_event(handler, 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:
@@ -28,3 +35,8 @@ class ColorPicker(Menu):
         :param color: the color to set
         """
         self.q_color.props(f'model-value="{color}"')
+
+    def on_pick(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a color is picked."""
+        self._pick_handlers.append(callback)
+        return self
diff --git a/nicegui/elements/echart.py b/nicegui/elements/echart.py
index fd35a8ff..ef76806e 100644
--- a/nicegui/elements/echart.py
+++ b/nicegui/elements/echart.py
@@ -1,4 +1,4 @@
-from typing import Callable, Dict, Optional
+from typing import Any, Callable, Dict, Optional
 
 from typing_extensions import Self
 
@@ -27,45 +27,50 @@ class EChart(Element, component='echart.js', libraries=['lib/echarts/echarts.min
         After data has changed, call the `update` method to refresh the chart.
 
         :param options: dictionary of EChart options
-        :param on_click_point: callback function that is called when a point is clicked
+        :param on_click_point: callback that is invoked when a point is clicked
         """
         super().__init__()
         self._props['options'] = options
         self._classes.append('nicegui-echart')
 
         if on_point_click:
-            def handle_point_click(e: GenericEventArguments) -> None:
-                handle_event(on_point_click, EChartPointClickEventArguments(
-                    sender=self,
-                    client=self.client,
-                    component_type=e.args['componentType'],
-                    series_type=e.args['seriesType'],
-                    series_index=e.args['seriesIndex'],
-                    series_name=e.args['seriesName'],
-                    name=e.args['name'],
-                    data_index=e.args['dataIndex'],
-                    data=e.args['data'],
-                    data_type=e.args.get('dataType'),
-                    value=e.args['value'],
-                ))
-            self.on('pointClick', handle_point_click, [
-                'componentType',
-                'seriesType',
-                'seriesIndex',
-                'seriesName',
-                'name',
-                'dataIndex',
-                'data',
-                'dataType',
-                'value',
-            ])
+            self.on_point_click(on_point_click)
+
+    def on_point_click(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a point is clicked."""
+        def handle_point_click(e: GenericEventArguments) -> None:
+            handle_event(callback, EChartPointClickEventArguments(
+                sender=self,
+                client=self.client,
+                component_type=e.args['componentType'],
+                series_type=e.args['seriesType'],
+                series_index=e.args['seriesIndex'],
+                series_name=e.args['seriesName'],
+                name=e.args['name'],
+                data_index=e.args['dataIndex'],
+                data=e.args['data'],
+                data_type=e.args.get('dataType'),
+                value=e.args['value'],
+            ))
+        self.on('pointClick', handle_point_click, [
+            'componentType',
+            'seriesType',
+            'seriesIndex',
+            'seriesName',
+            'name',
+            'dataIndex',
+            'data',
+            'dataType',
+            'value',
+        ])
+        return self
 
     @classmethod
     def from_pyecharts(cls, chart: 'Chart', on_point_click: Optional[Callable] = None) -> Self:
         """Create an echart element from a pyecharts object.
 
         :param chart: pyecharts chart object
-        :param on_click_point: callback function that is called when a point is clicked
+        :param on_click_point: callback which is invoked when a point is clicked
 
         :return: echart element
         """
diff --git a/nicegui/elements/editor.py b/nicegui/elements/editor.py
index a08e02c0..a9ce450b 100644
--- a/nicegui/elements/editor.py
+++ b/nicegui/elements/editor.py
@@ -5,6 +5,7 @@ from .mixins.value_element import ValueElement
 
 
 class Editor(ValueElement, DisableableElement):
+    LOOPBACK = None
 
     def __init__(self,
                  *,
diff --git a/nicegui/elements/interactive_image.js b/nicegui/elements/interactive_image.js
index e6493fb0..37c68f4d 100644
--- a/nicegui/elements/interactive_image.js
+++ b/nicegui/elements/interactive_image.js
@@ -10,7 +10,7 @@ export default {
         v-on="onUserEvents"
         draggable="false"
       />
-      <svg style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
+      <svg ref="svg" style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
         <g v-if="cross" :style="{ display: showCross ? 'block' : 'none' }">
           <line :x1="x" y1="0" :x2="x" y2="100%" :stroke="cross === true ? 'black' : cross" />
           <line x1="0" :y1="y" x2="100%" :y2="y" :stroke="cross === true ? 'black' : cross" />
@@ -45,6 +45,18 @@ export default {
     };
     this.$refs.img.addEventListener("load", handle_completion);
     this.$refs.img.addEventListener("error", handle_completion);
+    for (const event of [
+      "pointermove",
+      "pointerdown",
+      "pointerup",
+      "pointerover",
+      "pointerout",
+      "pointerenter",
+      "pointerleave",
+      "pointercancel",
+    ]) {
+      this.$refs.svg.addEventListener(event, (e) => this.onPointerEvent(event, e));
+    }
   },
   updated() {
     this.compute_src();
@@ -79,12 +91,12 @@ export default {
       this.$emit("loaded", { width: this.loaded_image_width, height: this.loaded_image_height, source: e.target.src });
     },
     onMouseEvent(type, e) {
-      const width = this.src ? this.loaded_image_width : this.size ? this.size[0] : 1;
-      const height = this.src ? this.loaded_image_height : this.size ? this.size[1] : 1;
+      const imageWidth = this.src ? this.loaded_image_width : this.size ? this.size[0] : 1;
+      const imageHeight = this.src ? this.loaded_image_height : this.size ? this.size[1] : 1;
       this.$emit("mouse", {
         mouse_event_type: type,
-        image_x: (e.offsetX * width) / e.target.clientWidth,
-        image_y: (e.offsetY * height) / e.target.clientHeight,
+        image_x: (e.offsetX * imageWidth) / this.$refs.img.clientWidth,
+        image_y: (e.offsetY * imageHeight) / this.$refs.img.clientHeight,
         button: e.button,
         buttons: e.buttons,
         altKey: e.altKey,
@@ -93,6 +105,16 @@ export default {
         shiftKey: e.shiftKey,
       });
     },
+    onPointerEvent(type, e) {
+      const imageWidth = this.src ? this.loaded_image_width : this.size ? this.size[0] : 1;
+      const imageHeight = this.src ? this.loaded_image_height : this.size ? this.size[1] : 1;
+      this.$emit(`svg:${type}`, {
+        type: type,
+        element_id: e.target.id,
+        image_x: (e.offsetX * imageWidth) / this.$refs.svg.clientWidth,
+        image_y: (e.offsetY * imageHeight) / this.$refs.svg.clientHeight,
+      });
+    },
   },
   computed: {
     onCrossEvents() {
diff --git a/nicegui/elements/interactive_image.py b/nicegui/elements/interactive_image.py
index da3eb15a..81f186a8 100644
--- a/nicegui/elements/interactive_image.py
+++ b/nicegui/elements/interactive_image.py
@@ -4,6 +4,8 @@ import time
 from pathlib import Path
 from typing import Any, Callable, List, Optional, Tuple, Union, cast
 
+from typing_extensions import Self
+
 from .. import optional_features
 from ..events import GenericEventArguments, MouseEventArguments, handle_event
 from .image import pil_to_base64
@@ -53,15 +55,19 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
         :param on_mouse: callback for mouse events (contains image coordinates `image_x` and `image_y` in pixels)
         :param events: list of JavaScript events to subscribe to (default: `['click']`)
         :param cross: whether to show crosshairs or a color string (default: `False`)
+        :param on_pointer: callback for pointer events (contains image coordinates `image_x` and `image_y` in pixels, and `type` of the event)
         """
         super().__init__(source=source, content=content)
         self._props['events'] = events
         self._props['cross'] = cross
         self._props['size'] = size
 
+        if on_mouse:
+            self.on_mouse(on_mouse)
+
+    def on_mouse(self, on_mouse: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a mouse event occurs."""
         def handle_mouse(e: GenericEventArguments) -> None:
-            if on_mouse is None:
-                return
             args = cast(dict, e.args)
             arguments = MouseEventArguments(
                 sender=self,
@@ -78,6 +84,7 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
             )
             handle_event(on_mouse, arguments)
         self.on('mouse', handle_mouse)
+        return self
 
     def _set_props(self, source: Union[str, Path, 'PIL_Image']) -> None:
         if optional_features.has('pillow') and isinstance(source, PIL_Image):
diff --git a/nicegui/elements/joystick.py b/nicegui/elements/joystick.py
index 8c0455bd..493d84ba 100644
--- a/nicegui/elements/joystick.py
+++ b/nicegui/elements/joystick.py
@@ -1,5 +1,7 @@
 from typing import Any, Callable, Optional
 
+from typing_extensions import Self
+
 from ..element import Element
 from ..events import GenericEventArguments, JoystickEventArguments, handle_event
 
@@ -26,26 +28,49 @@ class Joystick(Element, component='joystick.vue', libraries=['lib/nipplejs/nippl
         self._props['options'] = options
         self.active = False
 
+        self._start_handlers = [on_start] if on_start else []
+        self._move_handlers = [on_move] if on_move else []
+        self._end_handlers = [on_end] if on_end else []
+
         def handle_start() -> None:
             self.active = True
-            handle_event(on_start, JoystickEventArguments(sender=self,
-                                                          client=self.client,
-                                                          action='start'))
+            args = JoystickEventArguments(sender=self, client=self.client, action='start')
+            for handler in self._start_handlers:
+                handle_event(handler, args)
 
         def handle_move(e: GenericEventArguments) -> None:
             if self.active:
-                handle_event(on_move, JoystickEventArguments(sender=self,
-                                                             client=self.client,
-                                                             action='move',
-                                                             x=float(e.args['data']['vector']['x']),
-                                                             y=float(e.args['data']['vector']['y'])))
+                args = JoystickEventArguments(sender=self,
+                                              client=self.client,
+                                              action='move',
+                                              x=float(e.args['data']['vector']['x']),
+                                              y=float(e.args['data']['vector']['y']))
+                for handler in self._move_handlers:
+                    handle_event(handler, args)
 
         def handle_end() -> None:
             self.active = False
-            handle_event(on_end, JoystickEventArguments(sender=self,
-                                                        client=self.client,
-                                                        action='end'))
+            args = JoystickEventArguments(sender=self,
+                                          client=self.client,
+                                          action='end')
+            for handler in self._end_handlers:
+                handle_event(handler, args)
 
         self.on('start', handle_start, [])
         self.on('move', handle_move, ['data'], throttle=throttle)
         self.on('end', handle_end, [])
+
+    def on_start(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the user touches the joystick."""
+        self._start_handlers.append(callback)
+        return self
+
+    def on_move(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the user moves the joystick."""
+        self._move_handlers.append(callback)
+        return self
+
+    def on_end(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the user releases the joystick."""
+        self._end_handlers.append(callback)
+        return self
diff --git a/nicegui/elements/json_editor.py b/nicegui/elements/json_editor.py
index 3a81e539..50257522 100644
--- a/nicegui/elements/json_editor.py
+++ b/nicegui/elements/json_editor.py
@@ -1,4 +1,6 @@
-from typing import Callable, Dict, Optional
+from typing import Any, Callable, Dict, Optional
+
+from typing_extensions import Self
 
 from ..awaitable_response import AwaitableResponse
 from ..element import Element
@@ -19,21 +21,31 @@ class JsonEditor(Element, component='json_editor.js', exposed_libraries=['lib/va
         After data has changed, call the `update` method to refresh the editor.
 
         :param properties: dictionary of JSONEditor properties
-        :param on_select: callback function that is called when some of the content has been selected
-        :param on_change: callback function that is called when the content has changed
+        :param on_select: callback which is invoked when some of the content has been selected
+        :param on_change: callback which is invoked when the content has changed
         """
         super().__init__()
         self._props['properties'] = properties
 
         if on_select:
-            def handle_on_select(e: GenericEventArguments) -> None:
-                handle_event(on_select, JsonEditorSelectEventArguments(sender=self, client=self.client, **e.args))
-            self.on('select', handle_on_select, ['selection'])
+            self.on_select(on_select)
 
         if on_change:
-            def handle_on_change(e: GenericEventArguments) -> None:
-                handle_event(on_change, JsonEditorChangeEventArguments(sender=self, client=self.client, **e.args))
-            self.on('change', handle_on_change, ['content', 'errors'])
+            self.on_change(on_change)
+
+    def on_change(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the content changes."""
+        def handle_on_change(e: GenericEventArguments) -> None:
+            handle_event(callback, JsonEditorChangeEventArguments(sender=self, client=self.client, **e.args))
+        self.on('change', handle_on_change, ['content', 'errors'])
+        return self
+
+    def on_select(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when some of the content has been selected."""
+        def handle_on_select(e: GenericEventArguments) -> None:
+            handle_event(callback, JsonEditorSelectEventArguments(sender=self, client=self.client, **e.args))
+        self.on('select', handle_on_select, ['selection'])
+        return self
 
     @property
     def properties(self) -> Dict:
diff --git a/nicegui/elements/keyboard.py b/nicegui/elements/keyboard.py
index 024d7695..b8156d21 100644
--- a/nicegui/elements/keyboard.py
+++ b/nicegui/elements/keyboard.py
@@ -1,4 +1,6 @@
-from typing import Any, Callable, List, Literal
+from typing import Any, Callable, List, Literal, Optional
+
+from typing_extensions import Self
 
 from ..binding import BindableProperty
 from ..element import Element
@@ -10,7 +12,7 @@ class Keyboard(Element, component='keyboard.js'):
     active = BindableProperty()
 
     def __init__(self,
-                 on_key: Callable[..., Any], *,
+                 on_key: Optional[Callable[..., Any]] = None, *,
                  active: bool = True,
                  repeating: bool = True,
                  ignore: List[Literal['input', 'select', 'button', 'textarea']] = [
@@ -26,7 +28,7 @@ class Keyboard(Element, component='keyboard.js'):
         :param ignore: ignore keys when one of these element types is focussed (default: `['input', 'select', 'button', 'textarea']`)
         """
         super().__init__()
-        self.key_handler = on_key
+        self._key_handlers = [on_key] if on_key else []
         self.active = active
         self._props['events'] = ['keydown', 'keyup']
         self._props['repeating'] = repeating
@@ -60,4 +62,10 @@ class Keyboard(Element, component='keyboard.js'):
             modifiers=modifiers,
             key=key,
         )
-        handle_event(self.key_handler, arguments)
+        for handler in self._key_handlers:
+            handle_event(handler, arguments)
+
+    def on_key(self, handler: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when keyboard events occur."""
+        self._key_handlers.append(handler)
+        return self
diff --git a/nicegui/elements/list.py b/nicegui/elements/list.py
index 9d6c0162..c645b0ef 100644
--- a/nicegui/elements/list.py
+++ b/nicegui/elements/list.py
@@ -1,5 +1,7 @@
 from typing import Any, Callable, Optional
 
+from typing_extensions import Self
+
 from ..element import Element
 from ..events import ClickEventArguments, handle_event
 from .mixins.disableable_element import DisableableElement
@@ -28,8 +30,13 @@ class Item(DisableableElement):
         super().__init__(tag='q-item')
 
         if on_click:
-            self._props['clickable'] = True
-            self.on('click', lambda _: handle_event(on_click, ClickEventArguments(sender=self, client=self.client)))
+            self.on_click(on_click)
+
+    def on_click(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the List Item is clicked."""
+        self._props['clickable'] = True  # idempotent
+        self.on('click', lambda _: handle_event(callback, ClickEventArguments(sender=self, client=self.client)))
+        return self
 
 
 class ItemSection(Element):
diff --git a/nicegui/elements/menu.py b/nicegui/elements/menu.py
index dc6bf08c..c17c9038 100644
--- a/nicegui/elements/menu.py
+++ b/nicegui/elements/menu.py
@@ -63,10 +63,17 @@ class MenuItem(TextElement):
         super().__init__(tag='q-item', text=text)
         self.menu = context.get_slot().parent
         self._props['clickable'] = True
+        self._click_handlers = [on_click] if on_click else []
 
         def handle_click(_) -> None:
-            handle_event(on_click, ClickEventArguments(sender=self, client=self.client))
+            for handler in self._click_handlers:
+                handle_event(handler, ClickEventArguments(sender=self, client=self.client))
             if auto_close:
                 assert isinstance(self.menu, (Menu, ContextMenu))
                 self.menu.close()
         self.on('click', handle_click, [])
+
+    def on_click(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the menu item is clicked."""
+        self._click_handlers.append(callback)
+        return self
diff --git a/nicegui/elements/mixins/value_element.py b/nicegui/elements/mixins/value_element.py
index 9b60441e..5b2a3a3e 100644
--- a/nicegui/elements/mixins/value_element.py
+++ b/nicegui/elements/mixins/value_element.py
@@ -9,7 +9,16 @@ from ...events import GenericEventArguments, ValueChangeEventArguments, handle_e
 
 class ValueElement(Element):
     VALUE_PROP: str = 'model-value'
-    LOOPBACK: bool = True
+    """Name of the prop that holds the value of the element"""
+
+    LOOPBACK: Optional[bool] = True
+    """Whether to set the new value directly on the client or after getting an update from the server.
+    
+    - ``True``: The value is updated by sending a change event to the server which responds with an update.
+    - ``False``: The value is updated by setting the VALUE_PROP directly on the client.
+    - ``None``: The value is updated automatically by the Vue element.
+    """
+
     value = BindableProperty(
         on_change=lambda sender, value: cast(Self, sender)._handle_value_change(value))  # pylint: disable=protected-access
 
@@ -24,14 +33,19 @@ class ValueElement(Element):
         self.set_value(value)
         self._props[self.VALUE_PROP] = self._value_to_model_value(value)
         self._props['loopback'] = self.LOOPBACK
-        self._change_handler = on_value_change
+        self._change_handlers: list[Callable[..., Any]] = [on_value_change] if on_value_change else []
 
         def handle_change(e: GenericEventArguments) -> None:
-            self._send_update_on_value_change = self.LOOPBACK
+            self._send_update_on_value_change = self.LOOPBACK is True
             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, [None], throttle=throttle)
 
+    def on_value_change(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the value changes."""
+        self._change_handlers.append(callback)
+        return self
+
     def bind_value_to(self,
                       target_object: Any,
                       target_name: str = 'value',
@@ -98,7 +112,8 @@ class ValueElement(Element):
         if self._send_update_on_value_change:
             self.update()
         args = ValueChangeEventArguments(sender=self, client=self.client, value=self._value_to_event_value(value))
-        handle_event(self._change_handler, args)
+        for handler in self._change_handlers:
+            handle_event(handler, args)
 
     def _event_args_to_value(self, e: GenericEventArguments) -> Any:
         return e.args
diff --git a/nicegui/elements/range.py b/nicegui/elements/range.py
new file mode 100644
index 00000000..69afe616
--- /dev/null
+++ b/nicegui/elements/range.py
@@ -0,0 +1,29 @@
+from typing import Any, Callable, Dict, Optional
+
+from .mixins.disableable_element import DisableableElement
+from .mixins.value_element import ValueElement
+
+
+class Range(ValueElement, DisableableElement):
+
+    def __init__(self, *,
+                 min: float,  # pylint: disable=redefined-builtin
+                 max: float,  # pylint: disable=redefined-builtin
+                 step: float = 1.0,
+                 value: Optional[Dict[str, int]] = None,
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
+        """Range
+
+        This element is based on Quasar's `QRange <https://quasar.dev/vue-components/range>`_ component.
+
+        :param min: lower bound of the range
+        :param max: upper bound of the range
+        :param step: step size
+        :param value: initial value to set min and max position of the range
+        :param on_change: callback which is invoked when the user releases the range
+        """
+        super().__init__(tag='q-range', value=value, on_value_change=on_change, throttle=0.05)
+        self._props['min'] = min
+        self._props['max'] = max
+        self._props['step'] = step
diff --git a/nicegui/elements/scene.py b/nicegui/elements/scene.py
index 2454a15e..6817b728 100644
--- a/nicegui/elements/scene.py
+++ b/nicegui/elements/scene.py
@@ -93,9 +93,9 @@ class Scene(Element,
         self.objects: Dict[str, Object3D] = {}
         self.stack: List[Union[Object3D, SceneObject]] = [SceneObject()]
         self.camera: SceneCamera = SceneCamera()
-        self._click_handler = on_click
-        self._drag_start_handler = on_drag_start
-        self._drag_end_handler = on_drag_end
+        self._click_handlers = [on_click] if on_click else []
+        self._drag_start_handlers = [on_drag_start] if on_drag_start else []
+        self._drag_end_handlers = [on_drag_end] if on_drag_end else []
         self.is_initialized = False
         self.on('init', self._handle_init)
         self.on('click3d', self._handle_click)
@@ -103,6 +103,21 @@ class Scene(Element,
         self.on('dragend', self._handle_drag)
         self._props['drag_constraints'] = drag_constraints
 
+    def on_click(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a 3D object is clicked."""
+        self._click_handlers.append(callback)
+        return self
+
+    def on_drag_start(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a 3D object is dragged."""
+        self._drag_start_handlers.append(callback)
+        return self
+
+    def on_drag_end(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a 3D object is dropped."""
+        self._drag_end_handlers.append(callback)
+        return self
+
     def __enter__(self) -> Self:
         Object3D.current_scene = self
         super().__enter__()
@@ -151,7 +166,8 @@ class Scene(Element,
                 z=hit['point']['z'],
             ) for hit in e.args['hits']],
         )
-        handle_event(self._click_handler, arguments)
+        for handler in self._click_handlers:
+            handle_event(handler, arguments)
 
     def _handle_drag(self, e: GenericEventArguments) -> None:
         arguments = SceneDragEventArguments(
@@ -166,7 +182,9 @@ class Scene(Element,
         )
         if arguments.type == 'dragend':
             self.objects[arguments.object_id].move(arguments.x, arguments.y, arguments.z)
-        handle_event(self._drag_start_handler if arguments.type == 'dragstart' else self._drag_end_handler, arguments)
+
+        for handler in (self._drag_start_handlers if arguments.type == 'dragstart' else self._drag_end_handlers):
+            handle_event(handler, arguments)
 
     def __len__(self) -> int:
         return len(self.objects)
diff --git a/nicegui/elements/scroll_area.py b/nicegui/elements/scroll_area.py
index 95b0fd73..f1a3b58b 100644
--- a/nicegui/elements/scroll_area.py
+++ b/nicegui/elements/scroll_area.py
@@ -1,5 +1,7 @@
 from typing import Any, Callable, Literal, Optional
 
+from typing_extensions import Self
+
 from ..element import Element
 from ..events import GenericEventArguments, ScrollEventArguments, handle_event
 
@@ -18,16 +20,21 @@ class ScrollArea(Element):
         self._classes.append('nicegui-scroll-area')
 
         if on_scroll:
-            self.on('scroll', lambda e: self._handle_scroll(on_scroll, e), args=[
-                'verticalPosition',
-                'verticalPercentage',
-                'verticalSize',
-                'verticalContainerSize',
-                'horizontalPosition',
-                'horizontalPercentage',
-                'horizontalSize',
-                'horizontalContainerSize',
-            ])
+            self.on_scroll(on_scroll)
+
+    def on_scroll(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the scroll position changes."""
+        self.on('scroll', lambda e: self._handle_scroll(callback, e), args=[
+            'verticalPosition',
+            'verticalPercentage',
+            'verticalSize',
+            'verticalContainerSize',
+            'horizontalPosition',
+            'horizontalPercentage',
+            'horizontalSize',
+            'horizontalContainerSize',
+        ])
+        return self
 
     def _handle_scroll(self, handler: Optional[Callable[..., Any]], e: GenericEventArguments) -> None:
         handle_event(handler, ScrollEventArguments(
diff --git a/nicegui/elements/table.py b/nicegui/elements/table.py
index 5205083f..583c714d 100644
--- a/nicegui/elements/table.py
+++ b/nicegui/elements/table.py
@@ -52,6 +52,8 @@ class Table(FilterElement, component='table.js'):
         self._props['selection'] = selection or 'none'
         self._props['selected'] = []
         self._props['fullscreen'] = False
+        self._selection_handlers = [on_select] if on_select else []
+        self._pagination_change_handlers = [on_pagination_change] if on_pagination_change else []
 
         def handle_selection(e: GenericEventArguments) -> None:
             if e.args['added']:
@@ -62,16 +64,28 @@ class Table(FilterElement, component='table.js'):
                 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)
+            for handler in self._selection_handlers:
+                handle_event(handler, arguments)
         self.on('selection', handle_selection, ['added', 'rows', 'keys'])
 
         def handle_pagination_change(e: GenericEventArguments) -> None:
             self.pagination = e.args
             self.update()
             arguments = ValueChangeEventArguments(sender=self, client=self.client, value=self.pagination)
-            handle_event(on_pagination_change, arguments)
+            for handler in self._pagination_change_handlers:
+                handle_event(handler, arguments)
         self.on('update:pagination', handle_pagination_change)
 
+    def on_select(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the selection changes."""
+        self._selection_handlers.append(callback)
+        return self
+
+    def on_pagination_change(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the pagination changes."""
+        self._pagination_change_handlers.append(callback)
+        return self
+
     @classmethod
     def from_pandas(cls,
                     df: 'pd.DataFrame',
diff --git a/nicegui/elements/tree.py b/nicegui/elements/tree.py
index 389a8928..3f6a6e4a 100644
--- a/nicegui/elements/tree.py
+++ b/nicegui/elements/tree.py
@@ -46,6 +46,9 @@ class Tree(Element):
         self._props['ticked'] = []
         if tick_strategy is not None:
             self._props['tick-strategy'] = tick_strategy
+        self._select_handlers = [on_select] if on_select else []
+        self._expand_handlers = [on_expand] if on_expand else []
+        self._tick_handlers = [on_tick] if on_tick else []
 
         def update_prop(name: str, value: Any) -> None:
             if self._props[name] != value:
@@ -54,19 +57,37 @@ class Tree(Element):
 
         def handle_selected(e: GenericEventArguments) -> None:
             update_prop('selected', e.args)
-            handle_event(on_select, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
+            for handler in self._select_handlers:
+                handle_event(handler, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
         self.on('update:selected', handle_selected)
 
         def handle_expanded(e: GenericEventArguments) -> None:
             update_prop('expanded', e.args)
-            handle_event(on_expand, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
+            for handler in self._expand_handlers:
+                handle_event(handler, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
         self.on('update:expanded', handle_expanded)
 
         def handle_ticked(e: GenericEventArguments) -> None:
             update_prop('ticked', e.args)
-            handle_event(on_tick, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
+            for handler in self._tick_handlers:
+                handle_event(handler, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
         self.on('update:ticked', handle_ticked)
 
+    def on_select(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the selection changes."""
+        self._select_handlers.append(callback)
+        return self
+
+    def on_expand(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the expansion changes."""
+        self._expand_handlers.append(callback)
+        return self
+
+    def on_tick(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a node is ticked or unticked."""
+        self._tick_handlers.append(callback)
+        return self
+
     def expand(self, node_keys: Optional[List[str]] = None) -> Self:
         """Expand the given nodes.
 
diff --git a/nicegui/elements/upload.py b/nicegui/elements/upload.py
index b6163dc2..11c25aa3 100644
--- a/nicegui/elements/upload.py
+++ b/nicegui/elements/upload.py
@@ -2,6 +2,7 @@ from typing import Any, Callable, Dict, Optional
 
 from fastapi import Request
 from starlette.datastructures import UploadFile
+from typing_extensions import Self
 
 from ..events import UiEventArguments, UploadEventArguments, handle_event
 from ..nicegui import app
@@ -48,6 +49,8 @@ class Upload(DisableableElement, component='upload.js'):
         if max_files is not None:
             self._props['max-files'] = max_files
 
+        self._upload_handlers = [on_upload] if on_upload else []
+
         @app.post(self._props['url'])
         async def upload_route(request: Request) -> Dict[str, str]:
             for data in (await request.form()).values():
@@ -59,12 +62,22 @@ class Upload(DisableableElement, component='upload.js'):
                     name=data.filename or '',
                     type=data.content_type or '',
                 )
-                handle_event(on_upload, args)
+                for handler in self._upload_handlers:
+                    handle_event(handler, args)
             return {'upload': 'success'}
 
         if on_rejected:
-            self.on('rejected', lambda _: handle_event(on_rejected, UiEventArguments(sender=self, client=self.client)),
-                    args=[])
+            self.on_rejected(on_rejected)
+
+    def on_upload(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a file is uploaded."""
+        self._upload_handlers.append(callback)
+        return self
+
+    def on_rejected(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a file is rejected."""
+        self.on('rejected', lambda: handle_event(callback, UiEventArguments(sender=self, client=self.client)), args=[])
+        return self
 
     def reset(self) -> None:
         """Clear the upload queue."""
diff --git a/nicegui/ui.py b/nicegui/ui.py
index 7074f18f..2082dc7b 100644
--- a/nicegui/ui.py
+++ b/nicegui/ui.py
@@ -60,6 +60,7 @@ __all__ = [
     'pyplot',
     'query',
     'radio',
+    'range',
     'restructured_text',
     'row',
     'scene',
@@ -173,6 +174,7 @@ from .elements.progress import LinearProgress as linear_progress
 from .elements.pyplot import Pyplot as pyplot
 from .elements.query import Query as query
 from .elements.radio import Radio as radio
+from .elements.range import Range as range
 from .elements.restructured_text import ReStructuredText as restructured_text
 from .elements.row import Row as row
 from .elements.scene import Scene as scene
diff --git a/website/documentation/content/interactive_image_documentation.py b/website/documentation/content/interactive_image_documentation.py
index 49071fce..c269c8c7 100644
--- a/website/documentation/content/interactive_image_documentation.py
+++ b/website/documentation/content/interactive_image_documentation.py
@@ -70,4 +70,26 @@ def crosshairs():
     ui.interactive_image('https://picsum.photos/id/565/640/360', cross='red')
 
 
+@doc.demo('SVG events', '''
+    You can subscribe to events of the SVG elements by using the `on` method with an "svg:" prefix.
+    Make sure to set `pointer-events="all"` for the SVG elements you want to receive events from.
+
+    Currently the following SVG events are supported:
+
+    - pointermove
+    - pointerdown
+    - pointerup
+    - pointerover
+    - pointerout
+    - pointerenter
+    - pointerleave
+    - pointercancel
+''')
+def svg_content():
+    ui.interactive_image('https://picsum.photos/id/565/640/360', cross=True, content='''
+        <rect id="A" x="85" y="70" width="80" height="60" fill="none" stroke="red" pointer-events="all" cursor="pointer" />
+        <rect id="B" x="180" y="70" width="80" height="60" fill="none" stroke="red" pointer-events="all" cursor="pointer" />
+    ''').on('svg:pointerdown', lambda e: ui.notify(f'SVG clicked: {e.args}'))
+
+
 doc.reference(ui.interactive_image)
diff --git a/website/documentation/content/range_documentation.py b/website/documentation/content/range_documentation.py
new file mode 100644
index 00000000..02e87489
--- /dev/null
+++ b/website/documentation/content/range_documentation.py
@@ -0,0 +1,26 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.range)
+def main_demo() -> None:
+    min_max_range = ui.range(min=0, max=100, value={'min': 20, 'max': 80})
+    ui.label().bind_text_from(min_max_range, 'value',
+                              backward=lambda v: f'min: {v["min"]}, max: {v["max"]}')
+
+
+@doc.demo('Customize labels', '''
+        You can customize the colors of the range and its labels by setting them individually or for the range in total.
+''')
+def customize_labels():
+    ui.label('Color the entire range')
+    ui.range(min=0, max=100, value={'min': 20, 'max': 80}) \
+        .props('label snap color="secondary"')
+
+    ui.label('Customize the color of the labels')
+    ui.range(min=0, max=100, value={'min': 40, 'max': 80}) \
+        .props('label-always snap label-color="secondary" right-label-text-color="black"')
+
+
+doc.reference(ui.range)
diff --git a/website/documentation/content/section_controls.py b/website/documentation/content/section_controls.py
index 28a73a7d..a4f06c4b 100644
--- a/website/documentation/content/section_controls.py
+++ b/website/documentation/content/section_controls.py
@@ -1,6 +1,6 @@
 from . import (badge_documentation, button_documentation, checkbox_documentation, color_input_documentation,
                color_picker_documentation, date_documentation, doc, input_documentation, joystick_documentation,
-               knob_documentation, number_documentation, radio_documentation, select_documentation,
+               knob_documentation, number_documentation, radio_documentation, range_documentation, select_documentation,
                slider_documentation, switch_documentation, textarea_documentation, time_documentation,
                toggle_documentation, upload_documentation)
 
@@ -14,6 +14,7 @@ doc.intro(select_documentation)
 doc.intro(checkbox_documentation)
 doc.intro(switch_documentation)
 doc.intro(slider_documentation)
+doc.intro(range_documentation)
 doc.intro(joystick_documentation)
 doc.intro(input_documentation)
 doc.intro(textarea_documentation)


Test patch:
diff --git a/tests/test_range.py b/tests/test_range.py
new file mode 100644
index 00000000..69fb6f5d
--- /dev/null
+++ b/tests/test_range.py
@@ -0,0 +1,10 @@
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+def test_range(screen: Screen):
+    r = ui.range(min=0, max=100, value={'min': 20, 'max': 80})
+    ui.label().bind_text_from(r, 'value', backward=lambda v: f'min: {v["min"]}, max: {v["max"]}')
+
+    screen.open('/')
+    screen.should_contain('min: 20, max: 80')