Преглед изворни кода

Merge branch 'main' into feature/dependencies

Dominique CLAUSE пре 2 година
родитељ
комит
c3956e901d
65 измењених фајлова са 1089 додато и 333 уклоњено
  1. 3 3
      CITATION.cff
  2. 3 0
      CONTRIBUTING.md
  3. 15 14
      examples/chat_app/main.py
  4. 1 1
      fly.dockerfile
  5. 1 1
      fly.toml
  6. 50 38
      main.py
  7. 21 7
      nicegui/binding.py
  8. 1 0
      nicegui/element.py
  9. 2 1
      nicegui/elements/button.py
  10. 20 0
      nicegui/elements/chat_message.js
  11. 40 0
      nicegui/elements/chat_message.py
  12. 2 1
      nicegui/elements/checkbox.py
  13. 2 1
      nicegui/elements/color_input.py
  14. 21 0
      nicegui/elements/dark_mode.js
  15. 46 0
      nicegui/elements/dark_mode.py
  16. 2 1
      nicegui/elements/date.py
  17. 2 1
      nicegui/elements/expansion.py
  18. 24 0
      nicegui/elements/grid.py
  19. 26 3
      nicegui/elements/input.py
  20. 2 1
      nicegui/elements/knob.py
  21. 1 0
      nicegui/elements/markdown.py
  22. 81 0
      nicegui/elements/mixins/disableable_element.py
  23. 3 2
      nicegui/elements/number.py
  24. 2 1
      nicegui/elements/radio.py
  25. 2 1
      nicegui/elements/select.py
  26. 2 1
      nicegui/elements/slider.py
  27. 3 1
      nicegui/elements/splitter.py
  28. 2 1
      nicegui/elements/switch.py
  29. 5 5
      nicegui/elements/tabs.py
  30. 2 1
      nicegui/elements/time.py
  31. 2 1
      nicegui/elements/toggle.py
  32. 27 0
      nicegui/elements/upload.js
  33. 6 3
      nicegui/elements/upload.py
  34. 5 0
      nicegui/elements/video.js
  35. 7 0
      nicegui/elements/video.py
  36. 19 4
      nicegui/favicon.py
  37. 3 0
      nicegui/functions/refreshable.js
  38. 45 9
      nicegui/functions/refreshable.py
  39. 14 6
      nicegui/native_mode.py
  40. 1 1
      nicegui/page.py
  41. 2 1
      nicegui/run_with.py
  42. 9 0
      nicegui/static/nicegui.css
  43. 3 0
      nicegui/ui.py
  44. 129 126
      poetry.lock
  45. 1 1
      pyproject.toml
  46. 0 14
      tests/input.py
  47. 17 0
      tests/test_button.py
  48. 36 0
      tests/test_dark_mode.py
  49. 38 0
      tests/test_input.py
  50. 37 0
      tests/test_upload.py
  51. 35 35
      website/demo.py
  52. 74 25
      website/documentation.py
  53. 6 5
      website/documentation_tools.py
  54. 8 0
      website/more_documentation/chat_message_documentation.py
  55. 13 0
      website/more_documentation/dark_mode_documentation.py
  56. 22 0
      website/more_documentation/dialog_documentation.py
  57. 13 0
      website/more_documentation/grid_documentation.py
  58. 1 1
      website/more_documentation/icon_documentation.py
  59. 13 0
      website/more_documentation/input_documentation.py
  60. 58 0
      website/more_documentation/run_documentation.py
  61. 10 0
      website/more_documentation/slider_documentation.py
  62. 12 0
      website/more_documentation/table_documentation.py
  63. 21 12
      website/more_documentation/timer_documentation.py
  64. 11 0
      website/more_documentation/video_documentation.py
  65. 4 3
      website/star.py

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based interfaces with Python. The nice way.'
-version: v1.2.8
-date-released: '2023-04-17'
+version: v1.2.10
+date-released: '2023-04-27'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.7835809
+doi: 10.5281/zenodo.7871621

+ 3 - 0
CONTRIBUTING.md

@@ -130,6 +130,9 @@ Besides the documentation with interactive demos (see above) we collect useful,
 Each example should be about one concept.
 Please try to make them as minimal as possible to show what is needed to get some kind of functionality.
 We are happy to merge pull requests with new examples which show new concepts, ideas or interesting use cases.
