Repository: zauberzeug/nicegui
Pull request #4001 (2024-11-17 20:38:16)

Add ui.input trigger autocomplete trigger capability
This fixes the issue mentioned in #3886 .

The PR implements a workaround in the testing/user_interaction module by checking if a trigger event is a "tab" event and then replacing the current text value by the first element in the autocomplete list.

rodja: Thanks for implementing this feature. Looks good so far. Maybe you could also add an example to the documentation?

Patch:
diff --git a/nicegui/classes.py b/nicegui/classes.py
index 74f90c1e..062684b1 100644
--- a/nicegui/classes.py
+++ b/nicegui/classes.py
@@ -15,8 +15,9 @@ class Classes(list, Generic[T]):
     def __call__(self,
                  add: Optional[str] = None, *,
                  remove: Optional[str] = None,
+                 toggle: Optional[str] = None,
                  replace: Optional[str] = None) -> T:
-        """Apply, remove, or replace HTML classes.
+        """Apply, remove, toggle, or replace HTML classes.
 
         This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
 
@@ -24,9 +25,10 @@ class Classes(list, Generic[T]):
 
         :param add: whitespace-delimited string of classes
         :param remove: whitespace-delimited string of classes to remove from the element
+        :param toggle: whitespace-delimited string of classes to toggle
         :param replace: whitespace-delimited string of classes to use instead of existing ones
         """
-        new_classes = self.update_list(self, add, remove, replace)
+        new_classes = self.update_list(self, add, remove, toggle, replace)
         if self != new_classes:
             self[:] = new_classes
             self.element.update()
@@ -36,10 +38,18 @@ class Classes(list, Generic[T]):
     def update_list(classes: List[str],
                     add: Optional[str] = None,
                     remove: Optional[str] = None,
+                    toggle: Optional[str] = None,
                     replace: Optional[str] = None) -> List[str]:
         """Update a list of classes."""
         class_list = classes if replace is None else []
         class_list = [c for c in class_list if c not in (remove or '').split()]
         class_list += (add or '').split()
         class_list += (replace or '').split()
-        return list(dict.fromkeys(class_list))  # NOTE: remove duplicates while preserving order
+        class_list = list(dict.fromkeys(class_list))  # NOTE: remove duplicates while preserving order
+        if toggle is not None:
+            for class_ in toggle.split():
+                if class_ in class_list:
+                    class_list.remove(class_)
+                else:
+                    class_list.append(class_)
+        return class_list
diff --git a/nicegui/element.py b/nicegui/element.py
index 29af8709..ffa8e13b 100644
--- a/nicegui/element.py
+++ b/nicegui/element.py
@@ -222,8 +222,9 @@ class Element(Visibility):
     def default_classes(cls,
                         add: Optional[str] = None, *,
                         remove: Optional[str] = None,
+                        toggle: Optional[str] = None,
                         replace: Optional[str] = None) -> type[Self]:
-        """Apply, remove, or replace default HTML classes.
+        """Apply, remove, toggle, or replace default HTML classes.
 
         This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
 
@@ -233,9 +234,10 @@ class Element(Visibility):
 
         :param add: whitespace-delimited string of classes
         :param remove: whitespace-delimited string of classes to remove from the element
+        :param toggle: whitespace-delimited string of classes to toggle
         :param replace: whitespace-delimited string of classes to use instead of existing ones
         """
-        cls._default_classes = Classes.update_list(cls._default_classes, add, remove, replace)
+        cls._default_classes = Classes.update_list(cls._default_classes, add, remove, toggle, replace)
         return cls
 
     @property
diff --git a/nicegui/elements/dark_mode.py b/nicegui/elements/dark_mode.py
index 6deda2c9..e3930139 100644
--- a/nicegui/elements/dark_mode.py
+++ b/nicegui/elements/dark_mode.py
@@ -1,5 +1,6 @@
 from typing import Optional
 