+To list your addition on the website itself, you can use the `example_link` function below the
+["In-depth examples" section heading](https://github.com/zauberzeug/nicegui/blob/8a86d2064f8f4464f3819ac5c6763a2cb2d0e990/main.py#L242).
+The title should match the example folder name when [snake case converted](https://github.com/zauberzeug/nicegui/blob/8a86d2064f8f4464f3819ac5c6763a2cb2d0e990/website/style.py#L31).
 
 ## Pull requests
 

+ 15 - 14
examples/chat_app/main.py

@@ -1,27 +1,29 @@
 #!/usr/bin/env python3
-import asyncio
+from datetime import datetime
 from typing import List, Tuple
 
 from nicegui import Client, ui
 
 messages: List[Tuple[str, str]] = []
-contents: List[ui.column] = []
 
 
-async def update(content: ui.column) -> None:
-    content.clear()
-    with content:  # use the context of each client to update their ui
-        for name, text in messages:
-            ui.markdown(f'**{name or "someone"}:** {text}').classes('text-lg m-2')
-        await ui.run_javascript(f'window.scrollTo(0, document.body.scrollHeight)', respond=False)
+@ui.refreshable
+async def chat_messages(name_input: ui.input) -> None:
+    for name, text in messages:
+        ui.chat_message(text=text,
+                        name=name,
+                        stamp=datetime.utcnow().strftime('%X'),
+                        avatar=f'https://robohash.org/{name or "anonymous"}?bgset=bg2',
+                        sent=name == name_input.value)
+    await ui.run_javascript('window.scrollTo(0, document.body.scrollHeight)', respond=False)
 
 
 @ui.page('/')
 async def main(client: Client):
-    async def send() -> None:
+    def send() -> None:
         messages.append((name.value, text.value))
         text.value = ''
-        await asyncio.gather(*[update(content) for content in contents])  # run updates concurrently
+        chat_messages.refresh()
 
     anchor_style = r'a:link, a:visited {color: inherit !important; text-decoration: none; font-weight: 500}'
     ui.add_head_html(f'<style>{anchor_style}</style>')
@@ -33,9 +35,8 @@ async def main(client: Client):
         ui.markdown('simple chat app built with [NiceGUI](https://nicegui.io)') \
             .classes('text-xs self-end mr-8 m-[-1em] text-primary')
 
-    await client.connected()  # update(...) uses run_javascript which is only possible after connecting
-    contents.append(ui.column().classes('w-full max-w-2xl mx-auto'))  # save ui context for updates
-    await update(contents[-1])  # ensure all messages are shown after connecting
-
+    await client.connected()  # chat_messages(...) uses run_javascript which is only possible after connecting
+    with ui.column().classes('w-full max-w-2xl mx-auto items-stretch'):
+        await chat_messages(name_input=name)
 
 ui.run()

+ 1 - 1
fly.dockerfile

@@ -2,7 +2,7 @@ FROM python:3.11-slim
 
 LABEL maintainer="Zauberzeug GmbH <nicegui@zauberzeug.com>"
 
-RUN pip install itsdangerous prometheus_client isort docutils
+RUN pip install itsdangerous prometheus_client isort docutils pandas
 
 WORKDIR /app
 

+ 1 - 1
fly.toml

@@ -12,7 +12,7 @@ processes = []
 [deploy]
 # boot a single, new VM with the new release, verify its health, then
 # One by one, each running VM is taken down and replaced by the new release VM
-strategy = "canary" 
+strategy = "rolling" 
 
 
 [experimental]

+ 50 - 38
main.py

@@ -10,6 +10,7 @@ if True:
 
 import os
 from pathlib import Path
+from typing import Optional
 
 from fastapi import Request
 from fastapi.responses import FileResponse, RedirectResponse
@@ -61,7 +62,7 @@ def add_head_html() -> None:
     ui.add_head_html(f"<style>{(Path(__file__).parent / 'website' / 'static' / 'style.css').read_text()}</style>")
 
 
-def add_header() -> None:
+def add_header(menu: Optional[ui.left_drawer] = None) -> None:
     menu_items = {
         'Installation': '/#installation',
         'Features': '/#features',
@@ -73,14 +74,12 @@ def add_header() -> None:
     with ui.header() \
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
+        if menu:
+            ui.button(on_click=menu.toggle).props('flat color=white icon=menu round') \
+                .classes('max-[405px]:hidden lg:hidden')
         with ui.link(target=index_page).classes('row gap-4 items-center no-wrap mr-auto'):
             svg.face().classes('w-8 stroke-white stroke-2')
             svg.word().classes('w-24')
-        with ui.row().classes('lg:hidden'):
-            with ui.menu().classes('bg-primary text-white text-lg') as menu:
-                for title, target in menu_items.items():
-                    ui.menu_item(title, on_click=lambda _, target=target: ui.open(target))
-            ui.button(on_click=menu.open).props('flat color=white icon=menu')
         with ui.row().classes('max-lg:hidden'):
             for title, target in menu_items.items():
                 ui.link(title, target).classes(replace='text-lg text-white')
@@ -88,7 +87,12 @@ def add_header() -> None:
             svg.discord().classes('fill-white scale-125 m-1')
         with ui.link(target='https://github.com/zauberzeug/nicegui/'):
             svg.github().classes('fill-white scale-125 m-1')
-        add_star()
+        add_star().classes('max-[460px]:hidden')
+        with ui.row().classes('lg:hidden'):
+            with ui.button().props('flat color=white icon=more_vert round'):
+                with ui.menu().classes('bg-primary text-white text-lg').props(remove='no-parent-event'):
+                    for title, target in menu_items.items():
+                        ui.menu_item(title, on_click=lambda _, target=target: ui.open(target))
 
 
 @ui.page('/')
@@ -136,18 +140,25 @@ async def index_page(client: Client):
                 ui.html('<em>1.</em>').classes('text-3xl font-bold')
                 ui.markdown('Create __main.py__').classes('text-lg')
                 with python_window(classes='w-full h-52'):
-                    ui.markdown('''```python\n
-from nicegui import ui
+                    ui.markdown('''
+                        ```python\n
+                        from nicegui import ui
 
-ui.label('Hello NiceGUI!')
+                        ui.label('Hello NiceGUI!')
 
-ui.run()
-```''')
+                        ui.run()
+                        ```
+                    ''')
             with ui.column().classes('w-full max-w-md gap-2'):
                 ui.html('<em>2.</em>').classes('text-3xl font-bold')
                 ui.markdown('Install and launch').classes('text-lg')
                 with bash_window(classes='w-full h-52'):
-                    ui.markdown('```bash\npip3 install nicegui\npython3 main.py\n```')
+                    ui.markdown('''
+                        ```bash
+                        pip3 install nicegui
+                        python3 main.py
+                        ```
+                    ''')
             with ui.column().classes('w-full max-w-md gap-2'):
                 ui.html('<em>3.</em>').classes('text-3xl font-bold')
                 ui.markdown('Enjoy!').classes('text-lg')
@@ -156,15 +167,18 @@ ui.run()
         with ui.expansion('...or use Docker to run your main.py').classes('w-full gap-2 bold-links arrow-links'):
             with ui.row().classes('mt-8 w-full justify-center items-center gap-8'):
                 ui.markdown('''
-With our [multi-arch Docker image](https://hub.docker.com/repository/docker/zauberzeug/nicegui) 
-you can start the server without installing any packages.
+                    With our [multi-arch Docker image](https://hub.docker.com/repository/docker/zauberzeug/nicegui) 
+                    you can start the server without installing any packages.
 
-The command searches for `main.py` in in your current directory and makes the app available at http://localhost:8888.
-''').classes('max-w-xl')
+                    The command searches for `main.py` in in your current directory and makes the app available at http://localhost:8888.
+                ''').classes('max-w-xl')
                 with bash_window(classes='max-w-lg w-full h-52'):
-                    ui.markdown('```bash\n'
-                                'docker run -it --rm -p 8888:8080 \\\n -v "$PWD":/app zauberzeug/nicegui\n'
-                                '```')
+                    ui.markdown('''
+                        ```bash
+                        docker run -it --rm -p 8888:8080 \\
+                            -v "$PWD":/app zauberzeug/nicegui
+                        ```
+                    ''')
 
     with ui.column().classes('w-full p-8 lg:p-16 bold-links arrow-links max-w-[1600px] mx-auto'):
         link_target('features', '-50px')
@@ -229,18 +243,16 @@ The command searches for `main.py` in in your current directory and makes the ap
         with ui.row().classes('w-full text-lg leading-tight grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4'):
             example_link('Slideshow', 'implements a keyboard-controlled image slideshow')
             example_link('Authentication', 'shows how to use sessions to build a login screen')
-            example_link(
-                'Modularization',
-                'provides an example of how to modularize your application into multiple files and reuse code')
-            example_link(
-                'FastAPI',
-                'illustrates the integration of NiceGUI with an existing FastAPI application')
-            example_link(
-                'Map',
-                'demonstrates wrapping the JavaScript library [leaflet](https://leafletjs.com/) to display a map at specific locations')
-            example_link(
-                'AI Interface',
-                'utilizes the [replicate](https://replicate.com) library to perform voice-to-text transcription and generate images from prompts with Stable Diffusion')
+            example_link('Modularization',
+                         'provides an example of how to modularize your application into multiple files and reuse code')
+            example_link('FastAPI',
+                         'illustrates the integration of NiceGUI with an existing FastAPI application')
+            example_link('Map',
+                         'demonstrates wrapping the JavaScript library [leaflet](https://leafletjs.com/) '
+                         'to display a map at specific locations')
+            example_link('AI Interface',
+                         'utilizes the [replicate](https://replicate.com) library to perform voice-to-text '
+                         'transcription and generate images from prompts with Stable Diffusion')
             example_link('3D Scene', 'creates a webGL view and loads an STL mesh illuminated with a spotlight')
             example_link('Custom Vue Component', 'shows how to write and integrate a custom Vue component')
             example_link('Image Mask Overlay', 'shows how to overlay an image with a mask')
@@ -300,15 +312,15 @@ The command searches for `main.py` in in your current directory and makes the ap
 @ui.page('/documentation')
 def documentation_page():
     add_head_html()
-    add_header()
-    side_menu()
+    menu = side_menu()
+    add_header(menu)
     ui.add_head_html('<style>html {scroll-behavior: auto;}</style>')
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
         section_heading('Reference, Demos and more', '*NiceGUI* Documentation')
-        ui.markdown(
-            'This is the documentation for NiceGUI >= 1.0. '
-            'Documentation for older versions can be found at [https://0.9.nicegui.io/](https://0.9.nicegui.io/reference).'
-        ).classes('bold-links arrow-links')
+        ui.markdown('''
+            This is the documentation for NiceGUI >= 1.0.
+            Documentation for older versions can be found at [https://0.9.nicegui.io/](https://0.9.nicegui.io/reference).
+        ''').classes('bold-links arrow-links')
         documentation.create_full()
 
 

+ 21 - 7
nicegui/binding.py

@@ -2,7 +2,7 @@ import asyncio
 import logging
 import time
 from collections import defaultdict
-from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Type
+from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Type, Union
 
 from . import globals
 
@@ -11,15 +11,29 @@ bindable_properties: Dict[Tuple[int, str], Any] = {}
 active_links: List[Tuple[Any, str, Any, str, Callable]] = []
 
 
+def get_attribute(obj: Union[object, Dict], name: str) -> Any:
+    if isinstance(obj, dict):
+        return obj[name]
+    else:
+        return getattr(obj, name)
+
+
+def set_attribute(obj: Union[object, Dict], name: str, value: Any) -> None:
+    if isinstance(obj, dict):
+        obj[name] = value
+    else:
+        setattr(obj, name, value)
+
+
 async def loop():
     while True:
         visited: Set[Tuple[int, str]] = set()
         t = time.time()
         for link in active_links:
             (source_obj, source_name, target_obj, target_name, transform) = link
-            value = transform(getattr(source_obj, source_name))
-            if getattr(target_obj, target_name) != value:
-                setattr(target_obj, target_name, value)
+            value = transform(get_attribute(source_obj, source_name))
+            if get_attribute(target_obj, target_name) != value:
+                set_attribute(target_obj, target_name, value)
                 propagate(target_obj, target_name, visited)
             del link, source_obj, target_obj
         if time.time() - t > 0.01:
@@ -34,9 +48,9 @@ def propagate(source_obj: Any, source_name: str, visited: Optional[Set[Tuple[int
     for _, target_obj, target_name, transform in bindings.get((id(source_obj), source_name), []):
         if (id(target_obj), target_name) in visited:
             continue
-        target_value = transform(getattr(source_obj, source_name))
-        if getattr(target_obj, target_name) != target_value:
-            setattr(target_obj, target_name, target_value)
+        target_value = transform(get_attribute(source_obj, source_name))
+        if get_attribute(target_obj, target_name) != target_value:
+            set_attribute(target_obj, target_name, target_value)
             propagate(target_obj, target_name, visited)
 
 

+ 1 - 0
nicegui/element.py

@@ -229,6 +229,7 @@ class Element(Visibility):
                 trailing_events=trailing_events,
             )
             self._event_listeners[listener.id] = listener
+            self.update()
         return self
 
     def _handle_event(self, msg: Dict) -> None:

+ 2 - 1
nicegui/elements/button.py

@@ -2,10 +2,11 @@ from typing import Callable, Optional
 
 from ..colors import set_background_color
 from ..events import ClickEventArguments, handle_event
+from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
 
 
-class Button(TextElement):
+class Button(TextElement, DisableableElement):
 
     def __init__(self,
                  text: str = '', *,

+ 20 - 0
nicegui/elements/chat_message.js

@@ -0,0 +1,20 @@
+export default {
+  template: `
+    <q-chat-message
+      :text="[text]"
+      :name="name"
+      :label="label"
+      :stamp="stamp"
+      :avatar="avatar"
+      :sent=sent
+    />
+  `,
+  props: {
+    text: String,
+    name: String,
+    label: String,
+    stamp: String,
+    avatar: String,
+    sent: Boolean,
+  },
+};

+ 40 - 0
nicegui/elements/chat_message.py

@@ -0,0 +1,40 @@
+from typing import Optional
+
+from ..dependencies import register_component
+from ..element import Element
+
+register_component('chat_message', __file__, 'chat_message.js')
+
+
+class ChatMessage(Element):
+
+    def __init__(self,
+                 text: str, *,
+                 name: Optional[str] = None,
+                 label: Optional[str] = None,
+                 stamp: Optional[str] = None,
+                 avatar: Optional[str] = None,
+                 sent: bool = False,
+                 ) -> None:
+        """Chat Message
+
+        Based on Quasar's `Chat Message <https://quasar.dev/vue-components/chat/>`_ component.
+
+        :param text: the message body
+        :param name: the name of the message author
+        :param label: renders a label header/section only
+        :param stamp: timestamp of the message
+        :param avatar: URL to an avatar
+        :param sent: render as a sent message (so from current user) (default: False)
+        """
+        super().__init__('chat_message')
+        self._props['text'] = text
+        if name is not None:
+            self._props['name'] = name
+        if label is not None:
+            self._props['label'] = label
+        if stamp is not None:
+            self._props['stamp'] = stamp
+        if avatar is not None:
+            self._props['avatar'] = avatar
+        self._props['sent'] = sent

+ 2 - 1
nicegui/elements/checkbox.py

@@ -1,10 +1,11 @@
 from typing import Callable, Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
 from .mixins.value_element import ValueElement
 
 
-class Checkbox(TextElement, ValueElement):
+class Checkbox(TextElement, ValueElement, DisableableElement):
 
     def __init__(self, text: str = '', *, value: bool = False, on_change: Optional[Callable] = None) -> None:
         """Checkbox

+ 2 - 1
nicegui/elements/color_input.py

@@ -3,10 +3,11 @@ from typing import Callable, Optional
 from nicegui import ui
 
 from .color_picker import ColorPicker
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class ColorInput(ValueElement):
+class ColorInput(ValueElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self, label: Optional[str] = None, *,

+ 21 - 0
nicegui/elements/dark_mode.js

@@ -0,0 +1,21 @@
+export default {
+  mounted() {
+    this.update();
+  },
+  updated() {
+    this.update();
+  },
+  methods: {
+    update() {
+      Quasar.Dark.set(this.value === null ? "auto" : this.value);
+      if (window.tailwind) {
+        tailwind.config.darkMode = this.auto ? "media" : "class";
+        if (this.value) document.body.classList.add("dark");
+        else document.body.classList.remove("dark");
+      }
+    },
+  },
+  props: {
+    value: Boolean,
+  },
+};

+ 46 - 0
nicegui/elements/dark_mode.py

@@ -0,0 +1,46 @@
+from typing import Optional
+
+from ..dependencies import register_component
+from .mixins.value_element import ValueElement
+
+register_component('dark_mode', __file__, 'dark_mode.js')
+
+
+class DarkMode(ValueElement):
+    VALUE_PROP = 'value'
+
+    def __init__(self, value: Optional[bool] = False) -> None:
+        """Dark mode
+
+        You can use this element to enable, disable or toggle dark mode on the page.
+        The value `None` represents auto mode, which uses the client's system preference.
+
+        Note that this element overrides the `dark` parameter of the `ui.run` function and page decorators.
+
+        :param value: Whether dark mode is enabled. If None, dark mode is set to auto.
+        """
+        super().__init__(tag='dark_mode', value=value, on_value_change=None)
+
+    def enable(self) -> None:
+        """Enable dark mode."""
+        self.value = True
+
+    def disable(self) -> None:
+        """Disable dark mode."""
+        self.value = False
+
+    def toggle(self) -> None:
+        """Toggle dark mode.
+
+        This method will raise a ValueError if dark mode is set to auto.
+        """
+        if self.value is None:
+            raise ValueError('Cannot toggle dark mode when it is set to auto.')
+        self.value = not self.value
+
+    def auto(self) -> None:
+        """Set dark mode to auto.
+
+        This will use the client's system preference.
+        """
+        self.value = None

+ 2 - 1
nicegui/elements/date.py

@@ -1,9 +1,10 @@
 from typing import Callable, Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Date(ValueElement):
+class Date(ValueElement, DisableableElement):
     EVENT_ARGS = None
 
     def __init__(self,

+ 2 - 1
nicegui/elements/expansion.py

@@ -1,9 +1,10 @@
 from typing import Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Expansion(ValueElement):
+class Expansion(ValueElement, DisableableElement):
 
     def __init__(self, text: Optional[str] = None, *, icon: Optional[str] = None, value: bool = False) -> None:
         '''Expansion Element

+ 24 - 0
nicegui/elements/grid.py

@@ -0,0 +1,24 @@
+from typing import Optional
+
+from ..element import Element
+
+
+class Grid(Element):
+
+    def __init__(self,
+                 rows: Optional[int] = None,
+                 columns: Optional[int] = None,
+                 ) -> None:
+        '''Grid Element
+
+        Provides a container which arranges its child in a grid.
+
+        :param rows: number of rows in the grid
+        :param columns: number of columns in the grid
+        '''
+        super().__init__('div')
+        self._classes = ['nicegui-grid']
+        if rows is not None:
+            self._style['grid-template-rows'] = f'repeat({rows}, minmax(0, 1fr))'
+        if columns is not None:
+            self._style['grid-template-columns'] = f'repeat({columns}, minmax(0, 1fr))'

+ 26 - 3
nicegui/elements/input.py

@@ -1,10 +1,11 @@
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, Dict, List, Optional
 
 from .icon import Icon
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Input(ValueElement):
+class Input(ValueElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self,
@@ -14,6 +15,7 @@ class Input(ValueElement):
                  password: bool = False,
                  password_toggle_button: bool = False,
                  on_change: Optional[Callable] = None,
+                 autocomplete: Optional[List[str]] = None,
                  validation: Dict[str, Callable] = {}) -> None:
         """Text Input
 
@@ -31,7 +33,8 @@ class Input(ValueElement):
         :param value: the current value of the text input
         :param password: whether to hide the input (default: False)
         :param password_toggle_button: whether to show a button to toggle the password visibility (default: False)
-        :param on_change: callback to execute when the input is confirmed by leaving the focus
+        :param on_change: callback to execute when the value changes
+        :param autocomplete: optional list of strings for autocompletion
         :param validation: dictionary of validation rules, e.g. ``{'Too short!': lambda value: len(value) < 3}``
         """
         super().__init__(tag='q-input', value=value, on_value_change=on_change)
@@ -51,6 +54,26 @@ class Input(ValueElement):
 
         self.validation = validation
 
+        if autocomplete:
+            def find_autocompletion() -> Optional[str]:
+                if self.value:
+                    for item in autocomplete:
+                        if item.startswith(self.value):
+                            return item
+
+            def autocomplete_input() -> None:
+                match = find_autocompletion() or ''
+                self.props(f'shadow-text="{match[len(self.value):]}"')
+
+            def complete_input() -> None:
+                match = find_autocompletion()
+                if match:
+                    self.set_value(match)
+                self.props(f'shadow-text=""')
+
+            self.on('keyup', autocomplete_input)
+            self.on('keydown.tab', complete_input)
+
     def on_value_change(self, value: Any) -> None:
         super().on_value_change(value)
         for message, check in self.validation.items():

+ 2 - 1
nicegui/elements/knob.py

@@ -2,10 +2,11 @@ from typing import Optional
 
 from ..colors import set_text_color
 from .label import Label
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Knob(ValueElement):
+class Knob(ValueElement, DisableableElement):
 
     def __init__(self,
                  value: float = 0.0,

+ 1 - 0
nicegui/elements/markdown.py

@@ -25,6 +25,7 @@ class Markdown(ContentElement):
         """
         self.extras = extras
         super().__init__(tag='markdown', content=content)
+        self._classes = ['nicegui-markdown']
         self._props['codehilite_css'] = HtmlFormatter(nobackground=True).get_style_defs('.codehilite')
         self.use_component('markdown')
         if 'mermaid' in extras:

+ 81 - 0
nicegui/elements/mixins/disableable_element.py

@@ -0,0 +1,81 @@
+from typing import Any, Callable
+
+from typing_extensions import Self
+
+from ...binding import BindableProperty, bind, bind_from, bind_to
+from ...element import Element
+
+
+class DisableableElement(Element):
+    enabled = BindableProperty(on_change=lambda sender, value: sender.on_enabled_change(value))
+
+    def __init__(self, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self.enabled = True
+
+    def enable(self) -> None:
+        """Enable the element."""
+        self.enabled = True
+
+    def disable(self) -> None:
+        """Disable the element."""
+        self.enabled = False
+
+    def bind_enabled_to(self,
+                        target_object: Any,
+                        target_name: str = 'enabled',
+                        forward: Callable = lambda x: x) -> Self:
+        """Bind the enabled state of this element to the target object's target_name property.
+
+        The binding works one way only, from this element to the target.
+
+        :param target_object: The object to bind to.
+        :param target_name: The name of the property to bind to.
+        :param forward: A function to apply to the value before applying it to the target.
+        """
+        bind_to(self, 'enabled', target_object, target_name, forward)
+        return self
+
+    def bind_enabled_from(self,
+                          target_object: Any,
+                          target_name: str = 'enabled',
+                          backward: Callable = lambda x: x) -> Self:
+        """Bind the enabled state of this element from the target object's target_name property.
+
+        The binding works one way only, from the target to this element.
+
+        :param target_object: The object to bind from.
+        :param target_name: The name of the property to bind from.
+        :param backward: A function to apply to the value before applying it to this element.
+        """
+        bind_from(self, 'enabled', target_object, target_name, backward)
+        return self
+
+    def bind_enabled(self,
+                     target_object: Any,
+                     target_name: str = 'enabled', *,
+                     forward: Callable = lambda x: x,
+                     backward: Callable = lambda x: x) -> Self:
+        """Bind the enabled state of this element to the target object's target_name property.
+
+        The binding works both ways, from this element to the target and from the target to this element.
+
+        :param target_object: The object to bind to.
+        :param target_name: The name of the property to bind to.
+        :param forward: A function to apply to the value before applying it to the target.
+        :param backward: A function to apply to the value before applying it to this element.
+        """
+        bind(self, 'enabled', target_object, target_name, forward=forward, backward=backward)
+        return self
+
+    def set_enabled(self, value: bool) -> None:
+        """Set the enabled state of the element."""
+        self.enabled = value
+
+    def on_enabled_change(self, enabled: bool) -> None:
+        """Called when the element is enabled or disabled.
+
+        :param enabled: The new state.
+        """
+        self._props['disable'] = not enabled
+        self.update()

+ 3 - 2
nicegui/elements/number.py

@@ -1,9 +1,10 @@
 from typing import Any, Callable, Dict, Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Number(ValueElement):
+class Number(ValueElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self,
@@ -61,7 +62,7 @@ class Number(ValueElement):
         value = float(self.value or 0)
         value = max(value, self._props.get('min', -float('inf')))
         value = min(value, self._props.get('max', float('inf')))
-        self.set_value(self.format % value if self.format else str(value))
+        self.set_value(float(self.format % value) if self.format else value)
 
     def on_value_change(self, value: Any) -> None:
         super().on_value_change(value)

+ 2 - 1
nicegui/elements/radio.py

@@ -1,9 +1,10 @@
 from typing import Any, Callable, Dict, List, Optional, Union
 
 from .choice_element import ChoiceElement
+from .mixins.disableable_element import DisableableElement
 
 
-class Radio(ChoiceElement):
+class Radio(ChoiceElement, DisableableElement):
 
     def __init__(self, options: Union[List, Dict], *, value: Any = None, on_change: Optional[Callable] = None):
         """Radio Selection

+ 2 - 1
nicegui/elements/select.py

@@ -6,11 +6,12 @@ from typing import Any, Callable, Dict, List, Optional, Union
 from nicegui.dependencies import register_vue_component
 
 from .choice_element import ChoiceElement
+from .mixins.disableable_element import DisableableElement
 
 register_vue_component(name='select', path=Path(__file__).parent.joinpath('select.js'))
 
 
-class Select(ChoiceElement):
+class Select(ChoiceElement, DisableableElement):
 
     def __init__(self, options: Union[List, Dict], *,
                  label: Optional[str] = None,

+ 2 - 1
nicegui/elements/slider.py

@@ -1,9 +1,10 @@
 from typing import Callable, Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Slider(ValueElement):
+class Slider(ValueElement, DisableableElement):
 
     def __init__(self, *,
                  min: float,

+ 3 - 1
nicegui/elements/splitter.py

@@ -1,9 +1,11 @@
 from typing import Callable, Optional, Tuple
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Splitter(ValueElement):
+class Splitter(ValueElement, DisableableElement):
+
     def __init__(self, *,
                  horizontal: Optional[bool] = False,
                  reverse: Optional[bool] = False,

+ 2 - 1
nicegui/elements/switch.py

@@ -1,10 +1,11 @@
 from typing import Callable, Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
 from .mixins.value_element import ValueElement
 
 
-class Switch(TextElement, ValueElement):
+class Switch(TextElement, ValueElement, DisableableElement):
 
     def __init__(self, text: str = '', *, value: bool = False, on_change: Optional[Callable] = None) -> None:
         """Switch

+ 5 - 5
nicegui/elements/tabs.py

@@ -1,7 +1,7 @@
 from typing import Any, Callable, Optional
 
 from .. import globals
-from ..element import Element
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
@@ -22,7 +22,7 @@ class Tabs(ValueElement):
         self.panels: Optional[TabPanels] = None
 
 
-class Tab(Element):
+class Tab(DisableableElement):
 
     def __init__(self, name: str, label: Optional[str] = None, icon: Optional[str] = None) -> None:
         """Tab
@@ -34,7 +34,7 @@ class Tab(Element):
         :param label: label of the tab (default: `None`, meaning the same as `name`)
         :param icon: icon of the tab (default: `None`)
         """
-        super().__init__('q-tab')
+        super().__init__(tag='q-tab')
         self._props['name'] = name
         self._props['label'] = label if label is not None else name
         if icon:
@@ -65,7 +65,7 @@ class TabPanels(ValueElement):
         self._props['animated'] = animated
 
 
-class TabPanel(Element):
+class TabPanel(DisableableElement):
 
     def __init__(self, name: str) -> None:
         """Tab Panel
@@ -75,5 +75,5 @@ class TabPanel(Element):
 
         :param name: name of the tab panel (the value of the `TabPanels` element)
         """
-        super().__init__('q-tab-panel')
+        super().__init__(tag='q-tab-panel')
         self._props['name'] = name

+ 2 - 1
nicegui/elements/time.py

@@ -1,9 +1,10 @@
 from typing import Callable, Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Time(ValueElement):
+class Time(ValueElement, DisableableElement):
 
     def __init__(self,
                  value: Optional[str] = None,

+ 2 - 1
nicegui/elements/toggle.py

@@ -1,9 +1,10 @@
 from typing import Any, Callable, Dict, List, Optional, Union
 
 from .choice_element import ChoiceElement
+from .mixins.disableable_element import DisableableElement
 
 
-class Toggle(ChoiceElement):
+class Toggle(ChoiceElement, DisableableElement):
 
     def __init__(self, options: Union[List, Dict], *, value: Any = None, on_change: Optional[Callable] = None) -> None:
         """Toggle

+ 27 - 0
nicegui/elements/upload.js

@@ -0,0 +1,27 @@
+export default {
+  template: `
+    <q-uploader ref="uploader" :url="computed_url">
+        <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+            <slot :name="slot" v-bind="slotProps || {}" />
+        </template>
+    </q-uploader>
+  `,
+  mounted() {
+    setTimeout(() => {
+      this.computed_url = (window.path_prefix || "") + this.url;
+    }, 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  methods: {
+    reset() {
+      this.$refs.uploader.reset();
+    },
+  },
+  props: {
+    url: String,
+  },
+  data: function () {
+    return {
+      computed_url: this.url,
+    };
+  },
+};

+ 6 - 3
nicegui/elements/upload.py

@@ -2,12 +2,15 @@ from typing import Callable, Optional
 
 from fastapi import Request, Response
 
-from ..element import Element
+from ..dependencies import register_component
 from ..events import EventArguments, UploadEventArguments, handle_event
 from ..nicegui import app
+from .mixins.disableable_element import DisableableElement
 
+register_component('upload', __file__, 'upload.js')
 
-class Upload(Element):
+
+class Upload(DisableableElement):
 
     def __init__(self, *,
                  multiple: bool = False,
@@ -32,7 +35,7 @@ class Upload(Element):
         :param label: label for the uploader (default: `''`)
         :param auto_upload: automatically upload files when they are selected (default: `False`)
         """
-        super().__init__('q-uploader')
+        super().__init__(tag='upload')
         self._props['multiple'] = multiple
         self._props['label'] = label
         self._props['auto-upload'] = auto_upload

+ 5 - 0
nicegui/elements/video.js

@@ -1,5 +1,10 @@
 export default {
   template: `<video :controls="controls" :autoplay="autoplay" :muted="muted" :src="src" />`,
+  methods: {
+    seek(seconds) {
+      this.$el.currentTime = seconds;
+    },
+  },
   props: {
     controls: Boolean,
     autoplay: Boolean,

+ 7 - 0
nicegui/elements/video.py

@@ -38,3 +38,10 @@ class Video(Element):
         if type:
             url = f'https://github.com/zauberzeug/nicegui/pull/624'
             warnings.warn(DeprecationWarning(f'The type parameter for ui.video is deprecated and ineffective ({url}).'))
+
+    def seek(self, seconds: float) -> None:
+        """Seek to a specific position in the video.
+
+        :param seconds: the position in seconds
+        """
+        self.run_method('seek', seconds)

+ 19 - 4
nicegui/favicon.py

@@ -1,4 +1,4 @@
-import urllib
+import urllib.parse
 from pathlib import Path
 from typing import TYPE_CHECKING, Optional
 
@@ -25,7 +25,11 @@ def get_favicon_url(page: 'page', prefix: str) -> str:
         return favicon
     elif not favicon:
         return f'{prefix}/_nicegui/{__version__}/static/favicon.ico'
-    if is_char(favicon):
+    elif is_data_url(favicon):
+        return favicon
+    elif is_svg(favicon):
+        return svg_to_data_url(favicon)
+    elif is_char(favicon):
         return char_to_data_url(favicon)
     elif page.path == '/':
         return f'{prefix}/favicon.ico'
@@ -41,6 +45,14 @@ def is_char(favicon: str) -> bool:
     return len(favicon) == 1
 
 
+def is_svg(favicon: str) -> bool:
+    return favicon.strip().startswith('<svg')
+
+
+def is_data_url(favicon: str) -> bool:
+    return favicon.startswith('data:')
+
+
 def char_to_data_url(char: str) -> str:
     svg = f'''
         <svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg" >
@@ -58,6 +70,9 @@ def char_to_data_url(char: str) -> str:
             <text y=".9em" font-size="128" font-family="Georgia, sans-serif">{char}</text>
         </svg>
     '''
+    return svg_to_data_url(svg)
+
+
+def svg_to_data_url(svg: str) -> str:
     svg_urlencoded = urllib.parse.quote(svg)
-    data_url = f"data:image/svg+xml,{svg_urlencoded}"
-    return data_url
+    return f'data:image/svg+xml,{svg_urlencoded}'

+ 3 - 0
nicegui/functions/refreshable.js

@@ -0,0 +1,3 @@
+export default {
+  template: `<slot></slot>`,
+};

+ 45 - 9
nicegui/functions/refreshable.py

@@ -1,6 +1,13 @@
-from typing import Callable, List
+from typing import Any, Callable, Dict, List, Tuple
 
+from typing_extensions import Self
+
+from .. import background_tasks, globals
+from ..dependencies import register_component
 from ..element import Element
+from ..helpers import is_coroutine
+
+register_component('refreshable', __file__, 'refreshable.js')
 
 
 class refreshable:
@@ -8,19 +15,48 @@ class refreshable:
     def __init__(self, func: Callable) -> None:
         """Refreshable UI functions
 
-        The `@refreshable` decorator allows you to create functions that have a `refresh` method.
+        The `@ui.refreshable` decorator allows you to create functions that have a `refresh` method.
         This method will automatically delete all elements created by the function and recreate them.
         """
         self.func = func
-        self.containers: List[Element] = []
+        self.instance = None
+        self.containers: List[Tuple[Element, List[Any], Dict[str, Any]]] = []
+
+    def __get__(self, instance, _) -> Self:
+        self.instance = instance
+        return self
 
     def __call__(self, *args, **kwargs) -> None:
-        with Element('div') as container:
-            self.func(*args, **kwargs)
-        self.containers.append(container)
+        self.prune()
+        with Element('refreshable') as container:
+            self.containers.append((container, args, kwargs))
+            return self.func(*args, **kwargs) if self.instance is None else self.func(self.instance, *args, **kwargs)
 
     def refresh(self) -> None:
-        for container in self.containers:
+        self.prune()
+        for container, args, kwargs in self.containers:
             container.clear()
-            with container:
-                self.func()
+            if is_coroutine(self.func):
+                async def wait_for_result(container: Element, args, kwargs):
+                    with container:
+                        if self.instance is None:
+                            await self.func(*args, **kwargs)
+                        else:
+                            await self.func(self.instance, *args, **kwargs)
+                if globals.loop and globals.loop.is_running():
+                    background_tasks.create(wait_for_result(container=container, args=args, kwargs=kwargs))
+                else:
+                    globals.app.on_startup(wait_for_result(container=container, args=args, kwargs=kwargs))
+            else:
+                with container:
+                    if self.instance is None:
+                        self.func(*args, **kwargs)
+                    else:
+                        self.func(self.instance, *args, **kwargs)
+
+    def prune(self) -> None:
+        self.containers = [
+            (container, args, kwargs)
+            for container, args, kwargs in self.containers
+            if container.client.id in globals.clients
+        ]

+ 14 - 6
nicegui/native_mode.py

@@ -1,6 +1,7 @@
 import _thread
 import multiprocessing
 import socket
+import sys
 import tempfile
 import time
 import warnings
@@ -8,10 +9,13 @@ from threading import Thread
 
 from . import globals, helpers
 
-with warnings.catch_warnings():
-    # webview depends on bottle which uses the deprecated CGI function (https://github.com/bottlepy/bottle/issues/1403)
-    warnings.filterwarnings('ignore', category=DeprecationWarning)
-    import webview
+try:
+    with warnings.catch_warnings():
+        # webview depends on bottle which uses the deprecated CGI function (https://github.com/bottlepy/bottle/issues/1403)
+        warnings.filterwarnings('ignore', category=DeprecationWarning)
+        import webview
+except ModuleNotFoundError:
+    pass
 
 
 def open_window(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
@@ -21,8 +25,12 @@ def open_window(host: str, port: int, title: str, width: int, height: int, fulls
     window_kwargs = dict(url=f'http://{host}:{port}', title=title, width=width, height=height, fullscreen=fullscreen)
     window_kwargs.update(globals.app.native.window_args)
 
-    webview.create_window(**window_kwargs)
-    webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
+    try:
+        webview.create_window(**window_kwargs)
+        webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
+    except NameError:
+        print('Native mode is not supported in this configuration. Please install pywebview to use it.')
+        sys.exit(1)
 
 
 def activate(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:

+ 1 - 1
nicegui/page.py

@@ -34,7 +34,7 @@ class page:
         :param favicon: optional relative filepath or absolute URL to a favicon (default: `None`, NiceGUI icon will be used)
         :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
         :param response_timeout: maximum time for the decorated function to build the page (default: 3.0)
-        :param **kwargs: additional keyword arguments passed to FastAPI's @app.get method
+        :param kwargs: additional keyword arguments passed to FastAPI's @app.get method
         """
         self.path = path
         self.title = title

+ 2 - 1
nicegui/run_with.py

@@ -14,6 +14,7 @@ def run_with(
     dark: Optional[bool] = False,
     binding_refresh_interval: float = 0.1,
     exclude: str = '',
+    mount_path: str = '/',
 ) -> None:
     globals.ui_run_has_been_called = True
     globals.title = title
@@ -27,4 +28,4 @@ def run_with(
     app.on_event('startup')(lambda: handle_startup(with_welcome_message=False))
     app.on_event('shutdown')(lambda: handle_shutdown())
 
-    app.mount('/', globals.app)
+    app.mount(mount_path, globals.app)

+ 9 - 0
nicegui/static/nicegui.css

@@ -36,6 +36,10 @@
   align-items: flex-start;
   gap: 1rem;
 }
+.nicegui-grid {
+  display: grid;
+  gap: 1rem;
+}
 .nicegui-card {
   display: flex;
   flex-direction: column;
@@ -67,6 +71,11 @@
   opacity: 1 !important;
   cursor: text !important;
 }
+.nicegui-markdown blockquote {
+  border-left: 0.25rem solid #8884;
+  padding: 1rem 1rem 0.5rem 1rem;
+  margin: 1rem 0;
+}
 
 #popup {
   position: fixed;

+ 3 - 0
nicegui/ui.py

@@ -11,14 +11,17 @@ from .elements.card import Card as card
 from .elements.card import CardActions as card_actions
 from .elements.card import CardSection as card_section
 from .elements.chart import Chart as chart
+from .elements.chat_message import ChatMessage as chat_message
 from .elements.checkbox import Checkbox as checkbox
 from .elements.color_input import ColorInput as color_input
 from .elements.color_picker import ColorPicker as color_picker
 from .elements.colors import Colors as colors
 from .elements.column import Column as column
+from .elements.dark_mode import DarkMode as dark_mode
 from .elements.date import Date as date
 from .elements.dialog import Dialog as dialog
 from .elements.expansion import Expansion as expansion
+from .elements.grid import Grid as grid
 from .elements.html import Html as html
 from .elements.icon import Icon as icon
 from .elements.image import Image as image

+ 129 - 126
poetry.lock

@@ -65,22 +65,25 @@ files = [
 
 [[package]]
 name = "attrs"
-version = "22.2.0"
+version = "23.1.0"
 description = "Classes Without Boilerplate"
 category = "dev"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 files = [
-    {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"},
-    {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"},
+    {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"},
+    {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"},
 ]
 
+[package.dependencies]
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+
 [package.extras]
-cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"]
-dev = ["attrs[docs,tests]"]
-docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"]
-tests = ["attrs[tests-no-zope]", "zope.interface"]
-tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"]
+cov = ["attrs[tests]", "coverage[toml] (>=5.3)"]
+dev = ["attrs[docs,tests]", "pre-commit"]
+docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"]
+tests = ["attrs[tests-no-zope]", "zope-interface"]
+tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
 
 [[package]]
 name = "autopep8"
@@ -488,25 +491,25 @@ tests = ["asttokens", "littleutils", "pytest", "rich"]
 
 [[package]]
 name = "fastapi"
-version = "0.92.0"
+version = "0.95.1"
 description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "fastapi-0.92.0-py3-none-any.whl", hash = "sha256:ae7b97c778e2f2ec3fb3cb4fb14162129411d99907fb71920f6d69a524340ebf"},
-    {file = "fastapi-0.92.0.tar.gz", hash = "sha256:023a0f5bd2c8b2609014d3bba1e14a1d7df96c6abea0a73070621c9862b9a4de"},
+    {file = "fastapi-0.95.1-py3-none-any.whl", hash = "sha256:a870d443e5405982e1667dfe372663abf10754f246866056336d7f01c21dab07"},
+    {file = "fastapi-0.95.1.tar.gz", hash = "sha256:9569f0a381f8a457ec479d90fa01005cfddaae07546eb1f3fa035bc4797ae7d5"},
 ]
 
 [package.dependencies]
 pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0"
-starlette = ">=0.25.0,<0.26.0"
+starlette = ">=0.26.1,<0.27.0"
 
 [package.extras]
 all = ["email-validator (>=1.1.1)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
 dev = ["pre-commit (>=2.17.0,<3.0.0)", "ruff (==0.0.138)", "uvicorn[standard] (>=0.12.0,<0.21.0)"]
-doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer[all] (>=0.6.1,<0.8.0)"]
-test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==22.10.0)", "coverage[toml] (>=6.5.0,<8.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.982)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "pyyaml (>=5.3.1,<7.0.0)", "ruff (==0.0.138)", "sqlalchemy (>=1.3.18,<1.4.43)", "types-orjson (==3.6.2)", "types-ujson (==5.6.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"]
+doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer-cli (>=0.0.13,<0.0.14)", "typer[all] (>=0.6.1,<0.8.0)"]
+test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==23.1.0)", "coverage[toml] (>=6.5.0,<8.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.982)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.7)", "pyyaml (>=5.3.1,<7.0.0)", "ruff (==0.0.138)", "sqlalchemy (>=1.3.18,<1.4.43)", "types-orjson (==3.6.2)", "types-ujson (==5.7.0.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"]
 
 [[package]]
 name = "fastapi-socketio"
@@ -680,14 +683,14 @@ files = [
 
 [[package]]
 name = "importlib-metadata"
-version = "6.3.0"
+version = "6.5.0"
 description = "Read metadata from Python packages"
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "importlib_metadata-6.3.0-py3-none-any.whl", hash = "sha256:8f8bd2af397cf33bd344d35cfe7f489219b7d14fc79a3f854b75b8417e9226b0"},
-    {file = "importlib_metadata-6.3.0.tar.gz", hash = "sha256:23c2bcae4762dfb0bbe072d358faec24957901d75b6c4ab11172c0c982532402"},
+    {file = "importlib_metadata-6.5.0-py3-none-any.whl", hash = "sha256:03ba783c3a2c69d751b109fc0c94a62c51f581b3d6acf8ed1331b6d5729321ff"},
+    {file = "importlib_metadata-6.5.0.tar.gz", hash = "sha256:7a8bdf1bc3a726297f5cfbc999e6e7ff6b4fa41b26bba4afc580448624460045"},
 ]
 
 [package.dependencies]
@@ -1542,14 +1545,14 @@ email = ["email-validator (>=1.0.3)"]
 
 [[package]]
 name = "pygments"
-version = "2.15.0"
+version = "2.15.1"
 description = "Pygments is a syntax highlighting package written in Python."
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "Pygments-2.15.0-py3-none-any.whl", hash = "sha256:77a3299119af881904cd5ecd1ac6a66214b6e9bed1f2db16993b54adede64094"},
-    {file = "Pygments-2.15.0.tar.gz", hash = "sha256:f7e36cffc4c517fbc252861b9a6e4644ca0e5abadf9a113c72d1358ad09b9500"},
+    {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"},
+    {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"},
 ]
 
 [package.extras]
@@ -1557,58 +1560,58 @@ plugins = ["importlib-metadata"]
 
 [[package]]
 name = "pyobjc-core"
-version = "9.0.1"
+version = "9.1.1"
 description = "Python<->ObjC Interoperability Module"
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "pyobjc-core-9.0.1.tar.gz", hash = "sha256:5ce1510bb0bdff527c597079a42b2e13a19b7592e76850be7960a2775b59c929"},
-    {file = "pyobjc_core-9.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b614406d46175b1438a9596b664bf61952323116704d19bc1dea68052a0aad98"},
-    {file = "pyobjc_core-9.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bd397e729f6271c694fb70df8f5d3d3c9b2f2b8ac02fbbdd1757ca96027b94bb"},
-    {file = "pyobjc_core-9.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d919934eaa6d1cf1505ff447a5c2312be4c5651efcb694eb9f59e86f5bd25e6b"},
-    {file = "pyobjc_core-9.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:67d67ca8b164f38ceacce28a18025845c3ec69613f3301935d4d2c4ceb22e3fd"},
-    {file = "pyobjc_core-9.0.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:39d11d71f6161ac0bd93cffc8ea210bb0178b56d16a7408bf74283d6ecfa7430"},
-    {file = "pyobjc_core-9.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:25be1c4d530e473ed98b15063b8d6844f0733c98914de6f09fe1f7652b772bbc"},
+    {file = "pyobjc-core-9.1.1.tar.gz", hash = "sha256:4b6cb9053b5fcd3c0e76b8c8105a8110786b20f3403c5643a688c5ec51c55c6b"},
+    {file = "pyobjc_core-9.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4bd07049fd9fe5b40e4b7c468af9cf942508387faf383a5acb043d20627bad2c"},
+    {file = "pyobjc_core-9.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1a8307527621729ff2ab67860e7ed84f76ad0da881b248c2ef31e0da0088e4ba"},
+    {file = "pyobjc_core-9.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:083004d28b92ccb483a41195c600728854843b0486566aba2d6e63eef51f80e6"},
+    {file = "pyobjc_core-9.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d61e9517d451bc062a7fae8b3648f4deba4fa54a24926fa1cf581b90ef4ced5a"},
+    {file = "pyobjc_core-9.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1626909916603a3b04c07c721cf1af0e0b892cec85bb3db98d05ba024f1786fc"},
+    {file = "pyobjc_core-9.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2dde96462b52e952515d142e2afbb6913624a02c13582047e06211e6c3993728"},
 ]
 
 [[package]]
 name = "pyobjc-framework-cocoa"
-version = "9.0.1"
+version = "9.1.1"
 description = "Wrappers for the Cocoa frameworks on macOS"
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "pyobjc-framework-Cocoa-9.0.1.tar.gz", hash = "sha256:a8b53b3426f94307a58e2f8214dc1094c19afa9dcb96f21be12f937d968b2df3"},
-    {file = "pyobjc_framework_Cocoa-9.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5f94b0f92a62b781e633e58f09bcaded63d612f9b1e15202f5f372ea59e4aebd"},
-    {file = "pyobjc_framework_Cocoa-9.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f062c3bb5cc89902e6d164aa9a66ffc03638645dd5f0468b6f525ac997c86e51"},
-    {file = "pyobjc_framework_Cocoa-9.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0b374c0a9d32ba4fc5610ab2741cb05a005f1dfb82a47dbf2dbb2b3a34b73ce5"},
-    {file = "pyobjc_framework_Cocoa-9.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8928080cebbce91ac139e460d3dfc94c7cb6935be032dcae9c0a51b247f9c2d9"},
-    {file = "pyobjc_framework_Cocoa-9.0.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:9d2bd86a0a98d906f762f5dc59f2fc67cce32ae9633b02ff59ac8c8a33dd862d"},
-    {file = "pyobjc_framework_Cocoa-9.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2a41053cbcee30e1e8914efa749c50b70bf782527d5938f2bc2a6393740969ce"},
+    {file = "pyobjc-framework-Cocoa-9.1.1.tar.gz", hash = "sha256:345c32b6d1f3db45f635e400f2d0d6c0f0f7349d45ec823f76fc1df43d13caeb"},
+    {file = "pyobjc_framework_Cocoa-9.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9176a4276f3b4b4758e9b9ca10698be5341ceffaeaa4fa055133417179e6bc37"},
+    {file = "pyobjc_framework_Cocoa-9.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5e1e96fb3461f46ff951413515f2029e21be268b0e033db6abee7b64ec8e93d3"},
+    {file = "pyobjc_framework_Cocoa-9.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:083b195c496d30c6b9dd86126a6093c4b95e0138e9b052b13e54103fcc0b4872"},
+    {file = "pyobjc_framework_Cocoa-9.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a1b3333b1aa045608848bd68bbab4c31171f36aeeaa2fabeb4527c6f6f1e33cd"},
+    {file = "pyobjc_framework_Cocoa-9.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:54c017354671f0d955432986c42218e452ca69906a101c8e7acde8510432303a"},
+    {file = "pyobjc_framework_Cocoa-9.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:10c0075688ce95b92caf59e368585fffdcc98c919bc345067af070222f5d01d2"},
 ]
 
 [package.dependencies]
-pyobjc-core = ">=9.0.1"
+pyobjc-core = ">=9.1.1"
 
 [[package]]
 name = "pyobjc-framework-webkit"
-version = "9.0.1"
+version = "9.1.1"
 description = "Wrappers for the framework WebKit on macOS"
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "pyobjc-framework-WebKit-9.0.1.tar.gz", hash = "sha256:82ed0cb273012b48f7489072d6e00579f42d54bc4543471c262db3e5c4bb9e87"},
-    {file = "pyobjc_framework_WebKit-9.0.1-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:037082f72fa1f1d87889fdc172726c3381769de24ca5207d596f3925df9b25f0"},
-    {file = "pyobjc_framework_WebKit-9.0.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:952685b820545036833ed737600d32c344916a83b2af4e04acb4b618aaac9431"},
-    {file = "pyobjc_framework_WebKit-9.0.1-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:28a7859401b5af7c47e17612b4b3baca6669e76f974f6f6bfe5e93921a00adec"},
+    {file = "pyobjc-framework-WebKit-9.1.1.tar.gz", hash = "sha256:bc6ba0ca6ed9ebcb4c5fc338410a81f50b4da08e4bab4bfe5853612e8e5e79aa"},
+    {file = "pyobjc_framework_WebKit-9.1.1-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:49ffae7c9dcab8048601166c97e6352999b5cd837a73ae6c1d5a8b90aa892b6d"},
+    {file = "pyobjc_framework_WebKit-9.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:1d371fb72aa06a3c3f3cbb1360e8762b225c2f62c28562218535ce17206163d4"},
+    {file = "pyobjc_framework_WebKit-9.1.1-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:be51de0d13a1308cc67d3ac49eb30dffdf4c3a0b386e5515dfdab8c6b5fec4d4"},
 ]
 
 [package.dependencies]
-pyobjc-core = ">=9.0.1"
-pyobjc-framework-Cocoa = ">=9.0.1"
+pyobjc-core = ">=9.1.1"
+pyobjc-framework-Cocoa = ">=9.1.1"
 
 [[package]]
 name = "pyparsing"
@@ -1807,14 +1810,14 @@ cli = ["click (>=5.0)"]
 
 [[package]]
 name = "python-engineio"
-version = "4.4.0"
+version = "4.4.1"
 description = "Engine.IO server and client for Python"
 category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "python-engineio-4.4.0.tar.gz", hash = "sha256:bcc035c70ecc30acc3cfd49ef19aca6c51fa6caaadd0fa58c2d7480f50d04cf2"},
-    {file = "python_engineio-4.4.0-py3-none-any.whl", hash = "sha256:11f9c35b775fe70e0a25f67b16d5b69fbfafc368cdd87eeb6f4135a475c88e50"},
+    {file = "python-engineio-4.4.1.tar.gz", hash = "sha256:eb3663ecb300195926b526386f712dff84cd092c818fb7b62eeeda9160120c29"},
+    {file = "python_engineio-4.4.1-py3-none-any.whl", hash = "sha256:28ab67f94cba2e5f598cbb04428138fd6bb8b06d3478c939412da445f24f0773"},
 ]
 
 [package.extras]
@@ -2012,14 +2015,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 
 [[package]]
 name = "selenium"
-version = "4.8.3"
+version = "4.9.0"
 description = ""
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "selenium-4.8.3-py3-none-any.whl", hash = "sha256:28430ac54a54fa59ad1f5392a1b89b169fe3ab2c2ccd1a9a10b6fe74f36cd6da"},
-    {file = "selenium-4.8.3.tar.gz", hash = "sha256:61cda3a304f82637162bc155cae7bf88fdb04c115fa2cb1c1c2e1358fcd19a9f"},
+    {file = "selenium-4.9.0-py3-none-any.whl", hash = "sha256:4c19e6aac202719373108d53a5a8e9336ba8d2b25822ca32ae6ff37acbabbdbe"},
+    {file = "selenium-4.9.0.tar.gz", hash = "sha256:478fae77cdfaec32adb1e68d59632c8c191f920535282abcaa2d1a3d98655624"},
 ]
 
 [package.dependencies]
@@ -2066,14 +2069,14 @@ files = [
 
 [[package]]
 name = "starlette"
-version = "0.25.0"
+version = "0.26.1"
 description = "The little ASGI library that shines."
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "starlette-0.25.0-py3-none-any.whl", hash = "sha256:774f1df1983fd594b9b6fb3ded39c2aa1979d10ac45caac0f4255cbe2acb8628"},
-    {file = "starlette-0.25.0.tar.gz", hash = "sha256:854c71e73736c429c2bdb07801f2c76c9cba497e7c3cf4988fde5e95fe4cdb3c"},
+    {file = "starlette-0.26.1-py3-none-any.whl", hash = "sha256:e87fce5d7cbdde34b76f0ac69013fd9d190d581d80681493016666e6f96c6d5e"},
+    {file = "starlette-0.26.1.tar.gz", hash = "sha256:41da799057ea8620e4667a3e69a5b1923ebd32b1819c8fa75634bbe8d8bea9bd"},
 ]
 
 [package.dependencies]
@@ -2316,82 +2319,82 @@ anyio = ">=3.0.0"
 
 [[package]]
 name = "websockets"
-version = "11.0.1"
+version = "11.0.2"
 description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "websockets-11.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d30cc1a90bcbf9e22e1f667c1c5a7428e2d37362288b4ebfd5118eb0b11afa9"},
-    {file = "websockets-11.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dc77283a7c7b2b24e00fe8c3c4f7cf36bba4f65125777e906aae4d58d06d0460"},
-    {file = "websockets-11.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0929c2ebdf00cedda77bf77685693e38c269011236e7c62182fee5848c29a4fa"},
-    {file = "websockets-11.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db234da3aff01e8483cf0015b75486c04d50dbf90004bd3e5b46d384e1bd6c9e"},
-    {file = "websockets-11.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7fdfbed727ce6b4b5e6622d15a6efb2098b2d9e22ba4dc54b2e3ce80f982045"},
-    {file = "websockets-11.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5f3d0d177b3db3d1d02cce7ba6c0063586499ac28afe0c992be74ffc40d9257"},
-    {file = "websockets-11.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:25ea5dbd3b00c56b034639dc6fe4d1dd095b8205bab1782d9a47cb020695fdf4"},
-    {file = "websockets-11.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:dbeada3b8f1f6d9497840f761906c4236f912a42da4515520168bc7c525b52b0"},
-    {file = "websockets-11.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:892959b627eedcdf98ac7022f9f71f050a59624b380b67862da10c32ea3c221a"},
-    {file = "websockets-11.0.1-cp310-cp310-win32.whl", hash = "sha256:fc0a96a6828bfa6f1ccec62b54630bcdcc205d483f5a8806c0a8abb26101c54b"},
-    {file = "websockets-11.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:3a88375b648a2c479532943cc19a018df1e5fcea85d5f31963c0b22794d1bdc1"},
-    {file = "websockets-11.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3cf18bbd44b36749b7b66f047a30a40b799b8c0bd9a1b9173cba86a234b4306b"},
-    {file = "websockets-11.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:deb0dd98ea4e76b833f0bfd7a6042b51115360d5dfcc7c1daa72dfc417b3327a"},
-    {file = "websockets-11.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:45a85dc6b3ff76239379feb4355aadebc18d6e587c8deb866d11060755f4d3ea"},
-    {file = "websockets-11.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d68bd2a3e9fff6f7043c0a711cb1ebba9f202c196a3943d0c885650cd0b6464"},
-    {file = "websockets-11.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfd0b9b18d64c51e5cd322e16b5bf4fe490db65c9f7b18fd5382c824062ead7e"},
-    {file = "websockets-11.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0e6253c36e42f2637cfa3ff9b3903df60d05ec040c718999f6a0644ce1c497"},
-    {file = "websockets-11.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:12180bc1d72c6a9247472c1dee9dfd7fc2e23786f25feee7204406972d8dab39"},
-    {file = "websockets-11.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:a797da96d4127e517a5cb0965cd03fd6ec21e02667c1258fa0579501537fbe5c"},
-    {file = "websockets-11.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:07cc20655fb16aeef1a8f03236ba8671c61d332580b996b6396a5b7967ba4b3d"},
-    {file = "websockets-11.0.1-cp311-cp311-win32.whl", hash = "sha256:a01c674e0efe0f14aec7e722ed0e0e272fa2f10e8ea8260837e1f4f5dc4b3e53"},
-    {file = "websockets-11.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:2796f097841619acf053245f266a4f66cb27c040f0d9097e5f21301aab95ff43"},
-    {file = "websockets-11.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:54d084756c50dfc8086dce97b945f210ca43950154e1e04a44a30c6e6a2bcbb1"},
-    {file = "websockets-11.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe2aed5963ca267c40a2d29b1ee4e8ab008ac8d5daa284fdda9275201b8a334"},
-    {file = "websockets-11.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e92dbac318a84fef722f38ca57acef19cbb89527aba5d420b96aa2656970ee"},
-    {file = "websockets-11.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec4e87eb9916b481216b1fede7d8913be799915f5216a0c801867cbed8eeb903"},
-    {file = "websockets-11.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d4e0990b6a04b07095c969969da659eecf9069cf8e7b8f49c8f5ee1bb50e3352"},
-    {file = "websockets-11.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c90343fd0774749d23c1891dd8b3e9210f9afd30986673ce0f9d5857f5cb1562"},
-    {file = "websockets-11.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ac042e8ba9d7f2618e84af27927fdce0f3e03528eb74f343977486c093868389"},
-    {file = "websockets-11.0.1-cp37-cp37m-win32.whl", hash = "sha256:385c5391becb9b58e0a4f33345e12762fd857ccf9fbf6fee428669929ba45e4c"},
-    {file = "websockets-11.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:aef1602db81096ce3d3847865128c8879635bdad7963fb2b7df290edb9e9150a"},
-    {file = "websockets-11.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:52ba83ea132390e426f9a7b48848248a2dc0e7120ca8c65d5a8fc1efaa4eb51b"},
-    {file = "websockets-11.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:007ed0d62f7e06eeb6e3a848b0d83b9fbd9e14674a59a61326845f27d20d7452"},
-    {file = "websockets-11.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f888b9565ca1d1c25ab827d184f57f4772ffbfa6baf5710b873b01936cc335ee"},
-    {file = "websockets-11.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db78535b791840a584c48cf3f4215eae38a7e2f43271ecd27ce4ba8a798beaaa"},
-    {file = "websockets-11.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fa1c23ed3a02732fba906ec337df65d4cc23f9f453635e1a803c285b59c7d987"},
-    {file = "websockets-11.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e039f106d48d3c241f1943bccfb383bd38ec39900d6dcaad0c73cc5fe129f346"},
-    {file = "websockets-11.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b0ed24a3aa4213029e100257e5e73c5f912e70ca35630081de94b7f9e2cf4a9b"},
-    {file = "websockets-11.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5a3022f9291bf2d35ebf65929297d625e68effd3a5647b8eb8b89d51b09394c"},
-    {file = "websockets-11.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:1cb23597819f68ac6a6d133a002a1b3ef12a22850236b083242c93f81f206d5a"},
-    {file = "websockets-11.0.1-cp38-cp38-win32.whl", hash = "sha256:349dd1fa56a30d530555988be98013688de67809f384671883f8bf8b8c9de984"},
-    {file = "websockets-11.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:87ae582cf2319e45bc457a57232daded27a3c771263cab42fb8864214bbd74ea"},
-    {file = "websockets-11.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a88815a0c6253ad1312ef186620832fb347706c177730efec34e3efe75e0e248"},
-    {file = "websockets-11.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d5a6fa353b5ef36970c3bd1cd7cecbc08bb8f2f1a3d008b0691208cf34ebf5b0"},
-    {file = "websockets-11.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5ffe6fc5e5fe9f2634cdc59b805e4ba1fcccf3a5622f5f36c3c7c287f606e283"},
-    {file = "websockets-11.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b138f4bf8a64c344e12c76283dac279d11adab89ac62ae4a32ac8490d3c94832"},
-    {file = "websockets-11.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aedd94422745da60672a901f53de1f50b16e85408b18672b9b210db4a776b5a6"},
-    {file = "websockets-11.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4667d4e41fa37fa3d836b2603b8b40d6887fa4838496d48791036394f7ace39"},
-    {file = "websockets-11.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:43e0de552be624e5c0323ff4fcc9f0b4a9a6dc6e0116b8aa2cbb6e0d3d2baf09"},
-    {file = "websockets-11.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ceeef57b9aec8f27e523de4da73c518ece7721aefe7064f18aa28baabfe61b94"},
-    {file = "websockets-11.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9d91279d57f6546eaf43671d1de50621e0578f13c2f17c96c458a72d170698d7"},
-    {file = "websockets-11.0.1-cp39-cp39-win32.whl", hash = "sha256:29282631da3bfeb5db497e4d3d94d56ee36222fbebd0b51014e68a2e70736fb1"},
-    {file = "websockets-11.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:e2654e94c705ce9b768441d8e3a387a84951ca1056efdc4a26a4a6ee723c01b6"},
-    {file = "websockets-11.0.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:60a19d4ff5f451254f8623f6aa4169065f73a50ec7b59ab6b9dcddff4aa00267"},
-    {file = "websockets-11.0.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a58e83f82098d062ae5d4cbe7073b8783999c284d6f079f2fefe87cd8957ac8"},
-    {file = "websockets-11.0.1-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b91657b65355954e47f0df874917fa200426b3a7f4e68073326a8cfc2f6deef8"},
-    {file = "websockets-11.0.1-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53b8e1ee01eb5b8be5c8a69ae26b0820dbc198d092ad50b3451adc3cdd55d455"},
-    {file = "websockets-11.0.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:5d8d5d17371ed9eb9f0e3a8d326bdf8172700164c2e705bc7f1905a719a189be"},
-    {file = "websockets-11.0.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e53419201c6c1439148feb99de6b307651a88b8defd41348cc23bbe2a290de1d"},
-    {file = "websockets-11.0.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:718d19c494637f28e651031b3df6a791b9e86e0097c65ed5e8ec49b400b1210e"},
-    {file = "websockets-11.0.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7b2544eb3e7bc39ce59812371214cd97762080dab90c3afc857890039384753"},
-    {file = "websockets-11.0.1-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec4a887d2236e3878c07033ad5566f6b4d5d954b85f92a219519a1745d0c93e9"},
-    {file = "websockets-11.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:ae59a9f0a77ecb0cbdedea7d206a547ff136e8bfbc7d2d98772fb02d398797bb"},
-    {file = "websockets-11.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ef35cef161f76031f833146f895e7e302196e01c704c00d269c04d8e18f3ac37"},
-    {file = "websockets-11.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79b6548e57ab18f071b9bfe3ffe02af7184dd899bc674e2817d8fe7e9e7489ec"},
-    {file = "websockets-11.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8d9793f3fb0da16232503df14411dabafed5a81fc9077dc430cfc6f60e71179"},
-    {file = "websockets-11.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42aa05e890fcf1faed8e535c088a1f0f27675827cbacf62d3024eb1e6d4c9e0c"},
-    {file = "websockets-11.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:5d4f4b341100d313b08149d7031eb6d12738ac758b0c90d2f9be8675f401b019"},
-    {file = "websockets-11.0.1-py3-none-any.whl", hash = "sha256:85b4127f7da332feb932eee833c70e5e1670469e8c9de7ef3874aa2a91a6fbb2"},
-    {file = "websockets-11.0.1.tar.gz", hash = "sha256:369410925b240b30ef1c1deadbd6331e9cd865ad0b8966bf31e276cc8e0da159"},
+    {file = "websockets-11.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:580cc95c58118f8c39106be71e24d0b7e1ad11a155f40a2ee687f99b3e5e432e"},
+    {file = "websockets-11.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:143782041e95b63083b02107f31cda999f392903ae331de1307441f3a4557d51"},
+    {file = "websockets-11.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8df63dcd955eb6b2e371d95aacf8b7c535e482192cff1b6ce927d8f43fb4f552"},
+    {file = "websockets-11.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9b2dced5cbbc5094678cc1ec62160f7b0fe4defd601cd28a36fde7ee71bbb5"},
+    {file = "websockets-11.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0eeeea3b01c97fd3b5049a46c908823f68b59bf0e18d79b231d8d6764bc81ee"},
+    {file = "websockets-11.0.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:502683c5dedfc94b9f0f6790efb26aa0591526e8403ad443dce922cd6c0ec83b"},
+    {file = "websockets-11.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d3cc3e48b6c9f7df8c3798004b9c4b92abca09eeea5e1b0a39698f05b7a33b9d"},
+    {file = "websockets-11.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:808b8a33c961bbd6d33c55908f7c137569b09ea7dd024bce969969aa04ecf07c"},
+    {file = "websockets-11.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:34a6f8996964ccaa40da42ee36aa1572adcb1e213665e24aa2f1037da6080909"},
+    {file = "websockets-11.0.2-cp310-cp310-win32.whl", hash = "sha256:8f24cd758cbe1607a91b720537685b64e4d39415649cac9177cd1257317cf30c"},
+    {file = "websockets-11.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:3b87cd302f08ea9e74fdc080470eddbed1e165113c1823fb3ee6328bc40ca1d3"},
+    {file = "websockets-11.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3565a8f8c7bdde7c29ebe46146bd191290413ee6f8e94cf350609720c075b0a1"},
+    {file = "websockets-11.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f97e03d4d5a4f0dca739ea274be9092822f7430b77d25aa02da6775e490f6846"},
+    {file = "websockets-11.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f392587eb2767afa8a34e909f2fec779f90b630622adc95d8b5e26ea8823cb8"},
+    {file = "websockets-11.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7742cd4524622cc7aa71734b51294644492a961243c4fe67874971c4d3045982"},
+    {file = "websockets-11.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46dda4bc2030c335abe192b94e98686615f9274f6b56f32f2dd661fb303d9d12"},
+    {file = "websockets-11.0.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6b2bfa1d884c254b841b0ff79373b6b80779088df6704f034858e4d705a4802"},
+    {file = "websockets-11.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1df2413266bf48430ef2a752c49b93086c6bf192d708e4a9920544c74cd2baa6"},
+    {file = "websockets-11.0.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf45d273202b0c1cec0f03a7972c655b93611f2e996669667414557230a87b88"},
+    {file = "websockets-11.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a09cce3dacb6ad638fdfa3154d9e54a98efe7c8f68f000e55ca9c716496ca67"},
+    {file = "websockets-11.0.2-cp311-cp311-win32.whl", hash = "sha256:2174a75d579d811279855df5824676d851a69f52852edb0e7551e0eeac6f59a4"},
+    {file = "websockets-11.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:c78ca3037a954a4209b9f900e0eabbc471fb4ebe96914016281df2c974a93e3e"},
+    {file = "websockets-11.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3a2100b02d1aaf66dc48ff1b2a72f34f6ebc575a02bc0350cc8e9fbb35940166"},
+    {file = "websockets-11.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dca9708eea9f9ed300394d4775beb2667288e998eb6f542cdb6c02027430c599"},
+    {file = "websockets-11.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:320ddceefd2364d4afe6576195201a3632a6f2e6d207b0c01333e965b22dbc84"},
+    {file = "websockets-11.0.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2a573c8d71b7af937852b61e7ccb37151d719974146b5dc734aad350ef55a02"},
+    {file = "websockets-11.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:13bd5bebcd16a4b5e403061b8b9dcc5c77e7a71e3c57e072d8dff23e33f70fba"},
+    {file = "websockets-11.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:95c09427c1c57206fe04277bf871b396476d5a8857fa1b99703283ee497c7a5d"},
+    {file = "websockets-11.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2eb042734e710d39e9bc58deab23a65bd2750e161436101488f8af92f183c239"},
+    {file = "websockets-11.0.2-cp37-cp37m-win32.whl", hash = "sha256:5875f623a10b9ba154cb61967f940ab469039f0b5e61c80dd153a65f024d9fb7"},
+    {file = "websockets-11.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:634239bc844131863762865b75211a913c536817c0da27f691400d49d256df1d"},
+    {file = "websockets-11.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3178d965ec204773ab67985a09f5696ca6c3869afeed0bb51703ea404a24e975"},
+    {file = "websockets-11.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:955fcdb304833df2e172ce2492b7b47b4aab5dcc035a10e093d911a1916f2c87"},
+    {file = "websockets-11.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb46d2c7631b2e6f10f7c8bac7854f7c5e5288f024f1c137d4633c79ead1e3c0"},
+    {file = "websockets-11.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25aae96c1060e85836552a113495db6d857400288161299d77b7b20f2ac569f2"},
+    {file = "websockets-11.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2abeeae63154b7f63d9f764685b2d299e9141171b8b896688bd8baec6b3e2303"},
+    {file = "websockets-11.0.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:daa1e8ea47507555ed7a34f8b49398d33dff5b8548eae3de1dc0ef0607273a33"},
+    {file = "websockets-11.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:954eb789c960fa5daaed3cfe336abc066941a5d456ff6be8f0e03dd89886bb4c"},
+    {file = "websockets-11.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3ffe251a31f37e65b9b9aca5d2d67fd091c234e530f13d9dce4a67959d5a3fba"},
+    {file = "websockets-11.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:adf6385f677ed2e0b021845b36f55c43f171dab3a9ee0ace94da67302f1bc364"},
+    {file = "websockets-11.0.2-cp38-cp38-win32.whl", hash = "sha256:aa7b33c1fb2f7b7b9820f93a5d61ffd47f5a91711bc5fa4583bbe0c0601ec0b2"},
+    {file = "websockets-11.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:220d5b93764dd70d7617f1663da64256df7e7ea31fc66bc52c0e3750ee134ae3"},
+    {file = "websockets-11.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0fb4480556825e4e6bf2eebdbeb130d9474c62705100c90e59f2f56459ddab42"},
+    {file = "websockets-11.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ec00401846569aaf018700249996143f567d50050c5b7b650148989f956547af"},
+    {file = "websockets-11.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87c69f50281126dcdaccd64d951fb57fbce272578d24efc59bce72cf264725d0"},
+    {file = "websockets-11.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:232b6ba974f5d09b1b747ac232f3a3d8f86de401d7b565e837cc86988edf37ac"},
+    {file = "websockets-11.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:392d409178db1e46d1055e51cc850136d302434e12d412a555e5291ab810f622"},
+    {file = "websockets-11.0.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4fe2442091ff71dee0769a10449420fd5d3b606c590f78dd2b97d94b7455640"},
+    {file = "websockets-11.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ede13a6998ba2568b21825809d96e69a38dc43184bdeebbde3699c8baa21d015"},
+    {file = "websockets-11.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4c54086b2d2aec3c3cb887ad97e9c02c6be9f1d48381c7419a4aa932d31661e4"},
+    {file = "websockets-11.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e37a76ccd483a6457580077d43bc3dfe1fd784ecb2151fcb9d1c73f424deaeba"},
+    {file = "websockets-11.0.2-cp39-cp39-win32.whl", hash = "sha256:d1881518b488a920434a271a6e8a5c9481a67c4f6352ebbdd249b789c0467ddc"},
+    {file = "websockets-11.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:25e265686ea385f22a00cc2b719b880797cd1bb53b46dbde969e554fb458bfde"},
+    {file = "websockets-11.0.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ce69f5c742eefd039dce8622e99d811ef2135b69d10f9aa79fbf2fdcc1e56cd7"},
+    {file = "websockets-11.0.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b985ba2b9e972cf99ddffc07df1a314b893095f62c75bc7c5354a9c4647c6503"},
+    {file = "websockets-11.0.2-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b52def56d2a26e0e9c464f90cadb7e628e04f67b0ff3a76a4d9a18dfc35e3dd"},
+    {file = "websockets-11.0.2-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d70a438ef2a22a581d65ad7648e949d4ccd20e3c8ed7a90bbc46df4e60320891"},
+    {file = "websockets-11.0.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:752fbf420c71416fb1472fec1b4cb8631c1aa2be7149e0a5ba7e5771d75d2bb9"},
+    {file = "websockets-11.0.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:dd906b0cdc417ea7a5f13bb3c6ca3b5fd563338dc596996cb0fdd7872d691c0a"},
+    {file = "websockets-11.0.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e79065ff6549dd3c765e7916067e12a9c91df2affea0ac51bcd302aaf7ad207"},
+    {file = "websockets-11.0.2-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46388a050d9e40316e58a3f0838c63caacb72f94129eb621a659a6e49bad27ce"},
+    {file = "websockets-11.0.2-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c7de298371d913824f71b30f7685bb07ad13969c79679cca5b1f7f94fec012f"},
+    {file = "websockets-11.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6d872c972c87c393e6a49c1afbdc596432df8c06d0ff7cd05aa18e885e7cfb7c"},
+    {file = "websockets-11.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b444366b605d2885f0034dd889faf91b4b47668dd125591e2c64bfde611ac7e1"},
+    {file = "websockets-11.0.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b967a4849db6b567dec3f7dd5d97b15ce653e3497b8ce0814e470d5e074750"},
+    {file = "websockets-11.0.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2acdc82099999e44fa7bd8c886f03c70a22b1d53ae74252f389be30d64fd6004"},
+    {file = "websockets-11.0.2-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:518ed6782d9916c5721ebd61bb7651d244178b74399028302c8617d0620af291"},
+    {file = "websockets-11.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:58477b041099bb504e1a5ddd8aa86302ed1d5c6995bdd3db2b3084ef0135d277"},
+    {file = "websockets-11.0.2-py3-none-any.whl", hash = "sha256:5004c087d17251938a52cce21b3dbdabeecbbe432ce3f5bbbf15d8692c36eac9"},
+    {file = "websockets-11.0.2.tar.gz", hash = "sha256:b1a69701eb98ed83dd099de4a686dc892c413d974fa31602bc00aca7cb988ac9"},
 ]
 
 [[package]]