+from .. import core, helpers
 from ..events import Handler, ValueChangeEventArguments
 from .mixins.value_element import ValueElement
 
@@ -20,6 +21,19 @@ class DarkMode(ValueElement, component='dark_mode.js'):
         """
         super().__init__(value=value, on_value_change=on_change)
 
+        # HACK: this is a temporary warning to inform users about issue #3753
+        if core.app.is_started:
+            self._check_for_issue_3753()
+        else:
+            core.app.on_startup(self._check_for_issue_3753)
+
+    def _check_for_issue_3753(self) -> None:
+        if self.client.page.resolve_dark() is None and core.app.config.tailwind:
+            helpers.warn_once(
+                '`ui.dark_mode` is not supported on pages with `dark=None` while running with `tailwind=True` (the default). '
+                'See https://github.com/zauberzeug/nicegui/issues/3753 for more information.'
+            )
+
     def enable(self) -> None:
         """Enable dark mode."""
         self.value = True
diff --git a/nicegui/elements/echart.js b/nicegui/elements/echart.js
index 3a5e33a6..9ab0e95d 100644
--- a/nicegui/elements/echart.js
+++ b/nicegui/elements/echart.js
@@ -9,7 +9,7 @@ export default {
       await import("echarts-gl");
     }
 
-    this.chart = echarts.init(this.$el);
+    this.chart = echarts.init(this.$el, null, { renderer: this.renderer });
     this.chart.on("click", (e) => this.$emit("pointClick", e));
     for (const event of [
       "click",
@@ -90,5 +90,6 @@ export default {
   props: {
     options: Object,
     enable_3d: Boolean,
+    renderer: String,
   },
 };
diff --git a/nicegui/elements/echart.py b/nicegui/elements/echart.py
index 5c5f45ce..c0d290d3 100644
--- a/nicegui/elements/echart.py
+++ b/nicegui/elements/echart.py
@@ -1,4 +1,4 @@
-from typing import Callable, Dict, Optional
+from typing import Callable, Dict, Literal, Optional
 
 from typing_extensions import Self
 
@@ -22,7 +22,12 @@ class EChart(Element,
              dependencies=['lib/echarts/echarts.min.js', 'lib/echarts-gl/echarts-gl.min.js'],
              default_classes='nicegui-echart'):
 
-    def __init__(self, options: Dict, on_point_click: Optional[Handler[EChartPointClickEventArguments]] = None, *, enable_3d: bool = False) -> None:
+    def __init__(self,
+                 options: Dict,
+                 on_point_click: Optional[Handler[EChartPointClickEventArguments]] = None, *,
+                 enable_3d: bool = False,
+                 renderer: Literal['canvas', 'svg'] = 'canvas',
+                 ) -> None:
         """Apache EChart
 
         An element to create a chart using `ECharts <https://echarts.apache.org/>`_.