@@ -2428,4 +2431,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.7"
-content-hash = "5bf762587752502c0e0a137e7356b7a8a6eac24384f9e2fa0a8799328583e573"
+content-hash = "d77f95c9aec12c19ab35ade33d4e9f9acad27e48175f40a7f9197c6f771d3a91"

+ 1 - 1
pyproject.toml

@@ -18,7 +18,7 @@ matplotlib = [
     { version = "^3.5.0", markers = "python_version ~= '3.7'"},
     { version = "^3.6.0", markers = "python_version ~= '3.11.0'"},
 ]
-fastapi = "^0.92"
+fastapi = ">=0.92,<1.0.0"
 fastapi-socketio = "^0.0.10"
 vbuild = "^0.8.1"
 watchfiles = "^0.18.1"

+ 0 - 14
tests/input.py

@@ -1,14 +0,0 @@
-from nicegui import ui
-
-from .screen import Screen
-
-
-def test_input_with_multi_word_error_message(screen: Screen):
-    input = ui.input(label='some input')
-    ui.button('set error', on_click=lambda: input.props('error error-message="Some multi word error message"'))
-
-    screen.open('/')
-    screen.should_not_contain('Some multi word error message')
-
-    screen.click('set error')
-    screen.should_contain('Some multi word error message')

+ 17 - 0
tests/test_button.py

@@ -16,3 +16,20 @@ def test_quasar_colors(screen: Screen):
     assert screen.find_by_id(b3.id).value_of_css_property('background-color') == 'rgba(239, 83, 80, 1)'
     assert screen.find_by_id(b4.id).value_of_css_property('background-color') == 'rgba(239, 68, 68, 1)'
     assert screen.find_by_id(b5.id).value_of_css_property('background-color') == 'rgba(255, 0, 0, 1)'
+
+
+def test_enable_disable(screen: Screen):
+    events = []
+    b = ui.button('Button', on_click=lambda: events.append(1))
+    ui.button('Enable', on_click=b.enable)
+    ui.button('Disable', on_click=b.disable)
+
+    screen.open('/')
+    screen.click('Button')
+    assert events == [1]
+    screen.click('Disable')
+    screen.click('Button')
+    assert events == [1]
+    screen.click('Enable')
+    screen.click('Button')
+    assert events == [1, 1]

+ 36 - 0
tests/test_dark_mode.py

@@ -0,0 +1,36 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_dark_mode(screen: Screen):
+    ui.label('Hello')
+    dark = ui.dark_mode()
+    ui.button('Dark', on_click=dark.enable)
+    ui.button('Light', on_click=dark.disable)
+    ui.button('Auto', on_click=dark.auto)
+    ui.button('Toggle', on_click=dark.toggle)
+
+    screen.open('/')
+    screen.should_contain('Hello')
+    assert screen.find_by_tag('body').get_attribute('class') == 'desktop no-touch body--light'
+
+    screen.click('Dark')
+    screen.wait(0.5)
+    assert screen.find_by_tag('body').get_attribute('class') == 'desktop no-touch body--dark dark'
+
+    screen.click('Auto')
+    screen.wait(0.5)
+    assert screen.find_by_tag('body').get_attribute('class') == 'desktop no-touch body--light'
+
+    screen.click('Toggle')
+    screen.wait(0.5)
+    screen.assert_py_logger('ERROR', 'Cannot toggle dark mode when it is set to auto.')
+
+    screen.click('Light')
+    screen.wait(0.5)
+    assert screen.find_by_tag('body').get_attribute('class') == 'desktop no-touch body--light'
+
+    screen.click('Toggle')
+    screen.wait(0.5)
+    assert screen.find_by_tag('body').get_attribute('class') == 'desktop no-touch body--dark dark'