@@ -32,10 +37,12 @@ class EChart(Element,
         :param options: dictionary of EChart options
         :param on_click_point: callback that is invoked when a point is clicked
         :param enable_3d: enforce importing the echarts-gl library
+        :param renderer: renderer to use ("canvas" or "svg")
         """
         super().__init__()
         self._props['options'] = options
         self._props['enable_3d'] = enable_3d or any('3D' in key for key in options)
+        self._props['renderer'] = renderer
 
         if on_point_click:
             self.on_point_click(on_point_click)
diff --git a/nicegui/elements/query.py b/nicegui/elements/query.py
index 67d4a78b..7c603afd 100644
--- a/nicegui/elements/query.py
+++ b/nicegui/elements/query.py
@@ -37,9 +37,13 @@ class Query:
         else:
             self.element = QueryElement(selector)
 
-    def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
-            -> Self:
-        """Apply, remove, or replace HTML classes.
+    def classes(self,
+                add: Optional[str] = None, *,
+                remove: Optional[str] = None,
+                toggle: Optional[str] = None,
+                replace: Optional[str] = None,
+                ) -> Self:
+        """Apply, remove, toggle, or replace HTML classes.
 
         This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
 
@@ -47,9 +51,10 @@ class Query:
 
         :param add: whitespace-delimited string of classes
         :param remove: whitespace-delimited string of classes to remove from the element
+        :param toggle: whitespace-delimited string of classes to toggle
         :param replace: whitespace-delimited string of classes to use instead of existing ones
         """
-        classes = Classes.update_list(self.element.props['classes'], add, remove, replace)
+        classes = Classes.update_list(self.element.props['classes'], add, remove, toggle, replace)
         new_classes = [c for c in classes if c not in self.element.props['classes']]
         old_classes = [c for c in self.element.props['classes'] if c not in classes]
         if new_classes:
diff --git a/nicegui/elements/scene.js b/nicegui/elements/scene.js
index 659e9265..c72469a1 100644
--- a/nicegui/elements/scene.js
+++ b/nicegui/elements/scene.js
@@ -413,6 +413,22 @@ export default {
       const geometry = this.objects.get(object_id).geometry;
       set_point_cloud_data(position, color, geometry);
     },
+    attach(object_id, parent_id, x, y, z, R) {
+      if (!this.objects.has(object_id)) return;
+      const object = this.objects.get(object_id);
+      const parent = this.objects.get(parent_id);
+      parent.add(object);
+      this.move(object_id, x, y, z);
+      this.rotate(object_id, R);
+    },
+    detach(object_id, x, y, z, R) {
+      if (!this.objects.has(object_id)) return;
+      const object = this.objects.get(object_id);
+      object.removeFromParent();
+      this.scene.add(object);
+      this.move(object_id, x, y, z);
+      this.rotate(object_id, R);
+    },
     move_camera(x, y, z, look_at_x, look_at_y, look_at_z, up_x, up_y, up_z, duration) {
       if (this.camera_tween) this.camera_tween.stop();
       const camera_up_changed = up_x !== null || up_y !== null || up_z !== null;
diff --git a/nicegui/elements/scene_object3d.py b/nicegui/elements/scene_object3d.py
index 67a133ed..f30a608a 100644
--- a/nicegui/elements/scene_object3d.py
+++ b/nicegui/elements/scene_object3d.py
@@ -196,6 +196,138 @@ class Object3D:
             self._draggable()
         return self
 
+    def attach(self, parent: Object3D) -> None:
+        """Attach the object to a parent object.
+
+        The position and rotation of the object are preserved so that the object does not move in space.
+
+        But note that scaling is not preserved.
+        If either the parent or the object itself is scaled, the object shape and position can change.
+        """
+        self.detach()
+        self.parent = parent
+        self._move_into_parent(parent)
+        self.scene.run_method('attach', self.id, parent.id, self.x, self.y, self.z, self.R)
+
+    def _move_into_parent(self, parent: Union[Object3D, SceneObject]) -> None:
+        if not isinstance(parent, Object3D):
+            return
+        if isinstance(parent.parent, Object3D):
+            self._move_into_parent(parent.parent)
+        M1: List[List[float]] = [
+            [self.R[0][0], self.R[0][1], self.R[0][2], self.x],
+            [self.R[1][0], self.R[1][1], self.R[1][2], self.y],
+            [self.R[2][0], self.R[2][1], self.R[2][2], self.z],
+            [0, 0, 0, 1],
+        ]
+        M2_inv: List[List[float]] = [
+            [parent.R[0][0], parent.R[1][0], parent.R[2][0],
+             - parent.R[0][0] * parent.x
+             - parent.R[1][0] * parent.y
+             - parent.R[2][0] * parent.z],
+            [parent.R[0][1], parent.R[1][1], parent.R[2][1],
+             - parent.R[0][1] * parent.x
+             - parent.R[1][1] * parent.y
+             - parent.R[2][1] * parent.z],
+            [parent.R[0][2], parent.R[1][2], parent.R[2][2],
+             - parent.R[0][2] * parent.x
+             - parent.R[1][2] * parent.y
+             - parent.R[2][2] * parent.z],
+            [0, 0, 0, 1],
+        ]
+        M: List[List[float]] = [
+            [
+                M2_inv[0][0] * M1[0][0] + M2_inv[0][1] * M1[1][0] + M2_inv[0][2] * M1[2][0],
+                M2_inv[0][0] * M1[0][1] + M2_inv[0][1] * M1[1][1] + M2_inv[0][2] * M1[2][1],
+                M2_inv[0][0] * M1[0][2] + M2_inv[0][1] * M1[1][2] + M2_inv[0][2] * M1[2][2],
+                M2_inv[0][0] * M1[0][3] + M2_inv[0][1] * M1[1][3] + M2_inv[0][2] * M1[2][3] + M2_inv[0][3],
+            ],
+            [
+                M2_inv[1][0] * M1[0][0] + M2_inv[1][1] * M1[1][0] + M2_inv[1][2] * M1[2][0],
+                M2_inv[1][0] * M1[0][1] + M2_inv[1][1] * M1[1][1] + M2_inv[1][2] * M1[2][1],
+                M2_inv[1][0] * M1[0][2] + M2_inv[1][1] * M1[1][2] + M2_inv[1][2] * M1[2][2],
+                M2_inv[1][0] * M1[0][3] + M2_inv[1][1] * M1[1][3] + M2_inv[1][2] * M1[2][3] + M2_inv[1][3],
+            ],
+            [
+                M2_inv[2][0] * M1[0][0] + M2_inv[2][1] * M1[1][0] + M2_inv[2][2] * M1[2][0],
+                M2_inv[2][0] * M1[0][1] + M2_inv[2][1] * M1[1][1] + M2_inv[2][2] * M1[2][1],
+                M2_inv[2][0] * M1[0][2] + M2_inv[2][1] * M1[1][2] + M2_inv[2][2] * M1[2][2],
+                M2_inv[2][0] * M1[0][3] + M2_inv[2][1] * M1[1][3] + M2_inv[2][2] * M1[2][3] + M2_inv[2][3],
+            ],
+            [
+                0, 0, 0, 1,
+            ],
+        ]
+        self.x = M[0][3]
+        self.y = M[1][3]
+        self.z = M[2][3]
+        self.R = [
+            [M[0][0], M[0][1], M[0][2]],
+            [M[1][0], M[1][1], M[1][2]],
+            [M[2][0], M[2][1], M[2][2]],
+        ]
+
+    def detach(self) -> None:
+        """Remove the object from its parent group object.
+
+        The position and rotation of the object are preserved so that the object does not move in space.
+
+        But note that scaling is not preserved.
+        If either the parent or the object itself is scaled, the object shape and position can change.
+        """
+        self._move_out_of_parent(self.parent)
+        self.parent = self.scene.stack[0]
+        self.scene.run_method('detach', self.id, self.x, self.y, self.z, self.R)
+
+    def _move_out_of_parent(self, parent: Union[Object3D, SceneObject]) -> None:
+        if not isinstance(parent, Object3D):
+            return
+        M1: List[List[float]] = [
+            [self.R[0][0], self.R[0][1], self.R[0][2], self.x],
+            [self.R[1][0], self.R[1][1], self.R[1][2], self.y],
+            [self.R[2][0], self.R[2][1], self.R[2][2], self.z],
+            [0, 0, 0, 1],
+        ]
+        M2: List[List[float]] = [
+            [parent.R[0][0], parent.R[0][1], parent.R[0][2], parent.x],
+            [parent.R[1][0], parent.R[1][1], parent.R[1][2], parent.y],
+            [parent.R[2][0], parent.R[2][1], parent.R[2][2], parent.z],
+            [0, 0, 0, 1],
+        ]
+        M: List[List[float]] = [
+            [
+                M2[0][0] * M1[0][0] + M2[0][1] * M1[1][0] + M2[0][2] * M1[2][0],
+                M2[0][0] * M1[0][1] + M2[0][1] * M1[1][1] + M2[0][2] * M1[2][1],
+                M2[0][0] * M1[0][2] + M2[0][1] * M1[1][2] + M2[0][2] * M1[2][2],
+                M2[0][0] * M1[0][3] + M2[0][1] * M1[1][3] + M2[0][2] * M1[2][3] + M2[0][3],
+            ],
+            [
+                M2[1][0] * M1[0][0] + M2[1][1] * M1[1][0] + M2[1][2] * M1[2][0],
+                M2[1][0] * M1[0][1] + M2[1][1] * M1[1][1] + M2[1][2] * M1[2][1],
+                M2[1][0] * M1[0][2] + M2[1][1] * M1[1][2] + M2[1][2] * M1[2][2],
+                M2[1][0] * M1[0][3] + M2[1][1] * M1[1][3] + M2[1][2] * M1[2][3] + M2[1][3],
+            ],
+            [
+                M2[2][0] * M1[0][0] + M2[2][1] * M1[1][0] + M2[2][2] * M1[2][0],
+                M2[2][0] * M1[0][1] + M2[2][1] * M1[1][1] + M2[2][2] * M1[2][1],
+                M2[2][0] * M1[0][2] + M2[2][1] * M1[1][2] + M2[2][2] * M1[2][2],
+                M2[2][0] * M1[0][3] + M2[2][1] * M1[1][3] + M2[2][2] * M1[2][3] + M2[2][3],
+            ],
+            [
+                0, 0, 0, 1,
+            ],
+        ]
+        self.x = M[0][3]
+        self.y = M[1][3]
+        self.z = M[2][3]
+        self.R = [
+            [M[0][0], M[0][1], M[0][2]],
+            [M[1][0], M[1][1], M[1][2]],
+            [M[2][0], M[2][1], M[2][2]],
+        ]
+        if isinstance(parent.parent, Object3D):
+            self._move_out_of_parent(parent.parent)
+
     @property
     def children(self) -> List[Object3D]:
         """List of children of the object."""
diff --git a/nicegui/html.py b/nicegui/html.py
index a51bc2af..56527c83 100644
--- a/nicegui/html.py
+++ b/nicegui/html.py
@@ -52,6 +52,7 @@ figure = _create_html_element('figure')
 footer = _create_html_element('footer')
 form = _create_html_element('form')
 h1 = _create_html_element('h1')
+header = _create_html_element('header')
 hgroup = _create_html_element('hgroup')
 hr = _create_html_element('hr')
 i = _create_html_element('i')
@@ -105,6 +106,7 @@ tfoot = _create_html_element('tfoot')
 th = _create_html_element('th')
 thead = _create_html_element('thead')
 time = _create_html_element('time')
+tr = _create_html_element('tr')
 track = _create_html_element('track')
 u = _create_html_element('u')
 ul = _create_html_element('ul')
diff --git a/website/documentation/content/scene_documentation.py b/website/documentation/content/scene_documentation.py
index 8634c10e..744be6ca 100644
--- a/website/documentation/content/scene_documentation.py
+++ b/website/documentation/content/scene_documentation.py
@@ -268,4 +268,25 @@ def custom_composed_objects() -> None:
         CoordinateSystem('custom frame').move(-2, -2, 1).rotate(0.1, 0.2, 0.3)
 
 
+@doc.demo('Attaching/detaching objects', '''
+    To add or remove objects from groups you can use the `attach` and `detach` methods.
+    The position and rotation of the object are preserved so that the object does not move in space.
+    But note that scaling is not preserved.
+    If either the parent or the object itself is scaled, the object shape and position can change.
+''')
+def attach_detach() -> None:
+    import math
+    import time
+
+    with ui.scene().classes('w-full h-64') as scene:
+        with scene.group() as group:
+            a = scene.box().move(-2)
+            b = scene.box().move(0)
+            c = scene.box().move(2)
+
+    ui.timer(0.1, lambda: group.move(y=math.sin(time.time())).rotate(0, 0, time.time()))
+    ui.button('Detach', on_click=a.detach)
+    ui.button('Attach', on_click=lambda: a.attach(group))
+
+
 doc.reference(ui.scene)
diff --git a/website/documentation/content/user_documentation.py b/website/documentation/content/user_documentation.py
index 00da5e45..991fc496 100644
--- a/website/documentation/content/user_documentation.py
+++ b/website/documentation/content/user_documentation.py
@@ -156,6 +156,32 @@ def upload_table():
             ''')
 
 
+doc.text('Autocomplete', '''
+    The `UserInteraction` object returned by `user.find(...)` provides methods to trigger events on the found elements.
+    This demo shows how to trigger a "keydown.tab" event to autocomplete an input field.
+''')
+
+
+@doc.ui
+def trigger_events():
+    with ui.row().classes('gap-4 items-stretch'):
+        with python_window(classes='w-[500px]', title='some UI code'):
+            ui.markdown('''
+                ```python
+                fruits = ['apple', 'banana', 'cherry']
+                ui.input(label='fruit', autocomplete=fruits)
+                ```
+            ''')
+        with python_window(classes='w-[500px]', title='user assertions'):
+            ui.markdown('''
+                ```python
+                await user.open('/')
+                user.find('fruit').type('a').trigger('keydown.tab')
+                await user.should_see('apple')
+                ```
+            ''')
+
+
 doc.text('Test Downloads', '''
     You can verify that a download was triggered by checking `user.downloads.http_responses`.
     By awaiting `user.downloads.next()` you can get the next download response.


Test patch:
diff --git a/nicegui/testing/user_interaction.py b/nicegui/testing/user_interaction.py
index 2801cd7e..469011e3 100644
--- a/nicegui/testing/user_interaction.py
+++ b/nicegui/testing/user_interaction.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Generic, Set, Type, TypeVar, Union
+from typing import TYPE_CHECKING, Generic, List, Set, Type, TypeVar, Union
 
 from typing_extensions import Self
 
@@ -36,6 +36,13 @@ class UserInteraction(Generic[T]):
         assert self.user.client
         with self.user.client:
             for element in self.elements:
+                if isinstance(element, ui.input) and event == 'keydown.tab':
+                    autocomplete: List[str] = element.props['_autocomplete']
+                    for option in autocomplete:
+                        if option.startswith(element.value):
+                            element.value = option
+                            break
+
                 for listener in element._event_listeners.values():  # pylint: disable=protected-access
                     if listener.type != event:
                         continue
diff --git a/tests/test_element.py b/tests/test_element.py
index 66cce207..e7592e0a 100644
--- a/tests/test_element.py
+++ b/tests/test_element.py
@@ -35,6 +35,12 @@ def test_classes(screen: Screen):
     label.classes(replace='four')
     assert_classes('four')
 
+    label.classes(toggle='bg-red-500')
+    assert_classes('four bg-red-500')
+
+    label.classes(toggle='bg-red-500')
+    assert_classes('four')
+
 
 @pytest.mark.parametrize('value,expected', [
     (None, {}),
diff --git a/tests/test_user_simulation.py b/tests/test_user_simulation.py
index e79569a0..5fbc30a8 100644
--- a/tests/test_user_simulation.py
+++ b/tests/test_user_simulation.py
@@ -410,3 +410,12 @@ async def test_validation(user: User) -> None:
     await user.should_not_see('Not a number')
     user.find(ui.input).type('some invalid entry')
     await user.should_see('Not a number')
+
+
+async def test_trigger_autocomplete(user: User) -> None:
+    ui.input(label='fruit', autocomplete=['apple', 'banana', 'cherry'])
+
+    await user.open('/')
+    await user.should_not_see('apple')
+    user.find('fruit').type('a').trigger('keydown.tab')
+    await user.should_see('apple')