+ 38 - 0
tests/test_input.py

@@ -1,4 +1,5 @@
 from selenium.webdriver.common.by import By
+from selenium.webdriver.common.keys import Keys
 
 from nicegui import ui
 
@@ -66,3 +67,40 @@ def test_input_validation(screen: Screen):
     element.send_keys(' Doe')
     screen.wait(0.5)
     screen.should_not_contain('Too short')
+
+
+def test_input_with_multi_word_error_message(screen: Screen):
+    input = ui.input(label='some input')
+    ui.button('set error', on_click=lambda: input.props('error error-message="Some multi word error message"'))
+
+    screen.open('/')
+    screen.should_not_contain('Some multi word error message')
+
+    screen.click('set error')
+    screen.should_contain('Some multi word error message')
+
+
+def test_autocompletion(screen: Screen):
+    ui.input('Input', autocomplete=['foo', 'bar', 'baz'])
+
+    screen.open('/')
+    element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Input"]')
+    element.send_keys('f')
+    screen.should_contain('oo')
+
+    element.send_keys('l')
+    screen.wait(0.5)
+    screen.should_not_contain('oo')
+
+    element.send_keys(Keys.BACKSPACE)
+    screen.should_contain('oo')
+
+    element.send_keys(Keys.TAB)
+    assert element.get_attribute('value') == 'foo'
+
+    element.send_keys(Keys.BACKSPACE)
+    element.send_keys(Keys.BACKSPACE)
+    element.send_keys('x')
+    element.send_keys(Keys.TAB)
+    screen.wait(0.5)
+    assert element.get_attribute('value') == 'fx'

+ 37 - 0
tests/test_upload.py

@@ -56,3 +56,40 @@ def test_uploading_from_two_tabs(screen: Screen):
     screen.should_contain(f'uploaded {test_path1.name}')
     screen.switch_to(0)
     screen.should_not_contain(f'uploaded {test_path1.name}')
+
+
+def test_upload_with_header_slot(screen: Screen):
+    with ui.upload().add_slot('header'):
+        ui.label('Header')
+
+    screen.open('/')
+    screen.should_contain('Header')
+
+
+def test_replace_upload(screen: Screen):
+    with ui.row() as container:
+        ui.upload(label='A')
+
+    def replace():
+        container.clear()
+        with container:
+            ui.upload(label='B')
+    ui.button('Replace', on_click=replace)
+
+    screen.open('/')
+    screen.should_contain('A')
+    screen.click('Replace')
+    screen.should_contain('B')
+    screen.should_not_contain('A')
+
+
+def test_reset_upload(screen: Screen):
+    upload = ui.upload()
+    ui.button('Reset', on_click=upload.reset)
+
+    screen.open('/')
+    screen.selenium.find_element(By.CLASS_NAME, 'q-uploader__input').send_keys(str(test_path1))
+    screen.should_contain(test_path1.name)
+    screen.click('Reset')
+    screen.wait(0.5)
+    screen.should_not_contain(test_path1.name)

+ 35 - 35
website/demo.py

@@ -1,5 +1,5 @@
 import inspect
-from typing import Callable, Optional
+from typing import Callable, Optional, Union
 
 import isort
 
@@ -19,37 +19,32 @@ def remove_prefix(text: str, prefix: str) -> str:
     return text[len(prefix):] if text.startswith(prefix) else text
 
 
-class demo:
-
-    def __init__(self, browser_title: Optional[str] = None) -> None:
-        self.browser_title = browser_title
-
-    def __call__(self, f: Callable) -> Callable:
-        with ui.column().classes('w-full items-stretch gap-8 no-wrap min-[1500px]:flex-row'):
-            code = inspect.getsource(f).split('# END OF DEMO')[0].strip().splitlines()
-            while not code[0].strip().startswith('def') and not code[0].strip().startswith('async def'):
-                del code[0]
+def demo(f: Callable) -> Callable:
+    with ui.column().classes('w-full items-stretch gap-8 no-wrap min-[1500px]:flex-row'):
+        code = inspect.getsource(f).split('# END OF DEMO')[0].strip().splitlines()
+        while not code[0].strip().startswith('def') and not code[0].strip().startswith('async def'):
             del code[0]
-            indentation = len(code[0]) - len(code[0].lstrip())
-            code = [line[indentation:] for line in code]
-            code = ['from nicegui import ui'] + [remove_prefix(line, '# ') for line in code]
-            code = ['' if line == '#' else line for line in code]
-            if not code[-1].startswith('ui.run('):
-                code.append('')
-                code.append('ui.run()')
-            code = isort.code('\n'.join(code), no_sections=True, lines_after_imports=1)
-            with python_window(classes='w-full max-w-[44rem]'):
-                async def copy_code():
-                    await ui.run_javascript('navigator.clipboard.writeText(`' + code + '`)', respond=False)
-                    ui.notify('Copied to clipboard', type='positive', color='primary')
-                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)
-            with browser_window(self.browser_title,
-                                classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window'):
-                intersection_observer(on_intersection=f)
-        return f
+        del code[0]
+        indentation = len(code[0]) - len(code[0].lstrip())
+        code = [line[indentation:] for line in code]
+        code = ['from nicegui import ui'] + [remove_prefix(line, '# ') for line in code]
+        code = ['' if line == '#' else line for line in code]
+        if not code[-1].startswith('ui.run('):
+            code.append('')
+            code.append('ui.run()')
+        code = isort.code('\n'.join(code), no_sections=True, lines_after_imports=1)
+        with python_window(classes='w-full max-w-[44rem]'):
+            async def copy_code():
+                await ui.run_javascript('navigator.clipboard.writeText(`' + code + '`)', respond=False)
+                ui.notify('Copied to clipboard', type='positive', color='primary')
+            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)
+        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)
+    return f
 
 
 def _window_header(bgcolor: str) -> ui.row():
@@ -67,16 +62,21 @@ def _title(title: str) -> None:
     ui.label(title).classes('text-sm text-gray-600 absolute left-1/2 top-[6px]').style('transform: translateX(-50%)')
 
 
-def _tab(name: str, color: str, bgcolor: str) -> None:
+def _tab(content: Union[str, Callable], color: str, bgcolor: str) -> None:
     with ui.row().classes('gap-0'):
         with ui.label().classes(f'w-2 h-[24px] bg-[{color}]'):
             ui.label().classes(f'w-full h-full bg-[{bgcolor}] rounded-br-[6px]')
-        ui.label(name).classes(f'text-sm text-gray-600 px-6 py-1 h-[24px] rounded-t-[6px] bg-[{color}]')
+        with ui.row().classes(f'text-sm text-gray-600 px-6 py-1 h-[24px] rounded-t-[6px] bg-[{color}] items-center gap-2'):
+            if callable(content):
+                content()
+            else:
+                ui.label(content)
         with ui.label().classes(f'w-2 h-[24px] bg-[{color}]'):
             ui.label().classes(f'w-full h-full bg-[{bgcolor}] rounded-bl-[6px]')
 
 
-def window(color: str, bgcolor: str, *, title: str = '', tab: str = '', classes: str = '') -> ui.column:
+def window(color: str, bgcolor: str, *,
+           title: str = '', tab: Union[str, Callable] = '', classes: str = '') -> ui.column:
     with ui.card().classes(f'no-wrap bg-[{color}] rounded-xl p-0 gap-0 {classes}') \
             .style('box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1)'):
         with _window_header(bgcolor):
@@ -96,5 +96,5 @@ def bash_window(*, classes: str = '') -> ui.card:
     return window(BASH_COLOR, BASH_BGCOLOR, title='bash', classes=classes).classes('p-2 bash-window')
 
 
-def browser_window(title: Optional[str] = None, *, classes: str = '') -> ui.card:
+def browser_window(title: Optional[Union[str, Callable]] = None, *, classes: str = '') -> ui.card:
     return window(BROWSER_COLOR, BROWSER_BGCOLOR, tab=title or 'NiceGUI', classes=classes).classes('p-4 browser-window')

+ 74 - 25
website/documentation.py

@@ -1,6 +1,6 @@
 import uuid
 
-from nicegui import app, ui
+from nicegui import app, events, ui
 
 from . import demo
 from .documentation_tools import element_demo, heading, intro_demo, load_demo, subheading, text_demo
@@ -79,6 +79,7 @@ def create_full() -> None:
     load_demo(ui.date)
     load_demo(ui.time)
     load_demo(ui.upload)
+    load_demo(ui.chat_message)
     load_demo(ui.element)
 
     heading('Markdown and HTML')
@@ -144,6 +145,7 @@ def create_full() -> None:
     load_demo(ui.card)
     load_demo(ui.column)
     load_demo(ui.row)
+    load_demo(ui.grid)
 
     @text_demo('Clear Containers', '''
         To remove all elements from a row, column or card container, use the `clear()` method.
@@ -199,24 +201,6 @@ def create_full() -> None:
     load_demo(ui.notify)
     load_demo(ui.dialog)
 
-    @text_demo('Awaitable dialog', '''
-        Dialogs can be awaited.
-        Use the `submit` method to close the dialog and return a result.
-        Canceling the dialog by clicking in the background or pressing the escape key yields `None`.
-    ''')
-    def async_dialog_demo():
-        with ui.dialog() as dialog, ui.card():
-            ui.label('Are you sure?')
-            with ui.row():
-                ui.button('Yes', on_click=lambda: dialog.submit('Yes'))
-                ui.button('No', on_click=lambda: dialog.submit('No'))
-
-        async def show():
-            result = await dialog
-            ui.notify(f'You chose {result}')
-
-        ui.button('Await a dialog', on_click=show)
-
     heading('Appearance')
 
     @text_demo('Styling', '''
@@ -234,6 +218,74 @@ def create_full() -> None:
         ui.button().props('icon=touch_app outline round').classes('shadow-lg')
         ui.label('Stylish!').style('color: #6E93D6; font-size: 200%; font-weight: 300')
 
+    subheading('Try styling NiceGUI elements!')
+    ui.markdown('''
+        Try out how
+        [Tailwind CSS classes](https://tailwindcss.com/),
+        [Quasar props](https://justpy.io/quasar_tutorial/introduction/#props-of-quasar-components),
+        and CSS styles affect NiceGUI elements.
+    ''').classes('bold-links arrow-links mb-[-1rem]')
+    with ui.row():
+        ui.label('Select an element from those available and start styling it!').classes('mx-auto my-auto')
+        select_element = ui.select({
+            ui.label: 'ui.label',
+            ui.checkbox: 'ui.checkbox',
+            ui.switch: 'ui.switch',
+            ui.input: 'ui.input',
+            ui.textarea: 'ui.textarea',
+            ui.button: 'ui.button',
+        }, value=ui.button, on_change=lambda: live_demo_ui.refresh()).props('dense')
+
+    @ui.refreshable
+    def live_demo_ui():
+        with ui.column().classes('w-full items-stretch gap-8 no-wrap min-[1500px]:flex-row'):
+            with demo.python_window(classes='w-full max-w-[44rem]'):
+                with ui.column().classes('w-full gap-4'):
+                    ui.markdown(f'''
+                        ```py
+                        from nicegui import ui
+
+                        element = {select_element.options[select_element.value]}('element')
+                        ```
+                    ''').classes('mb-[-0.25em]')
+                    with ui.row().classes('items-center gap-0 w-full px-2'):
+                        def handle_classes(e: events.ValueChangeEventArguments):
+                            try:
+                                element.classes(replace=e.value)
+                            except ValueError:
+                                pass
+                        ui.markdown("`element.classes('`")
+                        ui.input(on_change=handle_classes).classes('mt-[-0.5em] text-mono grow').props('dense')
+                        ui.markdown("`')`")
+                    with ui.row().classes('items-center gap-0 w-full px-2'):
+                        def handle_props(e: events.ValueChangeEventArguments):
+                            element._props = {'label': 'Button', 'color': 'primary'}
+                            try:
+                                element.props(e.value)
+                            except ValueError:
+                                pass
+                            element.update()
+                        ui.markdown("`element.props('`")
+                        ui.input(on_change=handle_props).classes('mt-[-0.5em] text-mono grow').props('dense')
+                        ui.markdown("`')`")
+                    with ui.row().classes('items-center gap-0 w-full px-2'):
+                        def handle_style(e: events.ValueChangeEventArguments):
+                            try:
+                                element.style(replace=e.value)
+                            except ValueError:
+                                pass
+                        ui.markdown("`element.style('`")
+                        ui.input(on_change=handle_style).classes('mt-[-0.5em] text-mono grow').props('dense')
+                        ui.markdown("`')`")
+                    ui.markdown('''
+                        ```py
+                        ui.run()
+                        ```
+                    ''')
+            with demo.browser_window(classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window'):
+                element: ui.element = select_element.value("element")
+    live_demo_ui()
+
     @text_demo('Tailwind CSS', '''
         [Tailwind CSS](https://tailwindcss.com/) is a CSS framework for rapidly building custom user interfaces.
         NiceGUI provides a fluent, auto-complete friendly interface for adding Tailwind classes to UI elements.
@@ -258,6 +310,7 @@ def create_full() -> None:
 
     load_demo(ui.query)
     load_demo(ui.colors)
+    load_demo(ui.dark_mode)
 
     heading('Action')
 
@@ -577,11 +630,7 @@ def create_full() -> None:
 
     heading('Configuration')
 
-    @element_demo(ui.run, browser_title='My App')
-    def ui_run_demo():
-        ui.label('page with custom title')
-
-        # ui.run(title='My App')
+    load_demo(ui.run)
 
     # HACK: switch color to white for the next demo
     demo_BROWSER_BGCOLOR = demo.BROWSER_BGCOLOR
@@ -594,7 +643,7 @@ def create_full() -> None:
         Pick any parameter as it is defined by the internally used [pywebview module](https://pywebview.flowrl.com/guide/api.html) 
         for the `webview.create_window` and `webview.start` functions.
         Note that these keyword arguments will take precedence over the parameters defined in ui.run.
-    ''')
+    ''', tab=lambda: ui.label('NiceGUI'))
     def native_mode_demo():
         from nicegui import app
 

+ 6 - 5
website/documentation_tools.py

@@ -65,15 +65,17 @@ def render_docstring(doc: str, with_params: bool = True) -> ui.html:
 
 class text_demo:
 
-    def __init__(self, title: str, explanation: str) -> None:
+    def __init__(self, title: str, explanation: str, tab: Optional[Union[str, Callable]] = None) -> None:
         self.title = title
         self.explanation = explanation
         self.make_menu_entry = True
+        self.tab = tab
 
     def __call__(self, f: Callable) -> Callable:
         subheading(self.title, make_menu_entry=self.make_menu_entry)
         ui.markdown(self.explanation).classes('bold-links arrow-links')
-        return demo()(f)
+        f.tab = self.tab
+        return demo(f)
 
 
 class intro_demo(text_demo):
@@ -85,9 +87,8 @@ class intro_demo(text_demo):
 
 class element_demo:
 
-    def __init__(self, element_class: Union[Callable, type], browser_title: Optional[str] = None) -> None:
+    def __init__(self, element_class: Union[Callable, type]) -> None:
         self.element_class = element_class
-        self.browser_title = browser_title
 
     def __call__(self, f: Callable, *, more_link: Optional[str] = None) -> Callable:
         doc = self.element_class.__doc__ or self.element_class.__init__.__doc__
@@ -95,7 +96,7 @@ class element_demo:
         with ui.column().classes('w-full mb-8 gap-2'):
             subheading(title, more_link=more_link)
             render_docstring(documentation, with_params=more_link is None)
-            return demo(browser_title=self.browser_title)(f)
+            return demo(f)
 
 
 def load_demo(api: Union[type, Callable]) -> None:

+ 8 - 0
website/more_documentation/chat_message_documentation.py

@@ -0,0 +1,8 @@
+from nicegui import ui
+
+
+def main_demo() -> None:
+    ui.chat_message('Hello NiceGUI!',
+                    name='Robot',
+                    stamp='now',
+                    avatar='https://robohash.org/ui')

+ 13 - 0
website/more_documentation/dark_mode_documentation.py

@@ -0,0 +1,13 @@
+from nicegui import ui
+
+
+def main_demo() -> None:
+    dark = ui.dark_mode()
+    # ui.label('Switch mode:')
+    # ui.button('Dark', on_click=dark.enable)
+    # ui.button('Light', on_click=dark.disable)
+    # END OF DEMO
+    l = ui.label('Switch mode:')
+    c = l.parent_slot.parent
+    ui.button('Dark', on_click=lambda: (l.style('color: white'), c.style('background-color: var(--q-dark-page)')))
+    ui.button('Light', on_click=lambda: (l.style('color: default'), c.style('background-color: default')))

+ 22 - 0
website/more_documentation/dialog_documentation.py

@@ -1,5 +1,7 @@
 from nicegui import ui
 
+from ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     with ui.dialog() as dialog, ui.card():
@@ -7,3 +9,23 @@ def main_demo() -> None:
         ui.button('Close', on_click=dialog.close)
 
     ui.button('Open a dialog', on_click=dialog.open)
+
+
+def more() -> None:
+    @text_demo('Awaitable dialog', '''
+        Dialogs can be awaited.
+        Use the `submit` method to close the dialog and return a result.
+        Canceling the dialog by clicking in the background or pressing the escape key yields `None`.
+    ''')
+    def async_dialog_demo():
+        with ui.dialog() as dialog, ui.card():
+            ui.label('Are you sure?')
+            with ui.row():
+                ui.button('Yes', on_click=lambda: dialog.submit('Yes'))
+                ui.button('No', on_click=lambda: dialog.submit('No'))
+
+        async def show():
+            result = await dialog
+            ui.notify(f'You chose {result}')
+
+        ui.button('Await a dialog', on_click=show)

+ 13 - 0
website/more_documentation/grid_documentation.py

@@ -0,0 +1,13 @@
+from nicegui import ui
+
+
+def main_demo() -> None:
+    with ui.grid(columns=2):
+        ui.label('Name:')
+        ui.label('Tom')
+
+        ui.label('Age:')
+        ui.label('42')
+
+        ui.label('Height:')
+        ui.label('1.80m')

+ 1 - 1
website/more_documentation/icon_documentation.py

@@ -4,7 +4,7 @@ from ..documentation_tools import text_demo
 
 
 def main_demo() -> None:
-    ui.icon('thumb_up').classes('text-5xl')
+    ui.icon('thumb_up', color='primary').classes('text-5xl')
 
 
 def more() -> None:

+ 13 - 0
website/more_documentation/input_documentation.py

@@ -1,8 +1,21 @@
 from nicegui import ui
 
+from ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     ui.input(label='Text', placeholder='start typing',
              on_change=lambda e: result.set_text('you typed: ' + e.value),
              validation={'Input too long': lambda value: len(value) < 20})
     result = ui.label()
+
+
+def more() -> None:
+
+    @text_demo('Autocompletion', '''
+        The `autocomplete` feature provides suggestions as you type, making input easier and faster.
+        The parameter `options` is a list of strings that contains the available options that will appear.
+    ''')
+    async def autocomplete_demo():
+        options = ['AutoComplete', 'NiceGUI', 'Awesome']
+        ui.input(label='Text', placeholder='start typing', autocomplete=options)

+ 58 - 0
website/more_documentation/run_documentation.py

@@ -1,7 +1,65 @@
 from nicegui import ui
 
+from ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     ui.label('page with custom title')
 
     # ui.run(title='My App')
+main_demo.tab = 'My App'
+
+
+def more() -> None:
+    @text_demo('Emoji favicon', '''
+        You can use an emoji as favicon.
+        This works in Chrome, Firefox and Safari.
+    ''', tab=lambda: ui.markdown('🚀&nbsp; NiceGUI'))
+    def emoji_favicon():
+        ui.label('NiceGUI Rocks!')
+
+        # ui.run(favicon='🚀')
+
+    @text_demo(
+        'Base64 favicon', '''
+        You can also use an base64-encoded image as favicon.
+    ''', tab=lambda: (
+            ui.image('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==')
+            .classes('w-4 h-4'),
+            ui.label('NiceGUI'),
+        ),
+    )
+    def base64_favicon():
+        ui.label('NiceGUI with a red dot!')
+
+        icon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=='
+
+        # ui.run(favicon=icon)
+
+    @text_demo('SVG favicon', '''
+        And directly use an SVG as favicon.
+        Works in Chrome, Firefox and Safari.
+    ''', tab=lambda: (
+        ui.html('''
+            <svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+                <circle cx="100" cy="100" r="78" fill="#ffde34" stroke="black" stroke-width="3" />
+                <circle cx="80" cy="85" r="8" />
+                <circle cx="120" cy="85" r="8" />
+                <path d="m60,120 C75,150 125,150 140,120" style="fill:none; stroke:black; stroke-width:8; stroke-linecap:round" />
+            </svg>
+        ''').classes('w-4 h-4'),
+        ui.label('NiceGUI'),
+    ))
+    def svg_favicon():
+        ui.label('NiceGUI makes you smile!')
+
+        smiley = '''
+            <svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+                <circle cx="100" cy="100" r="78" fill="#ffde34" stroke="black" stroke-width="3" />
+                <circle cx="80" cy="85" r="8" />
+                <circle cx="120" cy="85" r="8" />
+                <path d="m60,120 C75,150 125,150 140,120" style="fill:none; stroke:black; stroke-width:8; stroke-linecap:round" />
+            </svg>
+        '''
+
+        # ui.run(favicon=smiley)

+ 10 - 0
website/more_documentation/slider_documentation.py

@@ -35,3 +35,13 @@ def more() -> None:
         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"]}'),
                 throttle=1.0, leading_events=False)
+
+    @text_demo('Disable slider', '''
+        You can disable a slider with the `disable()` method.
+        This will prevent the user from moving the slider.
+        The slider will also be grayed out.
+    ''')
+    def disable_slider():
+        slider = ui.slider(min=0, max=100, value=50)
+        ui.button('Disable slider', on_click=slider.disable)
+        ui.button('Enable slider', on_click=slider.enable)

+ 12 - 0
website/more_documentation/table_documentation.py

@@ -128,3 +128,15 @@ def more() -> None:
             </q-tr>
         ''')
         table.on('rename', rename)
+
+    @text_demo('Table from pandas dataframe', '''
+        Here is a demo of how to create a table from a pandas dataframe.
+    ''')
+    def table_from_pandas_demo():
+        import pandas as pd
+
+        df = pd.DataFrame(data={'col1': [1, 2], 'col2': [3, 4]})
+        ui.table(
+            columns=[{'name': col, 'label': col, 'field': col} for col in df.columns],
+            rows=df.to_dict('records'),
+        )

+ 21 - 12
website/more_documentation/timer_documentation.py

@@ -1,19 +1,28 @@
 from nicegui import ui
 
+from ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     from datetime import datetime
 
-    with ui.row().classes('items-center'):
-        clock = ui.label()
-        t = ui.timer(interval=0.1, callback=lambda: clock.set_text(datetime.now().strftime('%X.%f')[:-5]))
-        ui.checkbox('active').bind_value(t, 'active')
+    label = ui.label()
+    ui.timer(1.0, lambda: label.set_text(f'{datetime.now():%X}'))
+
+
+def more() -> None:
+    @text_demo('Activate and deactivate a timer', '''
+        You can activate and deactivate a timer using the `active` property.
+    ''')
+    def activate_deactivate_demo():
+        slider = ui.slider(min=-1, max=1, value=0)
+        timer = ui.timer(0.1, lambda: slider.set_value((slider.value + 0.01) % 1.0))
+        ui.switch('active').bind_value_to(timer, 'active')
 
-    with ui.row():
-        def lazy_update() -> None:
-            new_text = datetime.now().strftime('%X.%f')[:-5]
-            if lazy_clock.text[:8] == new_text[:8]:
-                return
-            lazy_clock.text = new_text
-        lazy_clock = ui.label()
-        ui.timer(interval=0.1, callback=lazy_update)
+    @text_demo('Call a function after a delay', '''
+        You can call a function after a delay using a timer with the `once` parameter.
+    ''')
+    def call_after_delay_demo():
+        def handle_click():
+            ui.timer(1.0, lambda: ui.notify('Hi!'), once=True)
+        ui.button('Notify after 1 second', on_click=handle_click)

+ 11 - 0
website/more_documentation/video_documentation.py

@@ -1,6 +1,17 @@
 from nicegui import ui
 
+from ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     v = ui.video('https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4')
     v.on('ended', lambda _: ui.notify('Video playback completed'))
+
+
+def more() -> None:
+    @text_demo('Video start position', '''
+        This demo shows how to set the start position of a video.
+    ''')
+    def start_position_demo() -> None:
+        v = ui.video('https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4')
+        v.on('loadedmetadata', lambda: v.seek(5))

+ 4 - 3
website/star.py

@@ -5,7 +5,7 @@ from website import svg
 STYLE = '''
 <style>
     @keyframes star-tumble {
-          0% { transform: translateX(2em) rotate(144deg); }
+          0% { transform: translateX(6em) rotate(432deg); }
         100% { transform: translateX(0)   rotate(0);      }
     }
     @keyframes star-pulse {
@@ -43,9 +43,9 @@ STAR = '''
 '''
 
 
-def add_star() -> None:
+def add_star() -> ui.link:
     ui.add_head_html(STYLE)
-    with ui.link(target='https://github.com/zauberzeug/nicegui/').classes('star-container'):
+    with ui.link(target='https://github.com/zauberzeug/nicegui/').classes('star-container') as link:
         with Element('svg').props('viewBox="0 0 24 24"').classes('star'):
             Element('path').props('d="M23.555,8.729a1.505,1.505,0,0,0-1.406-.98H16.062a.5.5,0,0,1-.472-.334L13.405,1.222a1.5,1.5,0,0,0-2.81,0l-.005.016L8.41,7.415a.5.5,0,0,1-.471.334H1.85A1.5,1.5,0,0,0,.887,10.4l5.184,4.3a.5.5,0,0,1,.155.543L4.048,21.774a1.5,1.5,0,0,0,2.31,1.684l5.346-3.92a.5.5,0,0,1,.591,0l5.344,3.919a1.5,1.5,0,0,0,2.312-1.683l-2.178-6.535a.5.5,0,0,1,.155-.543l5.194-4.306A1.5,1.5,0,0,0,23.555,8.729Z"')
         with ui.tooltip('').classes('bg-[#486991] w-96 p-4'):
@@ -54,3 +54,4 @@ def add_star() -> None:
                 with ui.column().classes('p-2 gap-2'):
                     ui.label('Star us on GitHub!').classes('text-[180%]')
                     ui.label('And tell others about NiceGUI.').classes('text-[140%]')
+    return link