Browse Source

Merge branch 'main' into v1.3

# Conflicts:
#	nicegui/functions/notify.py
Falko Schindler 1 year ago
parent
commit
e69a33352f
100 changed files with 1363 additions and 535 deletions
  1. 3 2
      .github/workflows/test.yml
  2. 2 2
      .gitignore
  3. 3 3
      CITATION.cff
  4. 1 0
      README.md
  5. 15 44
      examples/authentication/main.py
  6. 10 3
      examples/fastapi/frontend.py
  7. 57 0
      examples/lightbox/main.py
  8. 13 1
      examples/local_file_picker/local_file_picker.py
  9. 1 1
      examples/local_file_picker/main.py
  10. 13 24
      examples/menu_and_tabs/main.py
  11. 20 0
      examples/modularization/example_c.py
  12. 2 7
      examples/modularization/example_pages.py
  13. 5 1
      examples/modularization/main.py
  14. 1 1
      examples/modularization/theme.py
  15. 3 3
      examples/script_executor/main.py
  16. 109 0
      examples/simpy/async_realtime_environment.py
  17. 49 0
      examples/simpy/main.py
  18. 2 0
      examples/simpy/requirements.txt
  19. 6 5
      examples/single_page_app/main.py
  20. 2 2
      examples/sqlite_database/main.py
  21. 1 1
      examples/table_and_slots/main.py
  22. 32 40
      examples/todo_list/main.py
  23. 5 3
      fetch_tailwind.py
  24. 1 1
      fly.toml
  25. 17 16
      main.py
  26. 2 1
      nicegui/__init__.py
  27. 44 0
      nicegui/api_router.py
  28. 90 7
      nicegui/app.py
  29. 3 1
      nicegui/background_tasks.py
  30. 27 17
      nicegui/binding.py
  31. 7 7
      nicegui/client.py
  32. 0 33
      nicegui/colors.py
  33. 3 2
      nicegui/dependencies.py
  34. 18 11
      nicegui/element.py
  35. 3 2
      nicegui/elements/aggrid.py
  36. 17 1
      nicegui/elements/audio.js
  37. 7 2
      nicegui/elements/audio.py
  38. 4 7
      nicegui/elements/avatar.py
  39. 4 5
      nicegui/elements/badge.py
  40. 10 6
      nicegui/elements/button.py
  41. 2 2
      nicegui/elements/checkbox.py
  42. 5 1
      nicegui/elements/choice_element.py
  43. 9 5
      nicegui/elements/color_input.py
  44. 2 2
      nicegui/elements/color_picker.py
  45. 2 2
      nicegui/elements/date.py
  46. 3 6
      nicegui/elements/icon.py
  47. 28 0
      nicegui/elements/image.js
  48. 10 3
      nicegui/elements/image.py
  49. 60 0
      nicegui/elements/input.js
  50. 17 33
      nicegui/elements/input.py
  51. 11 1
      nicegui/elements/interactive_image.js
  52. 9 4
      nicegui/elements/interactive_image.py
  53. 29 20
      nicegui/elements/joystick.py
  54. 2 2
      nicegui/elements/keyboard.py
  55. 3 4
      nicegui/elements/knob.py
  56. 9 3
      nicegui/elements/line_plot.py
  57. 13 4
      nicegui/elements/link.py
  58. 5 1
      nicegui/elements/log.js
  59. 5 2
      nicegui/elements/log.py
  60. 6 2
      nicegui/elements/menu.py
  61. 1 1
      nicegui/elements/mermaid.py
  62. 41 0
      nicegui/elements/mixins/color_elements.py
  63. 8 5
      nicegui/elements/mixins/content_element.py
  64. 16 5
      nicegui/elements/mixins/disableable_element.py
  65. 8 5
      nicegui/elements/mixins/filter_element.py
  66. 16 8
      nicegui/elements/mixins/source_element.py
  67. 8 5
      nicegui/elements/mixins/text_element.py
  68. 27 0
      nicegui/elements/mixins/validation_element.py
  69. 17 9
      nicegui/elements/mixins/value_element.py
  70. 17 8
      nicegui/elements/mixins/visibility.py
  71. 34 18
      nicegui/elements/number.py
  72. 1 0
      nicegui/elements/plotly.py
  73. 5 7
      nicegui/elements/progress.py
  74. 2 1
      nicegui/elements/pyplot.py
  75. 5 1
      nicegui/elements/radio.py
  76. 2 1
      nicegui/elements/scene.py
  77. 4 4
      nicegui/elements/scene_object3d.py
  78. 44 10
      nicegui/elements/select.py
  79. 1 0
      nicegui/elements/separator.py
  80. 3 2
      nicegui/elements/slider.py
  81. 3 5
      nicegui/elements/spinner.py
  82. 3 2
      nicegui/elements/splitter.py
  83. 69 0
      nicegui/elements/stepper.py
  84. 2 2
      nicegui/elements/switch.py
  85. 30 0
      nicegui/elements/table.js
  86. 7 4
      nicegui/elements/table.py
  87. 29 14
      nicegui/elements/tabs.py
  88. 8 4
      nicegui/elements/textarea.py
  89. 4 4
      nicegui/elements/time.py
  90. 5 1
      nicegui/elements/toggle.py
  91. 4 3
      nicegui/elements/tree.py
  92. 7 3
      nicegui/elements/upload.js
  93. 9 7
      nicegui/elements/upload.py
  94. 20 6
      nicegui/elements/video.js
  95. 7 2
      nicegui/elements/video.py
  96. 5 2
      nicegui/event_listener.py
  97. 10 8
      nicegui/events.py
  98. 36 16
      nicegui/favicon.py
  99. 2 2
      nicegui/functions/javascript.py
  100. 1 1
      nicegui/functions/notify.py

+ 3 - 2
.github/workflows/test.yml

@@ -9,7 +9,7 @@ jobs:
         python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
         python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
       fail-fast: false
       fail-fast: false
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
-    timeout-minutes: 20
+    timeout-minutes: 40
     steps:
     steps:
       - uses: actions/checkout@v3
       - uses: actions/checkout@v3
       - name: set up Python
       - name: set up Python
@@ -25,7 +25,8 @@ jobs:
           poetry config virtualenvs.create false
           poetry config virtualenvs.create false
           poetry install
           poetry install
           # install packages to run the examples
           # install packages to run the examples
-          pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai
+          pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai simpy
+          pip install -r tests/requirements.txt
           # try fix issue with importlib_resources
           # try fix issue with importlib_resources
           pip install importlib-resources
           pip install importlib-resources
       - name: test startup
       - name: test startup

+ 2 - 2
.gitignore

@@ -5,7 +5,7 @@ dist
 /test.py
 /test.py
 *.pickle
 *.pickle
 tests/screenshots/
 tests/screenshots/
-
-# ignore local virtual environments
+tests/media/
 venv
 venv
 .idea
 .idea
+.nicegui/

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
   orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
-version: v1.2.14
-date-released: '2023-05-14'
+version: v1.2.22
+date-released: '2023-06-24'
 url: https://github.com/zauberzeug/nicegui
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.7933863
+doi: 10.5281/zenodo.8076547

+ 1 - 0
README.md

@@ -43,6 +43,7 @@ NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Dock
 - straight-forward data binding and refreshable functions to write even less code
 - straight-forward data binding and refreshable functions to write even less code
 - notifications, dialogs and menus to provide state of the art user interaction
 - notifications, dialogs and menus to provide state of the art user interaction
 - shared and individual web pages
 - shared and individual web pages
+- easy-to-use per-user and general persistence
 - ability to add custom routes and data responses
 - ability to add custom routes and data responses
 - capture keyboard input for global shortcuts etc.
 - capture keyboard input for global shortcuts etc.
 - customize look by defining primary, secondary and accent colors
 - customize look by defining primary, secondary and accent colors

+ 15 - 44
examples/authentication/main.py

@@ -1,71 +1,42 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-'''This is only a very simple authentication example which stores session IDs in memory and does not do any password hashing.
+"""This is just a very simple authentication example.
 
 
 Please see the `OAuth2 example at FastAPI <https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/>`_  or
 Please see the `OAuth2 example at FastAPI <https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/>`_  or
-use the great `Authlib package <https://docs.authlib.org/en/v0.13/client/starlette.html#using-fastapi>`_ to implement a real authentication system.
-
+use the great `Authlib package <https://docs.authlib.org/en/v0.13/client/starlette.html#using-fastapi>`_ to implement a classing real authentication system.
 Here we just demonstrate the NiceGUI integration.
 Here we just demonstrate the NiceGUI integration.
-'''
-
-import os
-import uuid
-from typing import Dict
-
-from fastapi import Request
+"""
 from fastapi.responses import RedirectResponse
 from fastapi.responses import RedirectResponse
-from starlette.middleware.sessions import SessionMiddleware
 
 
 from nicegui import app, ui
 from nicegui import app, ui
 
 
-# put your your own secret key in an environment variable MY_SECRET_KEY
-app.add_middleware(SessionMiddleware, secret_key=os.environ.get('MY_SECRET_KEY', ''))
-
-# in reality users and session_info would be persistent (e.g. database, file, ...) and passwords obviously hashed
-users = [('user1', 'pass1'), ('user2', 'pass2')]
-session_info: Dict[str, Dict] = {}
-
-
-def is_authenticated(request: Request) -> bool:
-    return session_info.get(request.session.get('id'), {}).get('authenticated', False)
+# in reality users passwords would obviously need to be hashed
+passwords = {'user1': 'pass1', 'user2': 'pass2'}
 
 
 
 
 @ui.page('/')
 @ui.page('/')
-def main_page(request: Request) -> None:
-    if not is_authenticated(request):
+def main_page() -> None:
+    if not app.storage.user.get('authenticated', False):
         return RedirectResponse('/login')
         return RedirectResponse('/login')
-    session = session_info[request.session['id']]
     with ui.column().classes('absolute-center items-center'):
     with ui.column().classes('absolute-center items-center'):
-        ui.label(f'Hello {session["username"]}!').classes('text-2xl')
-        # NOTE we navigate to a new page here to be able to modify the session cookie (it is only editable while a request is en-route)
-        # see https://github.com/zauberzeug/nicegui/issues/527 for more details
-        ui.button('', on_click=lambda: ui.open('/logout')).props('outline round icon=logout')
+        ui.label(f'Hello {app.storage.user["username"]}!').classes('text-2xl')
+        ui.button(on_click=lambda: (app.storage.user.clear(), ui.open('/login')), icon='logout').props('outline round')
 
 
 
 
 @ui.page('/login')
 @ui.page('/login')
-def login(request: Request) -> None:
+def login() -> None:
     def try_login() -> None:  # local function to avoid passing username and password as arguments
     def try_login() -> None:  # local function to avoid passing username and password as arguments
-        if (username.value, password.value) in users:
-            session_info[request.session['id']] = {'username': username.value, 'authenticated': True}
+        if passwords.get(username.value) == password.value:
+            app.storage.user.update({'username': username.value, 'authenticated': True})
             ui.open('/')
             ui.open('/')
         else:
         else:
             ui.notify('Wrong username or password', color='negative')
             ui.notify('Wrong username or password', color='negative')
 
 
-    if is_authenticated(request):
+    if app.storage.user.get('authenticated', False):
         return RedirectResponse('/')
         return RedirectResponse('/')
-    request.session['id'] = str(uuid.uuid4())  # NOTE this stores a new session ID in the cookie of the client
     with ui.card().classes('absolute-center'):
     with ui.card().classes('absolute-center'):
         username = ui.input('Username').on('keydown.enter', try_login)
         username = ui.input('Username').on('keydown.enter', try_login)
-        password = ui.input('Password').props('type=password').on('keydown.enter', try_login)
+        password = ui.input('Password', password=True, password_toggle_button=True).on('keydown.enter', try_login)
         ui.button('Log in', on_click=try_login)
         ui.button('Log in', on_click=try_login)
 
 
 
 
-@ui.page('/logout')
-def logout(request: Request) -> None:
-    if is_authenticated(request):
-        session_info.pop(request.session['id'])
-        request.session['id'] = None
-        return RedirectResponse('/login')
-    return RedirectResponse('/')
-
-
-ui.run()
+ui.run(storage_secret='THIS_NEEDS_TO_BE_CHANGED')

+ 10 - 3
examples/fastapi/frontend.py

@@ -1,11 +1,18 @@
 from fastapi import FastAPI
 from fastapi import FastAPI
 
 
-from nicegui import ui
+from nicegui import app, ui
 
 
 
 
-def init(app: FastAPI) -> None:
+def init(fastapi_app: FastAPI) -> None:
     @ui.page('/show')
     @ui.page('/show')
     def show():
     def show():
         ui.label('Hello, FastAPI!')
         ui.label('Hello, FastAPI!')
 
 
-    ui.run_with(app)
+        # NOTE dark mode will be persistent for each user across tabs and server restarts
+        ui.dark_mode().bind_value(app.storage.user, 'dark_mode')
+        ui.checkbox('dark mode').bind_value(app.storage.user, 'dark_mode')
+
+    ui.run_with(
+        fastapi_app,
+        storage_secret='pick your private secret here',  # NOTE setting a secret is optional but allows for persistent storage per user
+    )

+ 57 - 0
examples/lightbox/main.py

@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+from typing import List
+
+import httpx
+
+from nicegui import events, ui
+
+
+class Lightbox:
+    """A thumbnail gallery where each image can be clicked to enlarge.
+    Inspired by https://lokeshdhakar.com/projects/lightbox2/.
+    """
+
+    def __init__(self) -> None:
+        with ui.dialog().props('maximized').classes('bg-black') as self.dialog:
+            ui.keyboard(self._on_key)
+            self.large_image = ui.image().props('no-spinner')
+        self.image_list: List[str] = []
+
+    def add_image(self, thumb_url: str, orig_url: str) -> ui.image:
+        """Place a thumbnail image in the UI and make it clickable to enlarge."""
+        self.image_list.append(orig_url)
+        with ui.button(on_click=lambda: self._open(orig_url)).props('flat dense square'):
+            return ui.image(thumb_url)
+
+    def _on_key(self, event_args: events.KeyEventArguments) -> None:
+        if not event_args.action.keydown:
+            return
+        if event_args.key.escape:
+            self.dialog.close()
+        image_index = self.image_list.index(self.large_image.source)
+        if event_args.key.arrow_left and image_index > 0:
+            self._open(self.image_list[image_index - 1])
+        if event_args.key.arrow_right and image_index < len(self.image_list) - 1:
+            self._open(self.image_list[image_index + 1])
+
+    def _open(self, url: str) -> None:
+        self.large_image.set_source(url)
+        self.dialog.open()
+
+
+@ui.page('/')
+async def page():
+    lightbox = Lightbox()
+    async with httpx.AsyncClient() as client:  # using async httpx instead of sync requests to avoid blocking the event loop
+        images = await client.get('https://picsum.photos/v2/list?page=4&limit=30')
+    with ui.row().classes('w-full'):
+        for image in images.json():  # picsum returns a list of images as json data
+            # we can use the image ID to construct the image URLs
+            image_base_url = f'https://picsum.photos/id/{image["id"]}'
+            # the lightbox allows us to add images which can be opened in a full screen dialog
+            lightbox.add_image(
+                thumb_url=f'{image_base_url}/300/200',
+                orig_url=f'{image_base_url}/{image["width"]}/{image["height"]}',
+            ).classes('w-[300px] h-[200px]')
+
+ui.run()

+ 13 - 1
examples/local_file_picker/local_file_picker.py

@@ -1,3 +1,4 @@
+import platform
 from pathlib import Path
 from pathlib import Path
 from typing import Dict, Optional
 from typing import Dict, Optional
 
 
@@ -27,6 +28,7 @@ class local_file_picker(ui.dialog):
         self.show_hidden_files = show_hidden_files
         self.show_hidden_files = show_hidden_files
 
 
         with self, ui.card():
         with self, ui.card():
+            self.add_drives_toggle()
             self.grid = ui.aggrid({
             self.grid = ui.aggrid({
                 'columnDefs': [{'field': 'name', 'headerName': 'File'}],
                 'columnDefs': [{'field': 'name', 'headerName': 'File'}],
                 'rowSelection': 'multiple' if multiple else 'single',
                 'rowSelection': 'multiple' if multiple else 'single',
@@ -36,6 +38,16 @@ class local_file_picker(ui.dialog):
                 ui.button('Ok', on_click=self._handle_ok)
                 ui.button('Ok', on_click=self._handle_ok)
         self.update_grid()
         self.update_grid()
 
 
+    def add_drives_toggle(self):
+        if platform.system() == 'Windows':
+            import win32api
+            drives = win32api.GetLogicalDriveStrings().split('\000')[:-1]
+            self.drives_toggle = ui.toggle(drives, value=drives[0], on_change=self.update_drive)
+
+    def update_drive(self):
+        self.path = Path(self.drives_toggle.value).expanduser()
+        self.update_grid()
+
     def update_grid(self) -> None:
     def update_grid(self) -> None:
         paths = list(self.path.glob('*'))
         paths = list(self.path.glob('*'))
         if not self.show_hidden_files:
         if not self.show_hidden_files:
@@ -58,7 +70,7 @@ class local_file_picker(ui.dialog):
             })
             })
         self.grid.update()
         self.grid.update()
 
 
-    async def handle_double_click(self, msg: Dict) -> None:
+    def handle_double_click(self, msg: Dict) -> None:
         self.path = Path(msg['args']['data']['path'])
         self.path = Path(msg['args']['data']['path'])
         if self.path.is_dir():
         if self.path.is_dir():
             self.update_grid()
             self.update_grid()

+ 1 - 1
examples/local_file_picker/main.py

@@ -8,6 +8,6 @@ async def pick_file() -> None:
     result = await local_file_picker('~', multiple=True)
     result = await local_file_picker('~', multiple=True)
     ui.notify(f'You chose {result}')
     ui.notify(f'You chose {result}')
 
 
-ui.button('Choose file', on_click=pick_file).props('icon=folder')
+ui.button('Choose file', on_click=pick_file, icon='folder')
 
 
 ui.run()
 ui.run()

+ 13 - 24
examples/menu_and_tabs/main.py

@@ -1,24 +1,12 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-from typing import Dict
-
 from nicegui import ui
 from nicegui import ui
 
 
-tab_names = ['A', 'B', 'C']
-
-# necessary until we improve native support for tabs (https://github.com/zauberzeug/nicegui/issues/251)
-
-
-def switch_tab(msg: Dict) -> None:
-    name = msg['args']
-    tabs.props(f'model-value={name}')
-    panels.props(f'model-value={name}')
-
-
 with ui.header().classes(replace='row items-center') as header:
 with ui.header().classes(replace='row items-center') as header:
     ui.button(on_click=lambda: left_drawer.toggle()).props('flat color=white icon=menu')
     ui.button(on_click=lambda: left_drawer.toggle()).props('flat color=white icon=menu')
-    with ui.element('q-tabs').on('update:model-value', switch_tab) as tabs:
-        for name in tab_names:
-            ui.element('q-tab').props(f'name={name} label={name}')
+    with ui.tabs() as tabs:
+        ui.tab('A')
+        ui.tab('B')
+        ui.tab('C')
 
 
 with ui.footer(value=False) as footer:
 with ui.footer(value=False) as footer:
     ui.label('Footer')
     ui.label('Footer')
@@ -27,13 +15,14 @@ with ui.left_drawer().classes('bg-blue-100') as left_drawer:
     ui.label('Side menu')
     ui.label('Side menu')
 
 
 with ui.page_sticky(position='bottom-right', x_offset=20, y_offset=20):
 with ui.page_sticky(position='bottom-right', x_offset=20, y_offset=20):
-    ui.button(on_click=footer.toggle).props('fab icon=contact_support')
-
-
-# the page content consists of multiple tab panels
-with ui.element('q-tab-panels').props('model-value=A animated').classes('w-full') as panels:
-    for name in tab_names:
-        with ui.element('q-tab-panel').props(f'name={name}').classes('w-full'):
-            ui.label(f'Content of {name}')
+    ui.button(on_click=footer.toggle, icon='contact_support').props('fab')
+
+with ui.tab_panels(tabs, value='A').classes('w-full'):
+    with ui.tab_panel('A'):
+        ui.label('Content of A')
+    with ui.tab_panel('B'):
+        ui.label('Content of B')
+    with ui.tab_panel('C'):
+        ui.label('Content of C')
 
 
 ui.run()
 ui.run()

+ 20 - 0
examples/modularization/example_c.py

@@ -0,0 +1,20 @@
+import theme
+
+from nicegui import APIRouter, ui
+
+router = APIRouter(prefix='/c')
+
+
+@router.page('/')
+def example_page():
+    with theme.frame('- Example C -'):
+        ui.label('Example C').classes('text-h4 text-grey-8')
+        for i in range(1, 4):
+            ui.link(f'Item {i}', f'/c/items/{i}').classes('text-xl text-grey-8')
+
+
+@router.page('/items/{id}', dark=True)
+def item(id: str):
+    with theme.frame(f'- Example C{id} -'):
+        ui.label(f'Item  #{id}').classes('text-h4 text-grey-8')
+        ui.link('go back', router.prefix).classes('text-xl text-grey-8')

+ 2 - 7
examples/modularization/example_pages.py

@@ -6,16 +6,11 @@ from nicegui import ui
 def create() -> None:
 def create() -> None:
 
 
     @ui.page('/a')
     @ui.page('/a')
-    def example_page():
+    def example_page_a():
         with theme.frame('- Example A -'):
         with theme.frame('- Example A -'):
             ui.label('Example A').classes('text-h4 text-grey-8')
             ui.label('Example A').classes('text-h4 text-grey-8')
 
 
     @ui.page('/b')
     @ui.page('/b')
-    def example_page():
+    def example_page_b():
         with theme.frame('- Example B -'):
         with theme.frame('- Example B -'):
             ui.label('Example B').classes('text-h4 text-grey-8')
             ui.label('Example B').classes('text-h4 text-grey-8')
-
-    @ui.page('/c')
-    def example_page():
-        with theme.frame('- Example C -'):
-            ui.label('Example C').classes('text-h4 text-grey-8')

+ 5 - 1
examples/modularization/main.py

@@ -1,9 +1,10 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
+import example_c
 import example_pages
 import example_pages
 import home_page
 import home_page
 import theme
 import theme
 
 
-from nicegui import ui
+from nicegui import app, ui
 
 
 
 
 # here we use our custom page decorator directly and just put the content creation into a separate function
 # here we use our custom page decorator directly and just put the content creation into a separate function
@@ -16,4 +17,7 @@ def index_page() -> None:
 # this call shows that you can also move the whole page creation into a separate file
 # this call shows that you can also move the whole page creation into a separate file
 example_pages.create()
 example_pages.create()
 
 
+# we can also use the APIRouter as described in https://nicegui.io/documentation/page#modularize_with_apirouter
+app.include_router(example_c.router)
+
 ui.run(title='Modularization Example')
 ui.run(title='Modularization Example')

+ 1 - 1
examples/modularization/theme.py

@@ -14,5 +14,5 @@ def frame(navtitle: str):
         ui.label(navtitle)
         ui.label(navtitle)
         with ui.row():
         with ui.row():
             menu()
             menu()
-    with ui.row().classes('absolute-center'):
+    with ui.column().classes('absolute-center items-center'):
         yield
         yield

+ 3 - 3
examples/script_executor/main.py

@@ -4,11 +4,11 @@ import os.path
 import platform
 import platform
 import shlex
 import shlex
 
 
-from nicegui import background_tasks, ui
+from nicegui import ui
 
 
 
 
 async def run_command(command: str) -> None:
 async def run_command(command: str) -> None:
-    '''Run a command in the background and display the output in the pre-created dialog.'''
+    """Run a command in the background and display the output in the pre-created dialog."""
     dialog.open()
     dialog.open()
     result.content = ''
     result.content = ''
     process = await asyncio.create_subprocess_exec(
     process = await asyncio.create_subprocess_exec(
@@ -32,7 +32,7 @@ with ui.dialog() as dialog, ui.card():
 commands = ['python3 hello.py', 'python3 hello.py NiceGUI', 'python3 slow.py']
 commands = ['python3 hello.py', 'python3 hello.py NiceGUI', 'python3 slow.py']
 with ui.row():
 with ui.row():
     for command in commands:
     for command in commands:
-        ui.button(command, on_click=lambda _, c=command: background_tasks.create(run_command(c))).props('no-caps')
+        ui.button(command, on_click=lambda command=command: run_command(command)).props('no-caps')
 
 
 
 
 # NOTE on windows reload must be disabled to make asyncio.create_subprocess_exec work (see https://github.com/zauberzeug/nicegui/issues/486)
 # NOTE on windows reload must be disabled to make asyncio.create_subprocess_exec work (see https://github.com/zauberzeug/nicegui/issues/486)

+ 109 - 0
examples/simpy/async_realtime_environment.py

@@ -0,0 +1,109 @@
+import asyncio
+from time import monotonic
+from typing import Any, Optional, Union
+
+from numpy import Infinity
+from simpy.core import EmptySchedule, Environment, Infinity, SimTime, StopSimulation
+from simpy.events import URGENT, Event
+from simpy.rt import RealtimeEnvironment
+
+
+class AsyncRealtimeEnvironment(RealtimeEnvironment):
+    """A real-time simulation environment that uses asyncio.
+
+    The methods step and run are a 1-1 copy of the original methods from simpy.rt.RealtimeEnvironment,
+    except that they are async and await asyncio.sleep instead of time.sleep.
+    """
+
+    async def step(self) -> None:
+        """Process the next event after enough real-time has passed for the
+        event to happen.
+
+        The delay is scaled according to the real-time :attr:`factor`. With
+        :attr:`strict` mode enabled, a :exc:`RuntimeError` will be raised, if
+        the event is processed too slowly.
+
+        """
+        evt_time = self.peek()
+
+        if evt_time is Infinity:
+            raise EmptySchedule()
+
+        real_time = self.real_start + (evt_time - self.env_start) * self.factor
+
+        if self.strict and monotonic() - real_time > self.factor:
+            # Events scheduled for time *t* may take just up to *t+1*
+            # for their computation, before an error is raised.
+            delta = monotonic() - real_time
+            raise RuntimeError(
+                f'Simulation too slow for real time ({delta:.3f}s).'
+            )
+
+        # Sleep in a loop to fix inaccuracies of windows (see
+        # http://stackoverflow.com/a/15967564 for details) and to ignore
+        # interrupts.
+        while True:
+            delta = real_time - monotonic()
+            if delta <= 0:
+                break
+            await asyncio.sleep(delta)
+
+        Environment.step(self)
+
+    async def run(
+        self, until: Optional[Union[SimTime, Event]] = None
+    ) -> Optional[Any]:
+        """Executes :meth:`step()` until the given criterion *until* is met.
+
+        - If it is ``None`` (which is the default), this method will return
+          when there are no further events to be processed.
+
+        - If it is an :class:`~simpy.events.Event`, the method will continue
+          stepping until this event has been triggered and will return its
+          value.  Raises a :exc:`RuntimeError` if there are no further events
+          to be processed and the *until* event was not triggered.
+
+        - If it is a number, the method will continue stepping
+          until the environment's time reaches *until*.
+
+        """
+        if until is not None:
+            if not isinstance(until, Event):
+                # Assume that *until* is a number if it is not None and
+                # not an event.  Create a Timeout(until) in this case.
+                at: SimTime
+                if isinstance(until, int):
+                    at = until
+                else:
+                    at = float(until)
+
+                if at <= self.now:
+                    raise ValueError(
+                        f'until(={at}) must be > the current simulation time.'
+                    )
+
+                # Schedule the event before all regular timeouts.
+                until = Event(self)
+                until._ok = True
+                until._value = None
+                self.schedule(until, URGENT, at - self.now)
+
+            elif until.callbacks is None:
+                # Until event has already been processed.
+                return until.value
+
+            until.callbacks.append(StopSimulation.callback)
+
+        try:
+            while True:
+                await self.step()
+        except StopSimulation as exc:
+            return exc.args[0]  # == until.value
+        except EmptySchedule:
+            if until is not None:
+                assert not until.triggered
+                raise RuntimeError(
+                    f'No scheduled events left but "until" event was not '
+                    f'triggered: {until}'
+                )
+        return None

+ 49 - 0
examples/simpy/main.py

@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+import asyncio
+import datetime
+
+from async_realtime_environment import AsyncRealtimeEnvironment
+
+from nicegui import ui
+
+start_time = datetime.datetime.now()
+
+
+def clock(env):
+    while True:
+        simulation_time = start_time + datetime.timedelta(seconds=env.now)
+        clock_label.text = simulation_time.strftime('%H:%M:%S')
+        yield env.timeout(1)
+
+
+def traffic_light(env):
+    while True:
+        light.classes('bg-green-500', remove='bg-red-500')
+        yield env.timeout(30)
+        light.classes('bg-yellow-500', remove='bg-green-500')
+        yield env.timeout(5)
+        light.classes('bg-red-500', remove='bg-yellow-500')
+        yield env.timeout(20)
+
+
+async def run_simpy():
+    env = AsyncRealtimeEnvironment(factor=0.1)  # fast forward simulation with 1/10th of realtime
+    env.process(traffic_light(env))
+    env.process(clock(env))
+    try:
+        await env.run(until=300)  # run until 300 seconds of simulation time have passed
+    except asyncio.CancelledError:
+        return
+    ui.notify('Simulation completed')
+    content.classes('opacity-0')  # fade out the content
+
+# define the UI
+with ui.column().classes('absolute-center items-center transition-opacity duration-500') as content:
+    ui.label('SimPy Traffic Light Demo').classes('text-2xl mb-6')
+    light = ui.element('div').classes('w-10 h-10 rounded-full shadow-lg transition')
+    clock_label = ui.label()
+
+# start the simpy simulation as an async task in the background as soon as the UI is ready
+ui.timer(0, run_simpy, once=True)
+
+ui.run()

+ 2 - 0
examples/simpy/requirements.txt

@@ -0,0 +1,2 @@
+nicegui>=1.2
+simpy

+ 6 - 5
examples/single_page_app/main.py

@@ -4,21 +4,21 @@ from router import Router
 from nicegui import ui
 from nicegui import ui
 
 
 
 
-@ui.page('/')  # normal index page (eg. the entry point of the app)
+@ui.page('/')  # normal index page (e.g. the entry point of the app)
 @ui.page('/{_:path}')  # all other pages will be handled by the router but must be registered to also show the SPA index page
 @ui.page('/{_:path}')  # all other pages will be handled by the router but must be registered to also show the SPA index page
-async def main():
+def main():
     router = Router()
     router = Router()
 
 
     @router.add('/')
     @router.add('/')
-    async def show_one():
+    def show_one():
         ui.label('Content One').classes('text-2xl')
         ui.label('Content One').classes('text-2xl')
 
 
     @router.add('/two')
     @router.add('/two')
-    async def show_two():
+    def show_two():
         ui.label('Content Two').classes('text-2xl')
         ui.label('Content Two').classes('text-2xl')
 
 
     @router.add('/three')
     @router.add('/three')
-    async def show_three():
+    def show_three():
         ui.label('Content Three').classes('text-2xl')
         ui.label('Content Three').classes('text-2xl')
 
 
     # adding some navigation buttons to switch between the different pages
     # adding some navigation buttons to switch between the different pages
@@ -30,4 +30,5 @@ async def main():
     # this places the content which should be displayed
     # this places the content which should be displayed
     router.frame().classes('w-full p-4 bg-gray-100')
     router.frame().classes('w-full p-4 bg-gray-100')
 
 
+
 ui.run()
 ui.run()

+ 2 - 2
examples/sqlite_database/main.py

@@ -24,8 +24,8 @@ def users_ui() -> None:
                 ui.label(user['name'])
                 ui.label(user['name'])
                 ui.label(user['age'])
                 ui.label(user['age'])
             with ui.row():
             with ui.row():
-                ui.button('edit', on_click=lambda _, user=user: open_dialog(user))
-                ui.button('delete', on_click=lambda _, user=user: delete(user), color='red')
+                ui.button('edit', on_click=lambda user=user: open_dialog(user))
+                ui.button('delete', on_click=lambda user=user: delete(user), color='red')
 
 
 
 
 def create() -> None:
 def create() -> None:

+ 1 - 1
examples/table_and_slots/main.py

@@ -28,7 +28,7 @@ with ui.table(title='My Team', columns=columns, rows=rows, selection='multiple',
                     table.add_rows({'id': time.time(), 'name': new_name.value, 'age': new_age.value}),
                     table.add_rows({'id': time.time(), 'name': new_name.value, 'age': new_age.value}),
                     new_name.set_value(None),
                     new_name.set_value(None),
                     new_age.set_value(None),
                     new_age.set_value(None),
-                )).props('flat fab-mini icon=add')
+                ), icon='add').props('flat fab-mini')
             with table.cell():
             with table.cell():
                 new_name = ui.input('Name')
                 new_name = ui.input('Name')
             with table.cell():
             with table.cell():

+ 32 - 40
examples/todo_list/main.py

@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-from dataclasses import dataclass
-from typing import List
+from dataclasses import dataclass, field
+from typing import Callable, List
 
 
 from nicegui import ui
 from nicegui import ui
 
 
@@ -11,55 +11,47 @@ class TodoItem:
     done: bool = False
     done: bool = False
 
 
 
 
-items: List[TodoItem] = [
-    TodoItem('Buy milk', done=True),
-    TodoItem('Clean the house'),
-    TodoItem('Call mom'),
-]
-
-
-def add(name: str) -> None:
-    items.append(TodoItem(name))
-    add_input.value = None
-    render_list.refresh()
-
-
-def remove(item: TodoItem) -> None:
-    items.remove(item)
-    render_list.refresh()
-
-
-def toggle(item: TodoItem) -> None:
-    item.done = not item.done
-    render_list.refresh()
+@dataclass
+class ToDoList:
+    title: str
+    on_change: Callable
+    items: List[TodoItem] = field(default_factory=list)
 
 
+    def add(self, name: str, done: bool = False) -> None:
+        self.items.append(TodoItem(name, done))
+        self.on_change()
 
 
-def rename(item: TodoItem, name: str) -> None:
-    item.name = name
-    render_list.refresh()
+    def remove(self, item: TodoItem) -> None:
+        self.items.remove(item)
+        self.on_change()
 
 
 
 
 @ui.refreshable
 @ui.refreshable
-def render_list():
-    if not items:
-        ui.label('List is empty.')
+def todo_ui():
+    if not todos.items:
+        ui.label('List is empty.').classes('mx-auto')
         return
         return
-    ui.linear_progress(sum(item.done for item in items) / len(items), show_value=False)
+    ui.linear_progress(sum(item.done for item in todos.items) / len(todos.items), show_value=False)
     with ui.row().classes('justify-center w-full'):
     with ui.row().classes('justify-center w-full'):
-        ui.label(f'Completed: {sum(item.done for item in items)}')
-        ui.label(f'Remaining: {sum(not item.done for item in items)}')
-    for item in items:
+        ui.label(f'Completed: {sum(item.done for item in todos.items)}')
+        ui.label(f'Remaining: {sum(not item.done for item in todos.items)}')
+    for item in todos.items:
         with ui.row().classes('items-center'):
         with ui.row().classes('items-center'):
-            ui.checkbox(value=item.done, on_change=lambda _, item=item: toggle(item))
-            input = ui.input(value=item.name).classes('flex-grow')
-            input.on('keydown.enter', lambda _, item=item, input=input: rename(item, input.value))
-            ui.button(on_click=lambda _, item=item: remove(item)).props('flat fab-mini icon=delete color=grey')
+            ui.checkbox(value=item.done, on_change=todo_ui.refresh).bind_value(item, 'done')
+            ui.input(value=item.name).classes('flex-grow').bind_value(item, 'name')
+            ui.button(on_click=lambda item=item: todos.remove(item), icon='delete').props('flat fab-mini color=grey')
+
 
 
+todos = ToDoList('My Weekend', on_change=todo_ui.refresh)
+todos.add('Order pizza', done=True)
+todos.add('New NiceGUI Release')
+todos.add('Clean the house')
+todos.add('Call mom')
 
 
 with ui.card().classes('w-80 items-stretch'):
 with ui.card().classes('w-80 items-stretch'):
-    ui.label('Todo list').classes('text-semibold text-2xl')
-    render_list()
+    ui.label().bind_text_from(todos, 'title').classes('text-semibold text-2xl')
+    todo_ui()
     add_input = ui.input('New item').classes('mx-12')
     add_input = ui.input('New item').classes('mx-12')
-    add_input.on('keydown.enter', lambda: add(add_input.value))
+    add_input.on('keydown.enter', lambda: (todos.add(add_input.value), add_input.set_value('')))
 
 
 ui.run()
 ui.run()

+ 5 - 3
fetch_tailwind.py

@@ -94,7 +94,7 @@ for property in properties:
 with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
 with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('from __future__ import annotations\n')
     f.write('from __future__ import annotations\n')
     f.write('\n')
     f.write('\n')
-    f.write('from typing import TYPE_CHECKING, List, Optional, overload\n')
+    f.write('from typing import TYPE_CHECKING, List, Optional, Union, overload\n')
     f.write('\n')
     f.write('\n')
     f.write('if TYPE_CHECKING:\n')
     f.write('if TYPE_CHECKING:\n')
     f.write('    from .element import Element\n')
     f.write('    from .element import Element\n')
@@ -116,10 +116,10 @@ with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('class Tailwind:\n')
     f.write('class Tailwind:\n')
     f.write('\n')
     f.write('\n')
     f.write("    def __init__(self, _element: Optional['Element'] = None) -> None:\n")
     f.write("    def __init__(self, _element: Optional['Element'] = None) -> None:\n")
-    f.write('        self.element = _element or PseudoElement()\n')
+    f.write('        self.element: Union[PseudoElement, Element] = PseudoElement() if _element is None else _element\n')
     f.write('\n')
     f.write('\n')
     f.write('    @overload\n')
     f.write('    @overload\n')
-    f.write('    def __call__(self, Tailwind) -> Tailwind:\n')
+    f.write('    def __call__(self, tailwind: Tailwind) -> Tailwind:\n')
     f.write('        ...\n')
     f.write('        ...\n')
     f.write('\n')
     f.write('\n')
     f.write('    @overload\n')
     f.write('    @overload\n')
@@ -127,6 +127,8 @@ with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('        ...\n')
     f.write('        ...\n')
     f.write('\n')
     f.write('\n')
     f.write('    def __call__(self, *args) -> Tailwind:\n')
     f.write('    def __call__(self, *args) -> Tailwind:\n')
+    f.write('        if not args:\n')
+    f.write('           return self\n')
     f.write('        if isinstance(args[0], Tailwind):\n')
     f.write('        if isinstance(args[0], Tailwind):\n')
     f.write('            args[0].apply(self.element)\n')
     f.write('            args[0].apply(self.element)\n')
     f.write('        else:\n')
     f.write('        else:\n')

+ 1 - 1
fly.toml

@@ -26,7 +26,7 @@ strategy = "rolling"
   script_checks = []
   script_checks = []
   [services.concurrency]
   [services.concurrency]
     hard_limit = 30
     hard_limit = 30
-    soft_limit = 15
+    soft_limit = 12
     type = "connections"
     type = "connections"
 
 
   [[services.ports]]
   [[services.ports]]

+ 17 - 16
main.py

@@ -23,6 +23,7 @@ from nicegui import ui
 from website import documentation, example_card, svg
 from website import documentation, example_card, svg
 from website.demo import bash_window, browser_window, python_window
 from website.demo import bash_window, browser_window, python_window
 from website.documentation_tools import create_anchor_name, element_demo, generate_class_doc
 from website.documentation_tools import create_anchor_name, element_demo, generate_class_doc
+from website.search import Search
 from website.star import add_star
 from website.star import add_star
 from website.style import example_link, features, heading, link_target, section_heading, side_menu, subtitle, title
 from website.style import example_link, features, heading, link_target, section_heading, side_menu, subtitle, title
 
 
@@ -33,6 +34,7 @@ app.add_middleware(SessionMiddleware, secret_key=os.environ.get('NICEGUI_SECRET_
 
 
 app.add_static_files('/favicon', str(Path(__file__).parent / 'website' / 'favicon'))
 app.add_static_files('/favicon', str(Path(__file__).parent / 'website' / 'favicon'))
 app.add_static_files('/fonts', str(Path(__file__).parent / 'website' / 'fonts'))
 app.add_static_files('/fonts', str(Path(__file__).parent / 'website' / 'fonts'))
+app.add_static_files('/static', str(Path(__file__).parent / 'website' / 'static'))
 
 
 
 
 @app.get('/logo.png')
 @app.get('/logo.png')
@@ -76,9 +78,9 @@ def add_header(menu: Optional[ui.left_drawer] = None) -> None:
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
         if menu:
         if menu:
-            ui.button(on_click=menu.toggle).props('flat color=white icon=menu round').classes('lg:hidden')
+            ui.button(on_click=menu.toggle, icon='menu').props('flat color=white round').classes('lg:hidden')
         with ui.link(target=index_page).classes('row gap-4 items-center no-wrap mr-auto'):
         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.face().classes('w-8 stroke-white stroke-2 max-[550px]:hidden')
             svg.word().classes('w-24')
             svg.word().classes('w-24')
         with ui.button(on_click=lambda: menu.open()).props('flat color=white icon=menu').classes('lg:hidden'):
         with ui.button(on_click=lambda: menu.open()).props('flat color=white icon=menu').classes('lg:hidden'):
             with ui.menu().classes('bg-primary text-white text-lg') as menu:
             with ui.menu().classes('bg-primary text-white text-lg') as menu:
@@ -87,18 +89,20 @@ def add_header(menu: Optional[ui.left_drawer] = None) -> None:
         with ui.row().classes('max-lg:hidden'):
         with ui.row().classes('max-lg:hidden'):
             for title, target in menu_items.items():
             for title, target in menu_items.items():
                 ui.link(title, target).classes(replace='text-lg text-white')
                 ui.link(title, target).classes(replace='text-lg text-white')
-        with ui.link(target='https://discord.gg/TEpFeAaF4f').classes('max-[435px]:hidden').tooltip('Discord'):
+        search = Search()
+        search.create_button()
+        with ui.link(target='https://discord.gg/TEpFeAaF4f').classes('max-[445px]:hidden').tooltip('Discord'):
             svg.discord().classes('fill-white scale-125 m-1')
             svg.discord().classes('fill-white scale-125 m-1')
-        with ui.link(target='https://www.reddit.com/r/nicegui/').classes('max-[385px]:hidden').tooltip('Reddit'):
+        with ui.link(target='https://www.reddit.com/r/nicegui/').classes('max-[395px]:hidden').tooltip('Reddit'):
             svg.reddit().classes('fill-white scale-125 m-1')
             svg.reddit().classes('fill-white scale-125 m-1')
         with ui.link(target='https://github.com/zauberzeug/nicegui/').tooltip('GitHub'):
         with ui.link(target='https://github.com/zauberzeug/nicegui/').tooltip('GitHub'):
             svg.github().classes('fill-white scale-125 m-1')
             svg.github().classes('fill-white scale-125 m-1')
-        add_star().classes('max-[480px]:hidden')
+        add_star().classes('max-[490px]:hidden')
         with ui.row().classes('lg:hidden'):
         with ui.row().classes('lg:hidden'):
-            with ui.button().props('flat color=white icon=more_vert round'):
+            with ui.button(icon='more_vert').props('flat color=white round'):
                 with ui.menu().classes('bg-primary text-white text-lg').props(remove='no-parent-event'):
                 with ui.menu().classes('bg-primary text-white text-lg').props(remove='no-parent-event'):
                     for title, target in menu_items.items():
                     for title, target in menu_items.items():
-                        ui.menu_item(title, on_click=lambda _, target=target: ui.open(target))
+                        ui.menu_item(title, on_click=lambda target=target: ui.open(target))
 
 
 
 
 @ui.page('/')
 @ui.page('/')
@@ -209,7 +213,7 @@ async def index_page(client: Client) -> None:
             features('insights', 'Visualization', [
             features('insights', 'Visualization', [
                 'charts, diagrams and tables',
                 'charts, diagrams and tables',
                 '3D scenes',
                 '3D scenes',
-                'progress bars',
+                'straight-forward data binding',
                 'built-in timer for data refresh',
                 'built-in timer for data refresh',
             ])
             ])
             features('brush', 'Styling', [
             features('brush', 'Styling', [
@@ -221,7 +225,7 @@ async def index_page(client: Client) -> None:
             features('source', 'Coding', [
             features('source', 'Coding', [
                 'routing for multiple pages',
                 'routing for multiple pages',
                 'auto-reload on code change',
                 'auto-reload on code change',
-                'straight-forward data binding',
+                'persistent user sessions',
                 'Jupyter notebook compatibility',
                 'Jupyter notebook compatibility',
             ])
             ])
             features('anchor', 'Foundation', [
             features('anchor', 'Foundation', [
@@ -283,6 +287,7 @@ async def index_page(client: Client) -> None:
             example_link('Chat with AI', 'a simple chat app with AI')
             example_link('Chat with AI', 'a simple chat app with AI')
             example_link('SQLite Database', 'CRUD operations on a SQLite database')
             example_link('SQLite Database', 'CRUD operations on a SQLite database')
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
+            example_link('Lightbox', 'A thumbnail gallery where each image can be clicked to enlarge')
 
 
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
         link_target('why')
         link_target('why')
@@ -330,17 +335,13 @@ def documentation_page() -> None:
     ui.add_head_html('<style>html {scroll-behavior: auto;}</style>')
     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'):
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
         section_heading('Reference, Demos and more', '*NiceGUI* Documentation')
         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')
         documentation.create_full()
         documentation.create_full()
 
 
 
 
 @ui.page('/documentation/{name}')
 @ui.page('/documentation/{name}')
 async def documentation_page_more(name: str, client: Client) -> None:
 async def documentation_page_more(name: str, client: Client) -> None:
-    if not hasattr(ui, name):
-        name = name.replace('_', '')  # NOTE: "AG Grid" leads to anchor name "ag_grid", but class is `ui.aggrid`
+    if name == 'ag_grid':
+        name = 'aggrid'  # NOTE: "AG Grid" leads to anchor name "ag_grid", but class is `ui.aggrid`
     module = importlib.import_module(f'website.more_documentation.{name}_documentation')
     module = importlib.import_module(f'website.more_documentation.{name}_documentation')
     more = getattr(module, 'more', None)
     more = getattr(module, 'more', None)
     if hasattr(ui, name):
     if hasattr(ui, name):
@@ -355,7 +356,7 @@ async def documentation_page_more(name: str, client: Client) -> None:
     with side_menu() as menu:
     with side_menu() as menu:
         ui.markdown(f'[← back](/documentation#{create_anchor_name(back_link_target)})').classes('bold-links')
         ui.markdown(f'[← back](/documentation#{create_anchor_name(back_link_target)})').classes('bold-links')
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
-        section_heading('Documentation', f'ui.*{name}*')
+        section_heading('Documentation', f'ui.*{name}*' if hasattr(ui, name) else f'*{name.replace("_", " ").title()}*')
         with menu:
         with menu:
             ui.markdown('**Demos**' if more else '**Demo**').classes('mt-4')
             ui.markdown('**Demos**' if more else '**Demo**').classes('mt-4')
         element_demo(api)(getattr(module, 'main_demo'))
         element_demo(api)(getattr(module, 'main_demo'))

+ 2 - 1
nicegui/__init__.py

@@ -3,9 +3,10 @@ try:
 except ModuleNotFoundError:
 except ModuleNotFoundError:
     import importlib_metadata
     import importlib_metadata
 
 
-__version__ = importlib_metadata.version('nicegui')
+__version__: str = importlib_metadata.version('nicegui')
 
 
 from . import elements, globals, ui
 from . import elements, globals, ui
+from .api_router import APIRouter
 from .client import Client
 from .client import Client
 from .nicegui import app
 from .nicegui import app
 from .tailwind import Tailwind
 from .tailwind import Tailwind

+ 44 - 0
nicegui/api_router.py

@@ -0,0 +1,44 @@
+from pathlib import Path
+from typing import Callable, Optional, Union
+
+import fastapi
+
+from .page import page as ui_page
+
+
+class APIRouter(fastapi.APIRouter):
+
+    def page(self,
+             path: str, *,
+             title: Optional[str] = None,
+             viewport: Optional[str] = None,
+             favicon: Optional[Union[str, Path]] = None,
+             dark: Optional[bool] = ...,
+             response_timeout: float = 3.0,
+             **kwargs,
+             ) -> Callable:
+        """Page
+
+        Creates a new page at the given route.
+        Each user will see a new instance of the page.
+        This means it is private to the user and not shared with others
+        (as it is done `when placing elements outside of a page decorator <https://nicegui.io/documentation#auto-index_page>`_).
+
+        :param path: route of the new page (path must start with '/')
+        :param title: optional page title
+        :param viewport: optional viewport meta tag content
+        :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
+        """
+        return ui_page(
+            path,
+            title=title,
+            viewport=viewport,
+            favicon=favicon,
+            dark=dark,
+            response_timeout=response_timeout,
+            api_router=self,
+            **kwargs
+        )

+ 90 - 7
nicegui/app.py

@@ -1,10 +1,18 @@
-from typing import Awaitable, Callable, Union
+import hashlib
+from pathlib import Path
+from typing import Awaitable, Callable, Optional, Union
 
 
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
+from fastapi.responses import FileResponse, StreamingResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 
 
-from . import globals
+from . import globals, helpers
 from .native import Native
 from .native import Native
+from .storage import Storage
+
+
+def hash_file_path(path: Path) -> str:
+    return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:32]
 
 
 
 
 class App(FastAPI):
 class App(FastAPI):
@@ -12,6 +20,7 @@ class App(FastAPI):
     def __init__(self, **kwargs) -> None:
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
         self.native = Native()
         self.native = Native()
+        self.storage = Storage()
 
 
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
         """Called every time a new client connects to NiceGUI.
         """Called every time a new client connects to NiceGUI.
@@ -43,7 +52,7 @@ class App(FastAPI):
         """
         """
         globals.shutdown_handlers.append(handler)
         globals.shutdown_handlers.append(handler)
 
 
-    def on_exception(self, handler: Union[Callable, Awaitable]) -> None:
+    def on_exception(self, handler: Callable) -> None:
         """Called when an exception occurs.
         """Called when an exception occurs.
 
 
         The callback has an optional parameter of `Exception`.
         The callback has an optional parameter of `Exception`.
@@ -60,20 +69,94 @@ class App(FastAPI):
             raise Exception('calling shutdown() is not supported when auto-reload is enabled')
             raise Exception('calling shutdown() is not supported when auto-reload is enabled')
         globals.server.should_exit = True
         globals.server.should_exit = True
 
 
-    def add_static_files(self, url_path: str, local_directory: str) -> None:
-        """Add static files.
+    def add_static_files(self, url_path: str, local_directory: Union[str, Path]) -> None:
+        """Add a directory of static files.
 
 
         `add_static_files()` makes a local directory available at the specified endpoint, e.g. `'/static'`.
         `add_static_files()` makes a local directory available at the specified endpoint, e.g. `'/static'`.
         This is useful for providing local data like images to the frontend.
         This is useful for providing local data like images to the frontend.
         Otherwise the browser would not be able to access the files.
         Otherwise the browser would not be able to access the files.
         Do only put non-security-critical files in there, as they are accessible to everyone.
         Do only put non-security-critical files in there, as they are accessible to everyone.
 
 
+        To make a single file accessible, you can use `add_static_file()`.
+        For media files which should be streamed, you can use `add_media_files()` or `add_media_file()` instead.
+
         :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
         :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
         :param local_directory: local folder with files to serve as static content
         :param local_directory: local folder with files to serve as static content
         """
         """
         if url_path == '/':
         if url_path == '/':
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
-        globals.app.mount(url_path, StaticFiles(directory=local_directory))
+        globals.app.mount(url_path, StaticFiles(directory=str(local_directory)))
+
+    def add_static_file(self, *, local_file: Union[str, Path], url_path: Optional[str] = None) -> str:
+        """Add a single static file.
+
+        Allows a local file to be accessed online with enabled caching.
+        If `url_path` is not specified, a path will be generated.
+
+        To make a whole folder of files accessible, use `add_static_files()` instead.
+        For media files which should be streamed, you can use `add_media_files()` or `add_media_file()` instead.
+
+        :param local_file: local file to serve as static content
+        :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
+        :return: URL path which can be used to access the file
+        """
+        file = Path(local_file)
+        if not file.is_file():
+            raise ValueError(f'File not found: {file}')
+        if url_path is None:
+            url_path = f'/_nicegui/auto/static/{hash_file_path(file)}/{file.name}'
+
+        @self.get(url_path)
+        async def read_item() -> FileResponse:
+            return FileResponse(file, headers={'Cache-Control': 'public, max-age=3600'})
+
+        return url_path
+
+    def add_media_files(self, url_path: str, local_directory: Union[str, Path]) -> None:
+        """Add directory of media files.
+
+        `add_media_files()` allows a local files to be streamed from a specified endpoint, e.g. `'/media'`.
+        This should be used for media files to support proper streaming.
+        Otherwise the browser would not be able to access and load the the files incrementally or jump to different positions in the stream.
+        Do only put non-security-critical files in there, as they are accessible to everyone.
+
+        To make a single file accessible via streaming, you can use `add_media_file()`.
+        For small static files, you can use `add_static_files()` or `add_static_file()` instead.
+
+        :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
+        :param local_directory: local folder with files to serve as media content
+        """
+        @self.get(url_path + '/{filename:path}')
+        async def read_item(request: Request, filename: str) -> StreamingResponse:
+            filepath = Path(local_directory) / filename
+            if not filepath.is_file():
+                return {'detail': 'Not Found'}, 404
+            return helpers.get_streaming_response(filepath, request)
+
+    def add_media_file(self, *, local_file: Union[str, Path], url_path: Optional[str] = None) -> str:
+        """Add a single media file.
+
+        Allows a local file to be streamed.
+        If `url_path` is not specified, a path will be generated.
+
+        To make a whole folder of media files accessible via streaming, use `add_media_files()` instead.
+        For small static files, you can use `add_static_files()` or `add_static_file()` instead.
+
+        :param local_file: local file to serve as media content
+        :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
+        :return: URL path which can be used to access the file
+        """
+        file = Path(local_file)
+        if not file.is_file():
+            raise ValueError(f'File not found: {local_file}')
+        if url_path is None:
+            url_path = f'/_nicegui/auto/media/{hash_file_path(file)}/{file.name}'
+
+        @self.get(url_path)
+        async def read_item(request: Request) -> StreamingResponse:
+            return helpers.get_streaming_response(file, request)
+
+        return url_path
 
 
     def remove_route(self, path: str) -> None:
     def remove_route(self, path: str) -> None:
         """Remove routes with the given path."""
         """Remove routes with the given path."""

+ 3 - 1
nicegui/background_tasks.py

@@ -21,7 +21,9 @@ def create(coroutine: Awaitable[T], *, name: str = 'unnamed task') -> 'asyncio.T
     Also a reference to the task is kept until it is done, so that the task is not garbage collected mid-execution.
     Also a reference to the task is kept until it is done, so that the task is not garbage collected mid-execution.
     See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.
     See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.
     """
     """
-    task = globals.loop.create_task(coroutine, name=name) if name_supported else globals.loop.create_task(coroutine)
+    assert globals.loop is not None
+    task: asyncio.Task = \
+        globals.loop.create_task(coroutine, name=name) if name_supported else globals.loop.create_task(coroutine)
     task.add_done_callback(_handle_task_result)
     task.add_done_callback(_handle_task_result)
     running_tasks.add(task)
     running_tasks.add(task)
     task.add_done_callback(running_tasks.discard)
     task.add_done_callback(running_tasks.discard)

+ 27 - 17
nicegui/binding.py

@@ -2,39 +2,48 @@ import asyncio
 import logging
 import logging
 import time
 import time
 from collections import defaultdict
 from collections import defaultdict
+from collections.abc import Mapping
 from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Type, Union
 from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Type, Union
 
 
 from . import globals
 from . import globals
 
 
 bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
 bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
 bindable_properties: Dict[Tuple[int, str], Any] = {}
 bindable_properties: Dict[Tuple[int, str], Any] = {}
-active_links: List[Tuple[Any, str, Any, str, Callable]] = []
+active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
 
 
 
 
-def get_attribute(obj: Union[object, Dict], name: str) -> Any:
-    if isinstance(obj, dict):
+def has_attribute(obj: Union[object, Mapping], name: str) -> Any:
+    if isinstance(obj, Mapping):
+        return name in obj
+    else:
+        return hasattr(obj, name)
+
+
+def get_attribute(obj: Union[object, Mapping], name: str) -> Any:
+    if isinstance(obj, Mapping):
         return obj[name]
         return obj[name]
     else:
     else:
         return getattr(obj, name)
         return getattr(obj, name)
 
 
 
 
-def set_attribute(obj: Union[object, Dict], name: str, value: Any) -> None:
+def set_attribute(obj: Union[object, Mapping], name: str, value: Any) -> None:
     if isinstance(obj, dict):
     if isinstance(obj, dict):
         obj[name] = value
         obj[name] = value
     else:
     else:
         setattr(obj, name, value)
         setattr(obj, name, value)
 
 
 
 
-async def loop():
+async def loop() -> None:
     while True:
     while True:
         visited: Set[Tuple[int, str]] = set()
         visited: Set[Tuple[int, str]] = set()
         t = time.time()
         t = time.time()
         for link in active_links:
         for link in active_links:
             (source_obj, source_name, target_obj, target_name, transform) = link
             (source_obj, source_name, target_obj, target_name, transform) = link
-            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)
+            if has_attribute(source_obj, source_name):
+                value = transform(get_attribute(source_obj, source_name))
+                if not has_attribute(target_obj, target_name) or 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
             del link, source_obj, target_obj
         if time.time() - t > 0.01:
         if time.time() - t > 0.01:
             logging.warning(f'binding propagation for {len(active_links)} active links took {time.time() - t:.3f} s')
             logging.warning(f'binding propagation for {len(active_links)} active links took {time.time() - t:.3f} s')
@@ -48,20 +57,21 @@ 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), []):
     for _, target_obj, target_name, transform in bindings.get((id(source_obj), source_name), []):
         if (id(target_obj), target_name) in visited:
         if (id(target_obj), target_name) in visited:
             continue
             continue
-        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)
+        if has_attribute(source_obj, source_name):
+            target_value = transform(get_attribute(source_obj, source_name))
+            if not has_attribute(target_obj, target_name) or get_attribute(target_obj, target_name) != target_value:
+                set_attribute(target_obj, target_name, target_value)
+                propagate(target_obj, target_name, visited)
 
 
 
 
-def bind_to(self_obj: Any, self_name: str, other_obj: Any, other_name: str, forward: Callable) -> None:
+def bind_to(self_obj: Any, self_name: str, other_obj: Any, other_name: str, forward: Callable[[Any], Any]) -> None:
     bindings[(id(self_obj), self_name)].append((self_obj, other_obj, other_name, forward))
     bindings[(id(self_obj), self_name)].append((self_obj, other_obj, other_name, forward))
     if (id(self_obj), self_name) not in bindable_properties:
     if (id(self_obj), self_name) not in bindable_properties:
         active_links.append((self_obj, self_name, other_obj, other_name, forward))
         active_links.append((self_obj, self_name, other_obj, other_name, forward))
     propagate(self_obj, self_name)
     propagate(self_obj, self_name)
 
 
 
 
-def bind_from(self_obj: Any, self_name: str, other_obj: Any, other_name: str, backward: Callable) -> None:
+def bind_from(self_obj: Any, self_name: str, other_obj: Any, other_name: str, backward: Callable[[Any], Any]) -> None:
     bindings[(id(other_obj), other_name)].append((other_obj, self_obj, self_name, backward))
     bindings[(id(other_obj), other_name)].append((other_obj, self_obj, self_name, backward))
     if (id(other_obj), other_name) not in bindable_properties:
     if (id(other_obj), other_name) not in bindable_properties:
         active_links.append((other_obj, other_name, self_obj, self_name, backward))
         active_links.append((other_obj, other_name, self_obj, self_name, backward))
@@ -69,14 +79,14 @@ def bind_from(self_obj: Any, self_name: str, other_obj: Any, other_name: str, ba
 
 
 
 
 def bind(self_obj: Any, self_name: str, other_obj: Any, other_name: str, *,
 def bind(self_obj: Any, self_name: str, other_obj: Any, other_name: str, *,
-         forward: Callable = lambda x: x, backward: Callable = lambda x: x) -> None:
+         forward: Callable[[Any], Any] = lambda x: x, backward: Callable[[Any], Any] = lambda x: x) -> None:
     bind_from(self_obj, self_name, other_obj, other_name, backward=backward)
     bind_from(self_obj, self_name, other_obj, other_name, backward=backward)
     bind_to(self_obj, self_name, other_obj, other_name, forward=forward)
     bind_to(self_obj, self_name, other_obj, other_name, forward=forward)
 
 
 
 
 class BindableProperty:
 class BindableProperty:
 
 
-    def __init__(self, on_change: Optional[Callable] = None) -> None:
+    def __init__(self, on_change: Optional[Callable[..., Any]] = None) -> None:
         self.on_change = on_change
         self.on_change = on_change
 
 
     def __set_name__(self, _, name: str) -> None:
     def __set_name__(self, _, name: str) -> None:

+ 7 - 7
nicegui/client.py

@@ -40,15 +40,15 @@ class Client:
                 with Element('q-page'):
                 with Element('q-page'):
                     self.content = Element('div').classes('nicegui-content')
                     self.content = Element('div').classes('nicegui-content')
 
 
-        self.waiting_javascript_commands: Dict[str, str] = {}
+        self.waiting_javascript_commands: Dict[str, Any] = {}
 
 
         self.head_html = ''
         self.head_html = ''
         self.body_html = ''
         self.body_html = ''
 
 
         self.page = page
         self.page = page
 
 
-        self.connect_handlers: List[Union[Callable, Awaitable]] = []
-        self.disconnect_handlers: List[Union[Callable, Awaitable]] = []
+        self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
+        self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
 
 
     @property
     @property
     def ip(self) -> Optional[str]:
     def ip(self) -> Optional[str]:
@@ -108,7 +108,7 @@ class Client:
         self.is_waiting_for_disconnect = False
         self.is_waiting_for_disconnect = False
 
 
     async def run_javascript(self, code: str, *,
     async def run_javascript(self, code: str, *,
-                             respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
+                             respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[Any]:
         """Execute JavaScript on the client.
         """Execute JavaScript on the client.
 
 
         The client connection must be established before this method is called.
         The client connection must be established before this method is called.
@@ -130,7 +130,7 @@ class Client:
             await asyncio.sleep(check_interval)
             await asyncio.sleep(check_interval)
         return self.waiting_javascript_commands.pop(request_id)
         return self.waiting_javascript_commands.pop(request_id)
 
 
-    def open(self, target: Union[Callable, str]) -> None:
+    def open(self, target: Union[Callable[..., Any], str]) -> None:
         """Open a new page in the client."""
         """Open a new page in the client."""
         path = target if isinstance(target, str) else globals.page_routes[target]
         path = target if isinstance(target, str) else globals.page_routes[target]
         outbox.enqueue_message('open', path, self.id)
         outbox.enqueue_message('open', path, self.id)
@@ -139,10 +139,10 @@ class Client:
         """Download a file from the given URL."""
         """Download a file from the given URL."""
         outbox.enqueue_message('download', {'url': url, 'filename': filename}, self.id)
         outbox.enqueue_message('download', {'url': url, 'filename': filename}, self.id)
 
 
-    def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
+    def on_connect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
         """Register a callback to be called when the client connects."""
         """Register a callback to be called when the client connects."""
         self.connect_handlers.append(handler)
         self.connect_handlers.append(handler)
 
 
-    def on_disconnect(self, handler: Union[Callable, Awaitable]) -> None:
+    def on_disconnect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
         """Register a callback to be called when the client disconnects."""
         """Register a callback to be called when the client disconnects."""
         self.disconnect_handlers.append(handler)
         self.disconnect_handlers.append(handler)

+ 0 - 33
nicegui/colors.py

@@ -1,33 +0,0 @@
-from typing import Optional
-
-from typing_extensions import get_args
-
-from .element import Element
-from .tailwind_types.background_color import BackgroundColor
-
-QUASAR_COLORS = {'primary', 'secondary', 'accent', 'dark', 'positive', 'negative', 'info', 'warning'}
-for color in {'red', 'pink', 'purple', 'deep-purple', 'indigo', 'blue', 'light-blue', 'cyan', 'teal', 'green',
-              'light-green', 'lime', 'yellow', 'amber', 'orange', 'deep-orange', 'brown', 'grey', 'blue-grey'}:
-    QUASAR_COLORS.add(color)
-    for i in range(1, 15):
-        QUASAR_COLORS.add(f'{color}-{i}')
-
-TAILWIND_COLORS = get_args(BackgroundColor)
-
-
-def set_text_color(element: Element, color: Optional[str], *, prop_name: str = 'color') -> None:
-    if color in QUASAR_COLORS:
-        element._props[prop_name] = color
-    elif color in TAILWIND_COLORS:
-        element._classes.append(f'text-{color}')
-    elif color is not None:
-        element._style['color'] = color
-
-
-def set_background_color(element: Element, color: Optional[str], *, prop_name: str = 'color') -> None:
-    if color in QUASAR_COLORS:
-        element._props[prop_name] = color
-    elif color in TAILWIND_COLORS:
-        element._classes.append(f'bg-{color}')
-    elif color is not None:
-        element._style['background-color'] = color

+ 3 - 2
nicegui/dependencies.py

@@ -82,6 +82,7 @@ def generate_js_imports(prefix: str) -> str:
     for name, component in js_components.items():
     for name, component in js_components.items():
         if name in globals.excludes:
         if name in globals.excludes:
             continue
             continue
-        result += f'import {{ default as {name} }} from "{prefix}{component.import_path}";\n'
-        result += f'app.component("{name}", {name});\n'
+        var_name = name.replace('-', '_')
+        result += f'import {{ default as {var_name} }} from "{prefix}{component.import_path}";\n'
+        result += f'app.component("{name}", {var_name});\n'
     return result
     return result

+ 18 - 11
nicegui/element.py

@@ -3,13 +3,13 @@ from __future__ import annotations
 import re
 import re
 import warnings
 import warnings
 from copy import deepcopy
 from copy import deepcopy
-from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
 from nicegui import json
 from nicegui import json
 
 
-from . import binding, events, globals, outbox
+from . import binding, events, globals, outbox, storage
 from .elements.mixins.visibility import Visibility
 from .elements.mixins.visibility import Visibility
 from .event_listener import EventListener
 from .event_listener import EventListener
 from .slot import Slot
 from .slot import Slot
@@ -18,7 +18,7 @@ from .tailwind import Tailwind
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .client import Client
     from .client import Client
 
 
-PROPS_PATTERN = re.compile(r'([\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.%:\/]+)))?(?:$|\s)')
+PROPS_PATTERN = re.compile(r'([:\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.%:\/]+)))?(?:$|\s)')
 
 
 
 
 class Element(Visibility):
 class Element(Visibility):
@@ -75,9 +75,14 @@ class Element(Visibility):
     def __exit__(self, *_):
     def __exit__(self, *_):
         self.default_slot.__exit__(*_)
         self.default_slot.__exit__(*_)
 
 
-    def _collect_slot_dict(self) -> Dict[str, List[int]]:
+    def __iter__(self) -> Iterator[Element]:
+        for slot in self.slots.values():
+            for child in slot:
+                yield child
+
+    def _collect_slot_dict(self) -> Dict[str, Any]:
         return {
         return {
-            name: {'template': slot.template, 'ids': [child.id for child in slot.children]}
+            name: {'template': slot.template, 'ids': [child.id for child in slot]}
             for name, slot in self.slots.items()
             for name, slot in self.slots.items()
         }
         }
 
 
@@ -197,7 +202,7 @@ class Element(Visibility):
 
 
     def on(self,
     def on(self,
            type: str,
            type: str,
-           handler: Optional[Callable],
+           handler: Optional[Callable[..., Any]] = None,
            args: Optional[List[str]] = None, *,
            args: Optional[List[str]] = None, *,
            throttle: float = 0.0,
            throttle: float = 0.0,
            leading_events: bool = True,
            leading_events: bool = True,
@@ -225,6 +230,7 @@ class Element(Visibility):
                 throttle=throttle,
                 throttle=throttle,
                 leading_events=leading_events,
                 leading_events=leading_events,
                 trailing_events=trailing_events,
                 trailing_events=trailing_events,
+                request=storage.request_contextvar.get(),
             )
             )
             self._event_listeners[listener.id] = listener
             self._event_listeners[listener.id] = listener
             self.update()
             self.update()
@@ -232,6 +238,7 @@ class Element(Visibility):
 
 
     def _handle_event(self, msg: Dict) -> None:
     def _handle_event(self, msg: Dict) -> None:
         listener = self._event_listeners[msg['listener_id']]
         listener = self._event_listeners[msg['listener_id']]
+        storage.request_contextvar.set(listener.request)
         events.handle_event(listener.handler, msg, sender=self)
         events.handle_event(listener.handler, msg, sender=self)
 
 
     def update(self) -> None:
     def update(self) -> None:
@@ -251,9 +258,8 @@ class Element(Visibility):
 
 
     def _collect_descendant_ids(self) -> List[int]:
     def _collect_descendant_ids(self) -> List[int]:
         ids: List[int] = [self.id]
         ids: List[int] = [self.id]
-        for slot in self.slots.values():
-            for child in slot.children:
-                ids.extend(child._collect_descendant_ids())
+        for child in self:
+            ids.extend(child._collect_descendant_ids())
         return ids
         return ids
 
 
     def clear(self) -> None:
     def clear(self) -> None:
@@ -272,6 +278,7 @@ class Element(Visibility):
         :param target_container: container to move the element to (default: the parent container)
         :param target_container: container to move the element to (default: the parent container)
         :param target_index: index within the target slot (default: append to the end)
         :param target_index: index within the target slot (default: append to the end)
         """
         """
+        assert self.parent_slot is not None
         self.parent_slot.children.remove(self)
         self.parent_slot.children.remove(self)
         self.parent_slot.parent.update()
         self.parent_slot.parent.update()
         target_container = target_container or self.parent_slot.parent
         target_container = target_container or self.parent_slot.parent
@@ -286,12 +293,12 @@ class Element(Visibility):
         :param element: either the element instance or its ID
         :param element: either the element instance or its ID
         """
         """
         if isinstance(element, int):
         if isinstance(element, int):
-            children = [child for slot in self.slots.values() for child in slot.children]
+            children = list(self)
             element = children[element]
             element = children[element]
         binding.remove([element], Element)
         binding.remove([element], Element)
         del self.client.elements[element.id]
         del self.client.elements[element.id]
         for slot in self.slots.values():
         for slot in self.slots.values():
-            slot.children[:] = [e for e in slot.children if e.id != element.id]
+            slot.children[:] = [e for e in slot if e.id != element.id]
         self.update()
         self.update()
 
 
     def delete(self) -> None:
     def delete(self) -> None:

+ 3 - 2
nicegui/elements/aggrid.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, cast
 
 
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..element import Element
 from ..element import Element
@@ -67,7 +67,8 @@ class AgGrid(Element):
 
 
         :return: list of selected row data
         :return: list of selected row data
         """
         """
-        return await run_javascript(f'return getElement({self.id}).gridOptions.api.getSelectedRows();')
+        result = await run_javascript(f'return getElement({self.id}).gridOptions.api.getSelectedRows();')
+        return cast(List[Dict], result)
 
 
     async def get_selected_row(self) -> Optional[Dict]:
     async def get_selected_row(self) -> Optional[Dict]:
         """Get the single currently selected row.
         """Get the single currently selected row.

+ 17 - 1
nicegui/elements/audio.js

@@ -1,9 +1,25 @@
 export default {
 export default {
-  template: `<audio :controls="controls" :autoplay="autoplay" :muted="muted" :src="src" />`,
+  template: `<audio :controls="controls" :autoplay="autoplay" :muted="muted" :src="computed_src" />`,
   props: {
   props: {
     controls: Boolean,
     controls: Boolean,
     autoplay: Boolean,
     autoplay: Boolean,
     muted: Boolean,
     muted: Boolean,
     src: String,
     src: String,
   },
   },
+  data: function () {
+    return {
+      computed_src: undefined,
+    };
+  },
+  mounted() {
+    setTimeout(() => this.compute_src(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_src();
+  },
+  methods: {
+    compute_src() {
+      this.computed_src = (this.src.startsWith("/") ? window.path_prefix : "") + this.src;
+    },
+  },
 };
 };

+ 7 - 2
nicegui/elements/audio.py

@@ -1,5 +1,8 @@
 import warnings
 import warnings
+from pathlib import Path
+from typing import Union
 
 
+from .. import globals
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..element import Element
 from ..element import Element
 
 
@@ -8,7 +11,7 @@ register_component('audio', __file__, 'audio.js')
 
 
 class Audio(Element):
 class Audio(Element):
 
 
-    def __init__(self, src: str, *,
+    def __init__(self, src: Union[str, Path], *,
                  controls: bool = True,
                  controls: bool = True,
                  autoplay: bool = False,
                  autoplay: bool = False,
                  muted: bool = False,
                  muted: bool = False,
@@ -17,7 +20,7 @@ class Audio(Element):
                  ) -> None:
                  ) -> None:
         """Audio
         """Audio
 
 
-        :param src: URL of the audio source
+        :param src: URL or local file path of the audio source
         :param controls: whether to show the audio controls, like play, pause, and volume (default: `True`)
         :param controls: whether to show the audio controls, like play, pause, and volume (default: `True`)
         :param autoplay: whether to start playing the audio automatically (default: `False`)
         :param autoplay: whether to start playing the audio automatically (default: `False`)
         :param muted: whether the audio should be initially muted (default: `False`)
         :param muted: whether the audio should be initially muted (default: `False`)
@@ -27,6 +30,8 @@ class Audio(Element):
         for a list of events you can subscribe to using the generic event subscription `on()`.
         for a list of events you can subscribe to using the generic event subscription `on()`.
         """
         """
         super().__init__('audio')
         super().__init__('audio')
+        if Path(src).is_file():
+            src = globals.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['src'] = src
         self._props['controls'] = controls
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay
         self._props['autoplay'] = autoplay

+ 4 - 7
nicegui/elements/avatar.py

@@ -1,10 +1,10 @@
 from typing import Optional
 from typing import Optional
 
 
-from ..colors import set_background_color, set_text_color
-from ..element import Element
+from .mixins.color_elements import BackgroundColorElement, TextColorElement
 
 
 
 
-class Avatar(Element):
+class Avatar(BackgroundColorElement, TextColorElement):
+    TEXT_COLOR_PROP = 'text-color'
 
 
     def __init__(self,
     def __init__(self,
                  icon: Optional[str] = None, *,
                  icon: Optional[str] = None, *,
@@ -28,16 +28,13 @@ class Avatar(Element):
         :param square: removes border-radius so borders are squared (default: False)
         :param square: removes border-radius so borders are squared (default: False)
         :param rounded: applies a small standard border-radius for a squared shape of the component (default: False)
         :param rounded: applies a small standard border-radius for a squared shape of the component (default: False)
         """
         """
-        super().__init__('q-avatar')
+        super().__init__(tag='q-avatar', background_color=color, text_color=text_color)
 
 
         if icon is not None:
         if icon is not None:
             self._props['icon'] = icon
             self._props['icon'] = icon
         self._props['square'] = square
         self._props['square'] = square
         self._props['rounded'] = rounded
         self._props['rounded'] = rounded
 
 
-        set_background_color(self, color)
-        set_text_color(self, text_color, prop_name='text-color')
-
         if size is not None:
         if size is not None:
             self._props['size'] = size
             self._props['size'] = size
 
 

+ 4 - 5
nicegui/elements/badge.py

@@ -1,10 +1,11 @@
 from typing import Optional
 from typing import Optional
 
 
-from ..colors import set_background_color, set_text_color
+from .mixins.color_elements import BackgroundColorElement, TextColorElement
 from .mixins.text_element import TextElement
 from .mixins.text_element import TextElement
 
 
 
 
-class Badge(TextElement):
+class Badge(TextElement, BackgroundColorElement, TextColorElement):
+    TEXT_COLOR_PROP = 'text-color'
 
 
     def __init__(self,
     def __init__(self,
                  text: str = '', *,
                  text: str = '', *,
@@ -21,7 +22,5 @@ class Badge(TextElement):
         :param text_color: text color (either a Quasar, Tailwind, or CSS color or `None`, default: `None`)
         :param text_color: text color (either a Quasar, Tailwind, or CSS color or `None`, default: `None`)
         :param outline: use 'outline' design (colored text and borders only) (default: False)
         :param outline: use 'outline' design (colored text and borders only) (default: False)
         """
         """
-        super().__init__(tag='q-badge', text=text)
-        set_background_color(self, color)
-        set_text_color(self, text_color, prop_name='text-color')
+        super().__init__(tag='q-badge', text=text, text_color=text_color, background_color=color)
         self._props['outline'] = outline
         self._props['outline'] = outline

+ 10 - 6
nicegui/elements/button.py

@@ -1,18 +1,19 @@
 import asyncio
 import asyncio
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 
-from ..colors import set_background_color
 from ..events import ClickEventArguments, handle_event
 from ..events import ClickEventArguments, handle_event
+from .mixins.color_elements import BackgroundColorElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
 from .mixins.text_element import TextElement
 
 
 
 
-class Button(TextElement, DisableableElement):
+class Button(TextElement, DisableableElement, BackgroundColorElement):
 
 
     def __init__(self,
     def __init__(self,
                  text: str = '', *,
                  text: str = '', *,
-                 on_click: Optional[Callable] = None,
+                 on_click: Optional[Callable[..., Any]] = None,
                  color: Optional[str] = 'primary',
                  color: Optional[str] = 'primary',
+                 icon: Optional[str] = None,
                  ) -> None:
                  ) -> None:
         """Button
         """Button
 
 
@@ -26,9 +27,12 @@ class Button(TextElement, DisableableElement):
         :param text: the label of the button
         :param text: the label of the button
         :param on_click: callback which is invoked when button is pressed
         :param on_click: callback which is invoked when button is pressed
         :param color: the color of the button (either a Quasar, Tailwind, or CSS color or `None`, default: 'primary')
         :param color: the color of the button (either a Quasar, Tailwind, or CSS color or `None`, default: 'primary')
+        :param icon: the name of an icon to be displayed on the button (default: `None`)
         """
         """
-        super().__init__(tag='q-btn', text=text)
-        set_background_color(self, color)
+        super().__init__(tag='q-btn', text=text, background_color=color)
+
+        if icon:
+            self._props['icon'] = icon
 
 
         if on_click:
         if on_click:
             self.on('click', lambda _: handle_event(on_click, ClickEventArguments(sender=self, client=self.client)))
             self.on('click', lambda _: handle_event(on_click, ClickEventArguments(sender=self, client=self.client)))

+ 2 - 2
nicegui/elements/checkbox.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
 from .mixins.text_element import TextElement
@@ -7,7 +7,7 @@ from .mixins.value_element import ValueElement
 
 
 class Checkbox(TextElement, ValueElement, DisableableElement):
 class Checkbox(TextElement, ValueElement, DisableableElement):
 
 
-    def __init__(self, text: str = '', *, value: bool = False, on_change: Optional[Callable] = None) -> None:
+    def __init__(self, text: str = '', *, value: bool = False, on_change: Optional[Callable[..., Any]] = None) -> None:
         """Checkbox
         """Checkbox
 
 
         :param text: the label to display next to the checkbox
         :param text: the label to display next to the checkbox

+ 5 - 1
nicegui/elements/choice_element.py

@@ -6,7 +6,11 @@ from .mixins.value_element import ValueElement
 class ChoiceElement(ValueElement):
 class ChoiceElement(ValueElement):
 
 
     def __init__(self, *,
     def __init__(self, *,
-                 tag: str, options: Union[List, Dict], value: Any, on_change: Optional[Callable] = None) -> None:
+                 tag: str,
+                 options: Union[List, Dict],
+                 value: Any,
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         self.options = options
         self.options = options
         self._values: List[str] = []
         self._values: List[str] = []
         self._labels: List[str] = []
         self._labels: List[str] = []

+ 9 - 5
nicegui/elements/color_input.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 
 from nicegui import ui
 from nicegui import ui
 
 
@@ -10,8 +10,12 @@ from .mixins.value_element import ValueElement
 class ColorInput(ValueElement, DisableableElement):
 class ColorInput(ValueElement, DisableableElement):
     LOOPBACK = False
     LOOPBACK = False
 
 
-    def __init__(self, label: Optional[str] = None, *,
-                 placeholder: Optional[str] = None, value: str = '', on_change: Optional[Callable] = None) -> None:
+    def __init__(self,
+                 label: Optional[str] = None, *,
+                 placeholder: Optional[str] = None,
+                 value: str = '',
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Color Input
         """Color Input
 
 
         :param label: displayed label for the color input
         :param label: displayed label for the color input
@@ -27,8 +31,8 @@ class ColorInput(ValueElement, DisableableElement):
 
 
         with self.add_slot('append'):
         with self.add_slot('append'):
             self.picker = ColorPicker(on_pick=lambda e: self.set_value(e.color))
             self.picker = ColorPicker(on_pick=lambda e: self.set_value(e.color))
-            self.button = ui.button(on_click=self.open_picker) \
-                .props('icon=colorize flat round', remove='color').classes('cursor-pointer')
+            self.button = ui.button(on_click=self.open_picker, icon='colorize') \
+                .props('flat round', remove='color').classes('cursor-pointer')
 
 
     def open_picker(self) -> None:
     def open_picker(self) -> None:
         """Open the color picker"""
         """Open the color picker"""

+ 2 - 2
nicegui/elements/color_picker.py

@@ -1,4 +1,4 @@
-from typing import Callable, Dict
+from typing import Any, Callable, Dict
 
 
 from nicegui.events import ColorPickEventArguments, handle_event
 from nicegui.events import ColorPickEventArguments, handle_event
 
 
@@ -8,7 +8,7 @@ from .menu import Menu
 
 
 class ColorPicker(Menu):
 class ColorPicker(Menu):
 
 
-    def __init__(self, *, on_pick: Callable, value: bool = False) -> None:
+    def __init__(self, *, on_pick: Callable[..., Any], value: bool = False) -> None:
         """Color Picker
         """Color Picker
 
 
         :param on_pick: callback to execute when a color is picked
         :param on_pick: callback to execute when a color is picked

+ 2 - 2
nicegui/elements/date.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 from .mixins.value_element import ValueElement
@@ -11,7 +11,7 @@ class Date(ValueElement, DisableableElement):
                  value: Optional[str] = None,
                  value: Optional[str] = None,
                  *,
                  *,
                  mask: str = 'YYYY-MM-DD',
                  mask: str = 'YYYY-MM-DD',
-                 on_change: Optional[Callable] = None) -> None:
+                 on_change: Optional[Callable[..., Any]] = None) -> None:
         """Date Input
         """Date Input
 
 
         This element is based on Quasar's `QDate <https://quasar.dev/vue-components/date>`_ component.
         This element is based on Quasar's `QDate <https://quasar.dev/vue-components/date>`_ component.

+ 3 - 6
nicegui/elements/icon.py

@@ -1,10 +1,9 @@
 from typing import Optional
 from typing import Optional
 
 
-from ..colors import set_text_color
-from ..element import Element
+from .mixins.color_elements import TextColorElement
 
 
 
 
-class Icon(Element):
+class Icon(TextColorElement):
 
 
     def __init__(self,
     def __init__(self,
                  name: str,
                  name: str,
@@ -22,10 +21,8 @@ class Icon(Element):
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
         :param color: icon color (either a Quasar, Tailwind, or CSS color or `None`, default: `None`)
         :param color: icon color (either a Quasar, Tailwind, or CSS color or `None`, default: `None`)
         """
         """
-        super().__init__('q-icon')
+        super().__init__(tag='q-icon', text_color=color)
         self._props['name'] = name
         self._props['name'] = name
 
 
         if size:
         if size:
             self._props['size'] = size
             self._props['size'] = size
-
-        set_text_color(self, color)

+ 28 - 0
nicegui/elements/image.js

@@ -0,0 +1,28 @@
+export default {
+  template: `
+    <q-img v-bind="$attrs" :src="computed_src">
+      <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+        <slot :name="slot" v-bind="slotProps || {}" />
+      </template>
+    </q-img>
+  `,
+  props: {
+    src: String,
+  },
+  data: function () {
+    return {
+      computed_src: undefined,
+    };
+  },
+  mounted() {
+    setTimeout(() => this.compute_src(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_src();
+  },
+  methods: {
+    compute_src() {
+      this.computed_src = (this.src.startsWith("/") ? window.path_prefix : "") + this.src;
+    },
+  },
+};

+ 10 - 3
nicegui/elements/image.py

@@ -1,13 +1,20 @@
+from pathlib import Path
+from typing import Union
+
+from nicegui.dependencies import register_component
+
 from .mixins.source_element import SourceElement
 from .mixins.source_element import SourceElement
 
 
+register_component('image', __file__, 'image.js')
+
 
 
 class Image(SourceElement):
 class Image(SourceElement):
 
 
-    def __init__(self, source: str = '') -> None:
+    def __init__(self, source: Union[str, Path] = '') -> None:
         """Image
         """Image
 
 
         Displays an image.
         Displays an image.
 
 
-        :param source: the source of the image; can be a URL or a base64 string
+        :param source: the source of the image; can be a URL, local file path or a base64 string
         """
         """
-        super().__init__(tag='q-img', source=source)
+        super().__init__(tag='image', source=source)

+ 60 - 0
nicegui/elements/input.js

@@ -0,0 +1,60 @@
+export default {
+  template: `
+    <q-input
+      v-bind="$attrs"
+      v-model="inputValue"
+      :shadow-text="shadowText"
+      @keydown.tab="perform_autocomplete"
+      :list="id + '-datalist'"
+    >
+      <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+        <slot :name="slot" v-bind="slotProps || {}" />
+      </template>
+    </q-input>
+    <datalist v-if="withDatalist" :id="id + '-datalist'">
+      <option v-for="option in autocomplete" :value="option"></option>
+    </datalist>
+  `,
+  props: {
+    id: String,
+    autocomplete: Array,
+    value: String,
+  },
+  data() {
+    return {
+      inputValue: this.value,
+    };
+  },
+  watch: {
+    value(newValue) {
+      this.inputValue = newValue;
+    },
+    inputValue(newValue) {
+      this.$emit("update:value", newValue);
+    },
+  },
+  computed: {
+    shadowText() {
+      if (!this.inputValue) return "";
+      const matchingOption = this.autocomplete.find((option) =>
+        option.toLowerCase().startsWith(this.inputValue.toLowerCase())
+      );
+      return matchingOption ? matchingOption.slice(this.inputValue.length) : "";
+    },
+    withDatalist() {
+      const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
+      return isMobile && this.autocomplete && this.autocomplete.length > 0;
+    },
+  },
+  methods: {
+    updateValue() {
+      this.inputValue = this.value;
+    },
+    perform_autocomplete(e) {
+      if (this.shadowText) {
+        this.inputValue += this.shadowText;
+        e.preventDefault();
+      }
+    },
+  },
+};

+ 17 - 33
nicegui/elements/input.py

@@ -1,11 +1,15 @@
 from typing import Any, Callable, Dict, List, Optional
 from typing import Any, Callable, Dict, List, Optional
 
 
+from ..dependencies import register_component
 from .icon import Icon
 from .icon import Icon
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
-from .mixins.value_element import ValueElement
+from .mixins.validation_element import ValidationElement
 
 
+register_component('nicegui-input', __file__, 'input.js')
 
 
-class Input(ValueElement, DisableableElement):
+
+class Input(ValidationElement, DisableableElement):
+    VALUE_PROP: str = 'value'
     LOOPBACK = False
     LOOPBACK = False
 
 
     def __init__(self,
     def __init__(self,
@@ -14,9 +18,9 @@ class Input(ValueElement, DisableableElement):
                  value: str = '',
                  value: str = '',
                  password: bool = False,
                  password: bool = False,
                  password_toggle_button: bool = False,
                  password_toggle_button: bool = False,
-                 on_change: Optional[Callable] = None,
+                 on_change: Optional[Callable[..., Any]] = None,
                  autocomplete: Optional[List[str]] = None,
                  autocomplete: Optional[List[str]] = None,
-                 validation: Dict[str, Callable] = {}) -> None:
+                 validation: Dict[str, Callable[..., bool]] = {}) -> None:
         """Text Input
         """Text Input
 
 
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.
@@ -35,9 +39,9 @@ class Input(ValueElement, DisableableElement):
         :param password_toggle_button: whether to show a button to toggle the password visibility (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 value changes
         :param on_change: callback to execute when the value changes
         :param autocomplete: optional list of strings for autocompletion
         :param autocomplete: optional list of strings for autocompletion
-        :param validation: dictionary of validation rules, e.g. ``{'Too short!': lambda value: len(value) < 3}``
+        :param validation: dictionary of validation rules, e.g. ``{'Too long!': lambda value: len(value) < 3}``
         """
         """
-        super().__init__(tag='q-input', value=value, on_value_change=on_change)
+        super().__init__(tag='nicegui-input', value=value, on_value_change=on_change, validation=validation)
         if label is not None:
         if label is not None:
             self._props['label'] = label
             self._props['label'] = label
         if placeholder is not None:
         if placeholder is not None:
@@ -52,34 +56,14 @@ class Input(ValueElement, DisableableElement):
                     self.props(f'type={"text" if is_hidden else "password"}')
                     self.props(f'type={"text" if is_hidden else "password"}')
                 icon = Icon('visibility_off').classes('cursor-pointer').on('click', toggle_type)
                 icon = Icon('visibility_off').classes('cursor-pointer').on('click', toggle_type)
 
 
-        self.validation = validation
-
-        if autocomplete:
-            def find_autocompletion() -> Optional[str]:
-                if self.value:
-                    needle = str(self.value).casefold()
-                    for item in autocomplete:
-                        if item.casefold().startswith(needle):
-                            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._props['autocomplete'] = autocomplete or []
 
 
-            self.on('keyup', autocomplete_input)
-            self.on('keydown.tab', complete_input)
+    def set_autocomplete(self, autocomplete: Optional[List[str]]) -> None:
+        """Set the autocomplete list."""
+        self._props['autocomplete'] = autocomplete
+        self.update()
 
 
     def on_value_change(self, value: Any) -> None:
     def on_value_change(self, value: Any) -> None:
         super().on_value_change(value)
         super().on_value_change(value)
-        for message, check in self.validation.items():
-            if not check(value):
-                self.props(f'error error-message="{message}"')
-                break
-        else:
-            self.props(remove='error')
+        if self._send_update_on_value_change:
+            self.run_method('updateValue')

+ 11 - 1
nicegui/elements/interactive_image.js

@@ -1,7 +1,7 @@
 export default {
 export default {
   template: `
   template: `
     <div style="position:relative">
     <div style="position:relative">
-      <img :src="src" style="width:100%; height:100%;" v-on="onEvents" draggable="false" />
+      <img :src="computed_src" style="width:100%; height:100%;" v-on="onEvents" draggable="false" />
       <svg style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
       <svg style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
         <g v-if="cross" :style="{ display: cssDisplay }">
         <g v-if="cross" :style="{ display: cssDisplay }">
           <line :x1="x" y1="0" :x2="x" y2="100%" stroke="black" />
           <line :x1="x" y1="0" :x2="x" y2="100%" stroke="black" />
@@ -18,9 +18,19 @@ export default {
       x: 100,
       x: 100,
       y: 100,
       y: 100,
       cssDisplay: "none",
       cssDisplay: "none",
+      computed_src: undefined,
     };
     };
   },
   },
+  mounted() {
+    setTimeout(() => this.compute_src(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_src();
+  },
   methods: {
   methods: {
+    compute_src() {
+      this.computed_src = (this.src.startsWith("/") ? window.path_prefix : "") + this.src;
+    },
     updateCrossHair(e) {
     updateCrossHair(e) {
       this.x = (e.offsetX * e.target.naturalWidth) / e.target.clientWidth;
       this.x = (e.offsetX * e.target.naturalWidth) / e.target.clientWidth;
       this.y = (e.offsetY * e.target.naturalHeight) / e.target.clientHeight;
       this.y = (e.offsetY * e.target.naturalHeight) / e.target.clientHeight;

+ 9 - 4
nicegui/elements/interactive_image.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
-from typing import Callable, Dict, List, Optional
+from pathlib import Path
+from typing import Any, Callable, Dict, List, Optional, Union
 
 
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..events import MouseEventArguments, handle_event
 from ..events import MouseEventArguments, handle_event
@@ -13,9 +14,13 @@ register_component('interactive_image', __file__, 'interactive_image.js')
 class InteractiveImage(SourceElement, ContentElement):
 class InteractiveImage(SourceElement, ContentElement):
     CONTENT_PROP = 'content'
     CONTENT_PROP = 'content'
 
 
-    def __init__(self, source: str = '', *,
+    def __init__(self,
+                 source: Union[str, Path] = '', *,
                  content: str = '',
                  content: str = '',
-                 on_mouse: Optional[Callable] = None, events: List[str] = ['click'], cross: bool = False) -> None:
+                 on_mouse: Optional[Callable[..., Any]] = None,
+                 events: List[str] = ['click'],
+                 cross: bool = False,
+                 ) -> None:
         """Interactive Image
         """Interactive Image
 
 
         Create an image with an SVG overlay that handles mouse events and yields image coordinates.
         Create an image with an SVG overlay that handles mouse events and yields image coordinates.
@@ -24,7 +29,7 @@ class InteractiveImage(SourceElement, ContentElement):
         Thereby repeatedly updating the image source will automatically adapt to the available bandwidth.
         Thereby repeatedly updating the image source will automatically adapt to the available bandwidth.
         See `OpenCV Webcam <https://github.com/zauberzeug/nicegui/tree/main/examples/opencv_webcam/main.py>`_ for an example.
         See `OpenCV Webcam <https://github.com/zauberzeug/nicegui/tree/main/examples/opencv_webcam/main.py>`_ for an example.
 
 
-        :param source: the source of the image; can be an URL or a base64 string
+        :param source: the source of the image; can be an URL, local file path or a base64 string
         :param content: SVG content which should be overlayed; viewport has the same dimensions as the image
         :param content: SVG content which should be overlayed; viewport has the same dimensions as the image
         :param on_mouse: callback for mouse events (yields `type`, `image_x` and `image_y`)
         :param on_mouse: callback for mouse events (yields `type`, `image_x` and `image_y`)
         :param events: list of JavaScript events to subscribe to (default: `['click']`)
         :param events: list of JavaScript events to subscribe to (default: `['click']`)

+ 29 - 20
nicegui/elements/joystick.py

@@ -1,4 +1,4 @@
-from typing import Any, Callable, Optional
+from typing import Any, Callable, Dict, Optional
 
 
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..element import Element
 from ..element import Element
@@ -10,9 +10,9 @@ register_component('joystick', __file__, 'joystick.vue', ['lib/nipplejs.min.js']
 class Joystick(Element):
 class Joystick(Element):
 
 
     def __init__(self, *,
     def __init__(self, *,
-                 on_start: Optional[Callable] = None,
-                 on_move: Optional[Callable] = None,
-                 on_end: Optional[Callable] = None,
+                 on_start: Optional[Callable[..., Any]] = None,
+                 on_move: Optional[Callable[..., Any]] = None,
+                 on_end: Optional[Callable[..., Any]] = None,
                  throttle: float = 0.05,
                  throttle: float = 0.05,
                  ** options: Any) -> None:
                  ** options: Any) -> None:
         """Joystick
         """Joystick
@@ -26,20 +26,29 @@ class Joystick(Element):
         :param options: arguments like `color` which should be passed to the `underlying nipple.js library <https://github.com/yoannmoinet/nipplejs#options>`_
         :param options: arguments like `color` which should be passed to the `underlying nipple.js library <https://github.com/yoannmoinet/nipplejs#options>`_
         """
         """
         super().__init__('joystick')
         super().__init__('joystick')
-        self.on('start',
-                lambda _: handle_event(on_start, JoystickEventArguments(sender=self,
-                                                                        client=self.client,
-                                                                        action='start')))
-        self.on('move',
-                lambda msg: handle_event(on_move, JoystickEventArguments(sender=self,
-                                                                         client=self.client,
-                                                                         action='move',
-                                                                         x=msg['args']['data']['vector']['x'],
-                                                                         y=msg['args']['data']['vector']['y'])),
-                args=['data'],
-                throttle=throttle)
-        self.on('end',
-                lambda _: handle_event(on_end, JoystickEventArguments(sender=self,
-                                                                      client=self.client,
-                                                                      action='end')))
         self._props['options'] = options
         self._props['options'] = options
+        self.active = False
+
+        def handle_start() -> None:
+            self.active = True
+            handle_event(on_start, JoystickEventArguments(sender=self,
+                                                          client=self.client,
+                                                          action='start'))
+
+        def handle_move(msg: Dict) -> None:
+            if self.active:
+                handle_event(on_move, JoystickEventArguments(sender=self,
+                                                             client=self.client,
+                                                             action='move',
+                                                             x=msg['args']['data']['vector']['x'],
+                                                             y=msg['args']['data']['vector']['y']))
+
+        def handle_end() -> None:
+            self.active = False
+            handle_event(on_end, JoystickEventArguments(sender=self,
+                                                        client=self.client,
+                                                        action='end'))
+
+        self.on('start', handle_start)
+        self.on('move', handle_move, args=['data'], throttle=throttle),
+        self.on('end', handle_end)

+ 2 - 2
nicegui/elements/keyboard.py

@@ -1,4 +1,4 @@
-from typing import Callable, Dict, List
+from typing import Any, Callable, Dict, List
 
 
 from typing_extensions import Literal
 from typing_extensions import Literal
 
 
@@ -14,7 +14,7 @@ class Keyboard(Element):
     active = BindableProperty()
     active = BindableProperty()
 
 
     def __init__(self,
     def __init__(self,
-                 on_key: Callable, *,
+                 on_key: Callable[..., Any], *,
                  active: bool = True,
                  active: bool = True,
                  repeating: bool = True,
                  repeating: bool = True,
                  ignore: List[Literal['input', 'select', 'button', 'textarea']] = ['input', 'select', 'button', 'textarea'],
                  ignore: List[Literal['input', 'select', 'button', 'textarea']] = ['input', 'select', 'button', 'textarea'],

+ 3 - 4
nicegui/elements/knob.py

@@ -1,12 +1,12 @@
 from typing import Optional
 from typing import Optional
 
 
-from ..colors import set_text_color
 from .label import Label
 from .label import Label
+from .mixins.color_elements import TextColorElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 from .mixins.value_element import ValueElement
 
 
 
 
-class Knob(ValueElement, DisableableElement):
+class Knob(ValueElement, DisableableElement, TextColorElement):
 
 
     def __init__(self,
     def __init__(self,
                  value: float = 0.0,
                  value: float = 0.0,
@@ -35,12 +35,11 @@ class Knob(ValueElement, DisableableElement):
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
         :param show_value: whether to show the value as text
         :param show_value: whether to show the value as text
         """
         """
-        super().__init__(tag='q-knob', value=value, on_value_change=None, throttle=0.05)
+        super().__init__(tag='q-knob', value=value, on_value_change=None, throttle=0.05, text_color=color)
 
 
         self._props['min'] = min
         self._props['min'] = min
         self._props['max'] = max
         self._props['max'] = max
         self._props['step'] = step
         self._props['step'] = step
-        set_text_color(self, color)
         self._props['show-value'] = True  # NOTE: enable default slot, e.g. for nested icon
         self._props['show-value'] = True  # NOTE: enable default slot, e.g. for nested icon
 
 
         if center_color:
         if center_color:

+ 9 - 3
nicegui/elements/line_plot.py

@@ -1,11 +1,17 @@
-from typing import List
+from typing import Any, List
 
 
 from .pyplot import Pyplot
 from .pyplot import Pyplot
 
 
 
 
 class LinePlot(Pyplot):
 class LinePlot(Pyplot):
 
 
-    def __init__(self, *, n: int = 1, limit: int = 100, update_every: int = 1, close: bool = True, **kwargs) -> None:
+    def __init__(self, *,
+                 n: int = 1,
+                 limit: int = 100,
+                 update_every: int = 1,
+                 close: bool = True,
+                 **kwargs: Any,
+                 ) -> None:
         """Line Plot
         """Line Plot
 
 
         Create a line plot using pyplot.
         Create a line plot using pyplot.
@@ -26,7 +32,7 @@ class LinePlot(Pyplot):
         self.update_every = update_every
         self.update_every = update_every
         self.push_counter = 0
         self.push_counter = 0
 
 
-    def with_legend(self, titles: List[str], **kwargs):
+    def with_legend(self, titles: List[str], **kwargs: Any):
         self.fig.gca().legend(titles, **kwargs)
         self.fig.gca().legend(titles, **kwargs)
         self._convert_to_html()
         self._convert_to_html()
         return self
         return self

+ 13 - 4
nicegui/elements/link.py

@@ -1,4 +1,4 @@
-from typing import Callable, Union
+from typing import Any, Callable, Union
 
 
 from .. import globals
 from .. import globals
 from ..dependencies import register_component
 from ..dependencies import register_component
@@ -10,7 +10,11 @@ register_component('link', __file__, 'link.js')
 
 
 class Link(TextElement):
 class Link(TextElement):
 
 
-    def __init__(self, text: str = '', target: Union[Callable, str] = '#', new_tab: bool = False) -> None:
+    def __init__(self,
+                 text: str = '',
+                 target: Union[Callable[..., Any], str, Element] = '#',
+                 new_tab: bool = False,
+                 ) -> None:
         """Link
         """Link
 
 
         Create a hyperlink.
         Create a hyperlink.
@@ -19,11 +23,16 @@ class Link(TextElement):
         and link to it with `ui.link(target="#name")`.
         and link to it with `ui.link(target="#name")`.
 
 
         :param text: display text
         :param text: display text
-        :param target: page function or string that is a an absolute URL or relative path from base URL
+        :param target: page function, NiceGUI element on the same page or string that is a an absolute URL or relative path from base URL
         :param new_tab: open link in new tab (default: False)
         :param new_tab: open link in new tab (default: False)
         """
         """
         super().__init__(tag='link', text=text)
         super().__init__(tag='link', text=text)
-        self._props['href'] = target if isinstance(target, str) else globals.page_routes[target]
+        if isinstance(target, str):
+            self._props['href'] = target
+        elif isinstance(target, Element):
+            self._props['href'] = f'#{target.id}'
+        elif callable(target):
+            self._props['href'] = globals.page_routes[target]
         self._props['target'] = '_blank' if new_tab else '_self'
         self._props['target'] = '_blank' if new_tab else '_self'
         self._classes = ['nicegui-link']
         self._classes = ['nicegui-link']
 
 

+ 5 - 1
nicegui/elements/log.js

@@ -3,15 +3,19 @@ export default {
   data() {
   data() {
     return {
     return {
       num_lines: 0,
       num_lines: 0,
+      total_count: 0,
     };
     };
   },
   },
   mounted() {
   mounted() {
     const text = decodeURIComponent(this.lines);
     const text = decodeURIComponent(this.lines);
     this.$el.innerHTML = text;
     this.$el.innerHTML = text;
     this.num_lines = text ? text.split("\n").length : 0;
     this.num_lines = text ? text.split("\n").length : 0;
+    this.total_count = this.num_lines;
   },
   },
   methods: {
   methods: {
-    push(line) {
+    push(line, total_count) {
+      if (total_count === this.total_count) return;
+      this.total_count = total_count;
       const decoded = decodeURIComponent(line);
       const decoded = decodeURIComponent(line);
       const textarea = this.$el;
       const textarea = this.$el;
       textarea.innerHTML += (this.num_lines ? "\n" : "") + decoded;
       textarea.innerHTML += (this.num_lines ? "\n" : "") + decoded;

+ 5 - 2
nicegui/elements/log.py

@@ -22,11 +22,14 @@ class Log(Element):
         self._props['lines'] = ''
         self._props['lines'] = ''
         self._classes = ['nicegui-log']
         self._classes = ['nicegui-log']
         self.lines: deque[str] = deque(maxlen=max_lines)
         self.lines: deque[str] = deque(maxlen=max_lines)
+        self.total_count: int = 0
 
 
     def push(self, line: Any) -> None:
     def push(self, line: Any) -> None:
-        self.lines.extend(map(urllib.parse.quote, str(line).splitlines()))
+        new_lines = [urllib.parse.quote(line) for line in str(line).splitlines()]
+        self.lines.extend(new_lines)
         self._props['lines'] = '\n'.join(self.lines)
         self._props['lines'] = '\n'.join(self.lines)
-        self.run_method('push', urllib.parse.quote(str(line)))
+        self.total_count += len(new_lines)
+        self.run_method('push', urllib.parse.quote(str(line)), self.total_count)
 
 
     def clear(self) -> None:
     def clear(self) -> None:
         """Clear the log"""
         """Clear the log"""

+ 6 - 2
nicegui/elements/menu.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 
 from .. import globals
 from .. import globals
 from ..events import ClickEventArguments, handle_event
 from ..events import ClickEventArguments, handle_event
@@ -28,7 +28,11 @@ class Menu(ValueElement):
 
 
 class MenuItem(TextElement):
 class MenuItem(TextElement):
 
 
-    def __init__(self, text: str = '', on_click: Optional[Callable] = None, *, auto_close: bool = True) -> None:
+    def __init__(self,
+                 text: str = '',
+                 on_click: Optional[Callable[..., Any]] = None, *,
+                 auto_close: bool = True,
+                 ) -> None:
         """Menu Item
         """Menu Item
 
 
         A menu item to be added to a menu.
         A menu item to be added to a menu.

+ 1 - 1
nicegui/elements/mermaid.py

@@ -19,4 +19,4 @@ class Mermaid(ContentElement):
 
 
     def on_content_change(self, content: str) -> None:
     def on_content_change(self, content: str) -> None:
         self._props[self.CONTENT_PROP] = content.strip()
         self._props[self.CONTENT_PROP] = content.strip()
-        self.run_method('update', content)
+        self.run_method('update', content.strip())

+ 41 - 0
nicegui/elements/mixins/color_elements.py

@@ -0,0 +1,41 @@
+from typing import Any
+
+from typing_extensions import get_args
+
+from ...element import Element
+from ...tailwind_types.background_color import BackgroundColor
+
+QUASAR_COLORS = {'primary', 'secondary', 'accent', 'dark', 'positive', 'negative', 'info', 'warning'}
+for color in {'red', 'pink', 'purple', 'deep-purple', 'indigo', 'blue', 'light-blue', 'cyan', 'teal', 'green',
+              'light-green', 'lime', 'yellow', 'amber', 'orange', 'deep-orange', 'brown', 'grey', 'blue-grey'}:
+    QUASAR_COLORS.add(color)
+    for i in range(1, 15):
+        QUASAR_COLORS.add(f'{color}-{i}')
+
+TAILWIND_COLORS = get_args(BackgroundColor)
+
+
+class BackgroundColorElement(Element):
+    BACKGROUND_COLOR_PROP = 'color'
+
+    def __init__(self, *, background_color: str, **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        if background_color in QUASAR_COLORS:
+            self._props[self.BACKGROUND_COLOR_PROP] = background_color
+        elif background_color in TAILWIND_COLORS:
+            self._classes.append(f'bg-{background_color}')
+        elif background_color is not None:
+            self._style['background-color'] = background_color
+
+
+class TextColorElement(Element):
+    TEXT_COLOR_PROP = 'color'
+
+    def __init__(self, *, text_color: str, **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        if text_color in QUASAR_COLORS:
+            self._props[self.TEXT_COLOR_PROP] = text_color
+        elif text_color in TAILWIND_COLORS:
+            self._classes.append(f'text-{text_color}')
+        elif text_color is not None:
+            self._style['color'] = text_color

+ 8 - 5
nicegui/elements/mixins/content_element.py

@@ -10,7 +10,7 @@ class ContentElement(Element):
     CONTENT_PROP = 'innerHTML'
     CONTENT_PROP = 'innerHTML'
     content = BindableProperty(on_change=lambda sender, content: sender.on_content_change(content))
     content = BindableProperty(on_change=lambda sender, content: sender.on_content_change(content))
 
 
-    def __init__(self, *, content: str, **kwargs) -> None:
+    def __init__(self, *, content: str, **kwargs: Any) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
         self.content = content
         self.content = content
         self.on_content_change(content)
         self.on_content_change(content)
@@ -18,7 +18,8 @@ class ContentElement(Element):
     def bind_content_to(self,
     def bind_content_to(self,
                         target_object: Any,
                         target_object: Any,
                         target_name: str = 'content',
                         target_name: str = 'content',
-                        forward: Callable = lambda x: x) -> Self:
+                        forward: Callable[..., Any] = lambda x: x,
+                        ) -> Self:
         """Bind the content of this element to the target object's target_name property.
         """Bind the content of this element to the target object's target_name property.
 
 
         The binding works one way only, from this element to the target.
         The binding works one way only, from this element to the target.
@@ -33,7 +34,8 @@ class ContentElement(Element):
     def bind_content_from(self,
     def bind_content_from(self,
                           target_object: Any,
                           target_object: Any,
                           target_name: str = 'content',
                           target_name: str = 'content',
-                          backward: Callable = lambda x: x) -> Self:
+                          backward: Callable[..., Any] = lambda x: x,
+                          ) -> Self:
         """Bind the content of this element from the target object's target_name property.
         """Bind the content of this element from the target object's target_name property.
 
 
         The binding works one way only, from the target to this element.
         The binding works one way only, from the target to this element.
@@ -48,8 +50,9 @@ class ContentElement(Element):
     def bind_content(self,
     def bind_content(self,
                      target_object: Any,
                      target_object: Any,
                      target_name: str = 'content', *,
                      target_name: str = 'content', *,
-                     forward: Callable = lambda x: x,
-                     backward: Callable = lambda x: x) -> Self:
+                     forward: Callable[..., Any] = lambda x: x,
+                     backward: Callable[..., Any] = lambda x: x,
+                     ) -> Self:
         """Bind the content of this element to the target object's target_name property.
         """Bind the content 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.
         The binding works both ways, from this element to the target and from the target to this element.

+ 16 - 5
nicegui/elements/mixins/disableable_element.py

@@ -9,9 +9,17 @@ from ...element import Element
 class DisableableElement(Element):
 class DisableableElement(Element):
     enabled = BindableProperty(on_change=lambda sender, value: sender.on_enabled_change(value))
     enabled = BindableProperty(on_change=lambda sender, value: sender.on_enabled_change(value))
 
 
-    def __init__(self, **kwargs) -> None:
+    def __init__(self, **kwargs: Any) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
         self.enabled = True
         self.enabled = True
+        self.ignores_events_when_disabled = True
+
+    @property
+    def is_ignoring_events(self) -> bool:
+        """Return whether the element is currently ignoring events."""
+        if super().is_ignoring_events:
+            return True
+        return not self.enabled and self.ignores_events_when_disabled
 
 
     def enable(self) -> None:
     def enable(self) -> None:
         """Enable the element."""
         """Enable the element."""
@@ -24,7 +32,8 @@ class DisableableElement(Element):
     def bind_enabled_to(self,
     def bind_enabled_to(self,
                         target_object: Any,
                         target_object: Any,
                         target_name: str = 'enabled',
                         target_name: str = 'enabled',
-                        forward: Callable = lambda x: x) -> Self:
+                        forward: Callable[..., Any] = lambda x: x,
+                        ) -> Self:
         """Bind the enabled state of this element to the target object's target_name property.
         """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.
         The binding works one way only, from this element to the target.
@@ -39,7 +48,8 @@ class DisableableElement(Element):
     def bind_enabled_from(self,
     def bind_enabled_from(self,
                           target_object: Any,
                           target_object: Any,
                           target_name: str = 'enabled',
                           target_name: str = 'enabled',
-                          backward: Callable = lambda x: x) -> Self:
+                          backward: Callable[..., Any] = lambda x: x,
+                          ) -> Self:
         """Bind the enabled state of this element from the target object's target_name property.
         """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.
         The binding works one way only, from the target to this element.
@@ -54,8 +64,9 @@ class DisableableElement(Element):
     def bind_enabled(self,
     def bind_enabled(self,
                      target_object: Any,
                      target_object: Any,
                      target_name: str = 'enabled', *,
                      target_name: str = 'enabled', *,
-                     forward: Callable = lambda x: x,
-                     backward: Callable = lambda x: x) -> Self:
+                     forward: Callable[..., Any] = lambda x: x,
+                     backward: Callable[..., Any] = lambda x: x,
+                     ) -> Self:
         """Bind the enabled state of this element to the target object's target_name property.
         """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.
         The binding works both ways, from this element to the target and from the target to this element.

+ 8 - 5
nicegui/elements/mixins/filter_element.py

@@ -10,7 +10,7 @@ class FilterElement(Element):
     FILTER_PROP = 'filter'
     FILTER_PROP = 'filter'
     filter = BindableProperty(on_change=lambda sender, filter: sender.on_filter_change(filter))
     filter = BindableProperty(on_change=lambda sender, filter: sender.on_filter_change(filter))
 
 
-    def __init__(self, *, filter: Optional[str] = None, **kwargs) -> None:
+    def __init__(self, *, filter: Optional[str] = None, **kwargs: Any) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
         self.filter = filter
         self.filter = filter
         self._props[self.FILTER_PROP] = filter
         self._props[self.FILTER_PROP] = filter
@@ -18,7 +18,8 @@ class FilterElement(Element):
     def bind_filter_to(self,
     def bind_filter_to(self,
                        target_object: Any,
                        target_object: Any,
                        target_name: str = 'filter',
                        target_name: str = 'filter',
-                       forward: Callable = lambda x: x) -> Self:
+                       forward: Callable[..., Any] = lambda x: x,
+                       ) -> Self:
         """Bind the filter of this element to the target object's target_name property.
         """Bind the filter of this element to the target object's target_name property.
 
 
         The binding works one way only, from this element to the target.
         The binding works one way only, from this element to the target.
@@ -33,7 +34,8 @@ class FilterElement(Element):
     def bind_filter_from(self,
     def bind_filter_from(self,
                          target_object: Any,
                          target_object: Any,
                          target_name: str = 'filter',
                          target_name: str = 'filter',
-                         backward: Callable = lambda x: x) -> Self:
+                         backward: Callable[..., Any] = lambda x: x,
+                         ) -> Self:
         """Bind the filter of this element from the target object's target_name property.
         """Bind the filter of this element from the target object's target_name property.
 
 
         The binding works one way only, from the target to this element.
         The binding works one way only, from the target to this element.
@@ -48,8 +50,9 @@ class FilterElement(Element):
     def bind_filter(self,
     def bind_filter(self,
                     target_object: Any,
                     target_object: Any,
                     target_name: str = 'filter', *,
                     target_name: str = 'filter', *,
-                    forward: Callable = lambda x: x,
-                    backward: Callable = lambda x: x) -> Self:
+                    forward: Callable[..., Any] = lambda x: x,
+                    backward: Callable[..., Any] = lambda x: x,
+                    ) -> Self:
         """Bind the filter of this element to the target object's target_name property.
         """Bind the filter 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.
         The binding works both ways, from this element to the target and from the target to this element.

+ 16 - 8
nicegui/elements/mixins/source_element.py

@@ -1,23 +1,29 @@
-from typing import Any, Callable
+from pathlib import Path
+from typing import Any, Callable, Union
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
+from ... import globals
 from ...binding import BindableProperty, bind, bind_from, bind_to
 from ...binding import BindableProperty, bind, bind_from, bind_to
 from ...element import Element
 from ...element import Element
+from ...helpers import is_file
 
 
 
 
 class SourceElement(Element):
 class SourceElement(Element):
     source = BindableProperty(on_change=lambda sender, source: sender.on_source_change(source))
     source = BindableProperty(on_change=lambda sender, source: sender.on_source_change(source))
 
 
-    def __init__(self, *, source: str, **kwargs) -> None:
+    def __init__(self, *, source: Union[str, Path], **kwargs: Any) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
+        if is_file(source):
+            source = globals.app.add_static_file(local_file=source)
         self.source = source
         self.source = source
         self._props['src'] = source
         self._props['src'] = source
 
 
     def bind_source_to(self,
     def bind_source_to(self,
                        target_object: Any,
                        target_object: Any,
                        target_name: str = 'source',
                        target_name: str = 'source',
-                       forward: Callable = lambda x: x) -> Self:
+                       forward: Callable[..., Any] = lambda x: x,
+                       ) -> Self:
         """Bind the source of this element to the target object's target_name property.
         """Bind the source of this element to the target object's target_name property.
 
 
         The binding works one way only, from this element to the target.
         The binding works one way only, from this element to the target.
@@ -32,7 +38,8 @@ class SourceElement(Element):
     def bind_source_from(self,
     def bind_source_from(self,
                          target_object: Any,
                          target_object: Any,
                          target_name: str = 'source',
                          target_name: str = 'source',
-                         backward: Callable = lambda x: x) -> Self:
+                         backward: Callable[..., Any] = lambda x: x,
+                         ) -> Self:
         """Bind the source of this element from the target object's target_name property.
         """Bind the source of this element from the target object's target_name property.
 
 
         The binding works one way only, from the target to this element.
         The binding works one way only, from the target to this element.
@@ -47,8 +54,9 @@ class SourceElement(Element):
     def bind_source(self,
     def bind_source(self,
                     target_object: Any,
                     target_object: Any,
                     target_name: str = 'source', *,
                     target_name: str = 'source', *,
-                    forward: Callable = lambda x: x,
-                    backward: Callable = lambda x: x) -> Self:
+                    forward: Callable[..., Any] = lambda x: x,
+                    backward: Callable[..., Any] = lambda x: x,
+                    ) -> Self:
         """Bind the source of this element to the target object's target_name property.
         """Bind the source 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.
         The binding works both ways, from this element to the target and from the target to this element.
@@ -61,14 +69,14 @@ class SourceElement(Element):
         bind(self, 'source', target_object, target_name, forward=forward, backward=backward)
         bind(self, 'source', target_object, target_name, forward=forward, backward=backward)
         return self
         return self
 
 
-    def set_source(self, source: str) -> None:
+    def set_source(self, source: Union[str, Path]) -> None:
         """Set the source of this element.
         """Set the source of this element.
 
 
         :param source: The new source.
         :param source: The new source.
         """
         """
         self.source = source
         self.source = source
 
 
-    def on_source_change(self, source: str) -> None:
+    def on_source_change(self, source: Union[str, Path]) -> None:
         """Called when the source of this element changes.
         """Called when the source of this element changes.
 
 
         :param source: The new source.
         :param source: The new source.

+ 8 - 5
nicegui/elements/mixins/text_element.py

@@ -9,7 +9,7 @@ from ...element import Element
 class TextElement(Element):
 class TextElement(Element):
     text = BindableProperty(on_change=lambda sender, text: sender.on_text_change(text))
     text = BindableProperty(on_change=lambda sender, text: sender.on_text_change(text))
 
 
-    def __init__(self, *, text: str, **kwargs) -> None:
+    def __init__(self, *, text: str, **kwargs: Any) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
         self.text = text
         self.text = text
         self._text_to_model_text(text)
         self._text_to_model_text(text)
@@ -17,7 +17,8 @@ class TextElement(Element):
     def bind_text_to(self,
     def bind_text_to(self,
                      target_object: Any,
                      target_object: Any,
                      target_name: str = 'text',
                      target_name: str = 'text',
-                     forward: Callable = lambda x: x) -> Self:
+                     forward: Callable[..., Any] = lambda x: x,
+                     ) -> Self:
         """Bind the text of this element to the target object's target_name property.
         """Bind the text of this element to the target object's target_name property.
 
 
         The binding works one way only, from this element to the target.
         The binding works one way only, from this element to the target.
@@ -32,7 +33,8 @@ class TextElement(Element):
     def bind_text_from(self,
     def bind_text_from(self,
                        target_object: Any,
                        target_object: Any,
                        target_name: str = 'text',
                        target_name: str = 'text',
-                       backward: Callable = lambda x: x) -> Self:
+                       backward: Callable[..., Any] = lambda x: x,
+                       ) -> Self:
         """Bind the text of this element from the target object's target_name property.
         """Bind the text of this element from the target object's target_name property.
 
 
         The binding works one way only, from the target to this element.
         The binding works one way only, from the target to this element.
@@ -47,8 +49,9 @@ class TextElement(Element):
     def bind_text(self,
     def bind_text(self,
                   target_object: Any,
                   target_object: Any,
                   target_name: str = 'text', *,
                   target_name: str = 'text', *,
-                  forward: Callable = lambda x: x,
-                  backward: Callable = lambda x: x) -> Self:
+                  forward: Callable[..., Any] = lambda x: x,
+                  backward: Callable[..., Any] = lambda x: x,
+                  ) -> Self:
         """Bind the text of this element to the target object's target_name property.
         """Bind the text 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.
         The binding works both ways, from this element to the target and from the target to this element.

+ 27 - 0
nicegui/elements/mixins/validation_element.py

@@ -0,0 +1,27 @@
+from typing import Any, Callable, Dict, Optional
+
+from .value_element import ValueElement
+
+
+class ValidationElement(ValueElement):
+
+    def __init__(self, validation: Dict[str, Callable[..., bool]], **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        self.validation = validation
+        self._error: Optional[str] = None
+
+    @property
+    def error(self) -> Optional[str]:
+        """The latest error message from the validation functions."""
+        return self._error
+
+    def on_value_change(self, value: Any) -> None:
+        super().on_value_change(value)
+        for message, check in self.validation.items():
+            if not check(value):
+                self._error = message
+                self.props(f'error error-message="{message}"')
+                break
+        else:
+            self._error = None
+            self.props(remove='error')

+ 17 - 9
nicegui/elements/mixins/value_element.py

@@ -1,4 +1,4 @@
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, Dict, List, Optional
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
@@ -8,12 +8,17 @@ from ...events import ValueChangeEventArguments, handle_event
 
 
 
 
 class ValueElement(Element):
 class ValueElement(Element):
-    VALUE_PROP = 'model-value'
-    EVENT_ARGS = ['value']
-    LOOPBACK = True
+    VALUE_PROP: str = 'model-value'
+    EVENT_ARGS: Optional[List[str]] = ['value']
+    LOOPBACK: bool = True
     value = BindableProperty(on_change=lambda sender, value: sender.on_value_change(value))
     value = BindableProperty(on_change=lambda sender, value: sender.on_value_change(value))
 
 
-    def __init__(self, *, value: Any, on_value_change: Optional[Callable], throttle: float = 0, **kwargs) -> None:
+    def __init__(self, *,
+                 value: Any,
+                 on_value_change: Optional[Callable[..., Any]],
+                 throttle: float = 0,
+                 **kwargs: Any,
+                 ) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
         self.set_value(value)
         self.set_value(value)
         self._props[self.VALUE_PROP] = self._value_to_model_value(value)
         self._props[self.VALUE_PROP] = self._value_to_model_value(value)
@@ -30,7 +35,8 @@ class ValueElement(Element):
     def bind_value_to(self,
     def bind_value_to(self,
                       target_object: Any,
                       target_object: Any,
                       target_name: str = 'value',
                       target_name: str = 'value',
-                      forward: Callable = lambda x: x) -> Self:
+                      forward: Callable[..., Any] = lambda x: x,
+                      ) -> Self:
         """Bind the value of this element to the target object's target_name property.
         """Bind the value of this element to the target object's target_name property.
 
 
         The binding works one way only, from this element to the target.
         The binding works one way only, from this element to the target.
@@ -45,7 +51,8 @@ class ValueElement(Element):
     def bind_value_from(self,
     def bind_value_from(self,
                         target_object: Any,
                         target_object: Any,
                         target_name: str = 'value',
                         target_name: str = 'value',
-                        backward: Callable = lambda x: x) -> Self:
+                        backward: Callable[..., Any] = lambda x: x,
+                        ) -> Self:
         """Bind the value of this element from the target object's target_name property.
         """Bind the value of this element from the target object's target_name property.
 
 
         The binding works one way only, from the target to this element.
         The binding works one way only, from the target to this element.
@@ -60,8 +67,9 @@ class ValueElement(Element):
     def bind_value(self,
     def bind_value(self,
                    target_object: Any,
                    target_object: Any,
                    target_name: str = 'value', *,
                    target_name: str = 'value', *,
-                   forward: Callable = lambda x: x,
-                   backward: Callable = lambda x: x) -> Self:
+                   forward: Callable[..., Any] = lambda x: x,
+                   backward: Callable[..., Any] = lambda x: x,
+                   ) -> Self:
         """Bind the value of this element to the target object's target_name property.
         """Bind the value 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.
         The binding works both ways, from this element to the target and from the target to this element.

+ 17 - 8
nicegui/elements/mixins/visibility.py

@@ -1,4 +1,4 @@
-from typing import TYPE_CHECKING, Any, Callable
+from typing import TYPE_CHECKING, Any, Callable, cast
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
@@ -11,14 +11,21 @@ if TYPE_CHECKING:
 class Visibility:
 class Visibility:
     visible = BindableProperty(on_change=lambda sender, visible: sender.on_visibility_change(visible))
     visible = BindableProperty(on_change=lambda sender, visible: sender.on_visibility_change(visible))
 
 
-    def __init__(self, **kwargs) -> None:
+    def __init__(self, **kwargs: Any) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
         self.visible = True
         self.visible = True
+        self.ignores_events_when_hidden = True
+
+    @property
+    def is_ignoring_events(self) -> bool:
+        """Return whether the element is currently ignoring events."""
+        return not self.visible and self.ignores_events_when_hidden
 
 
     def bind_visibility_to(self,
     def bind_visibility_to(self,
                            target_object: Any,
                            target_object: Any,
                            target_name: str = 'visible',
                            target_name: str = 'visible',
-                           forward: Callable = lambda x: x) -> Self:
+                           forward: Callable[..., Any] = lambda x: x,
+                           ) -> Self:
         """Bind the visibility of this element to the target object's target_name property.
         """Bind the visibility of this element to the target object's target_name property.
 
 
         The binding works one way only, from this element to the target.
         The binding works one way only, from this element to the target.
@@ -33,7 +40,7 @@ class Visibility:
     def bind_visibility_from(self,
     def bind_visibility_from(self,
                              target_object: Any,
                              target_object: Any,
                              target_name: str = 'visible',
                              target_name: str = 'visible',
-                             backward: Callable = lambda x: x, *,
+                             backward: Callable[..., Any] = lambda x: x, *,
                              value: Any = None) -> Self:
                              value: Any = None) -> Self:
         """Bind the visibility of this element from the target object's target_name property.
         """Bind the visibility of this element from the target object's target_name property.
 
 
@@ -52,9 +59,10 @@ class Visibility:
     def bind_visibility(self,
     def bind_visibility(self,
                         target_object: Any,
                         target_object: Any,
                         target_name: str = 'visible', *,
                         target_name: str = 'visible', *,
-                        forward: Callable = lambda x: x,
-                        backward: Callable = lambda x: x,
-                        value: Any = None) -> Self:
+                        forward: Callable[..., Any] = lambda x: x,
+                        backward: Callable[..., Any] = lambda x: x,
+                        value: Any = None,
+                        ) -> Self:
         """Bind the visibility of this element to the target object's target_name property.
         """Bind the visibility 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.
         The binding works both ways, from this element to the target and from the target to this element.
@@ -77,11 +85,12 @@ class Visibility:
         """
         """
         self.visible = visible
         self.visible = visible
 
 
-    def on_visibility_change(self: 'Element', visible: str) -> None:
+    def on_visibility_change(self, visible: str) -> None:
         """Called when the visibility of this element changes.
         """Called when the visibility of this element changes.
 
 
         :param visible: Whether the element should be visible.
         :param visible: Whether the element should be visible.
         """
         """
+        self = cast('Element', self)
         if visible and 'hidden' in self._classes:
         if visible and 'hidden' in self._classes:
             self._classes.remove('hidden')
             self._classes.remove('hidden')
             self.update()
             self.update()

+ 34 - 18
nicegui/elements/number.py

@@ -1,10 +1,10 @@
 from typing import Any, Callable, Dict, Optional
 from typing import Any, Callable, Dict, Optional
 
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
-from .mixins.value_element import ValueElement
+from .mixins.validation_element import ValidationElement
 
 
 
 
-class Number(ValueElement, DisableableElement):
+class Number(ValidationElement, DisableableElement):
     LOOPBACK = False
     LOOPBACK = False
 
 
     def __init__(self,
     def __init__(self,
@@ -17,8 +17,9 @@ class Number(ValueElement, DisableableElement):
                  prefix: Optional[str] = None,
                  prefix: Optional[str] = None,
                  suffix: Optional[str] = None,
                  suffix: Optional[str] = None,
                  format: Optional[str] = None,
                  format: Optional[str] = None,
-                 on_change: Optional[Callable] = None,
-                 validation: Dict[str, Callable] = {}) -> None:
+                 on_change: Optional[Callable[..., Any]] = None,
+                 validation: Dict[str, Callable[..., bool]] = {},
+                 ) -> None:
         """Number Input
         """Number Input
 
 
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.
@@ -36,10 +37,10 @@ class Number(ValueElement, DisableableElement):
         :param suffix: a suffix to append to the displayed value
         :param suffix: a suffix to append to the displayed value
         :param format: a string like "%.2f" to format the displayed value
         :param format: a string like "%.2f" to format the displayed value
         :param on_change: callback to execute when the value changes
         :param on_change: callback to execute when the value changes
-        :param validation: dictionary of validation rules, e.g. ``{'Too small!': lambda value: value < 3}``
+        :param validation: dictionary of validation rules, e.g. ``{'Too large!': lambda value: value < 3}``
         """
         """
         self.format = format
         self.format = format
-        super().__init__(tag='q-input', value=value, on_value_change=on_change)
+        super().__init__(tag='q-input', value=value, on_value_change=on_change, validation=validation)
         self._props['type'] = 'number'
         self._props['type'] = 'number'
         if label is not None:
         if label is not None:
             self._props['label'] = label
             self._props['label'] = label
@@ -55,24 +56,39 @@ class Number(ValueElement, DisableableElement):
             self._props['prefix'] = prefix
             self._props['prefix'] = prefix
         if suffix is not None:
         if suffix is not None:
             self._props['suffix'] = suffix
             self._props['suffix'] = suffix
-        self.validation = validation
         self.on('blur', self.sanitize)
         self.on('blur', self.sanitize)
 
 
+    @property
+    def min(self) -> float:
+        """The minimum value allowed."""
+        return self._props.get('min', -float('inf'))
+
+    @min.setter
+    def min(self, value: float) -> None:
+        self._props['min'] = value
+        self.sanitize()
+
+    @property
+    def max(self) -> float:
+        """The maximum value allowed."""
+        return self._props.get('max', float('inf'))
+
+    @max.setter
+    def max(self, value: float) -> None:
+        self._props['max'] = value
+        self.sanitize()
+
+    @property
+    def out_of_limits(self) -> bool:
+        """Whether the current value is out of the allowed limits."""
+        return not self.min <= self.value <= self.max
+
     def sanitize(self) -> None:
     def sanitize(self) -> None:
         value = float(self.value or 0)
         value = float(self.value or 0)
-        value = max(value, self._props.get('min', -float('inf')))
-        value = min(value, self._props.get('max', float('inf')))
+        value = max(value, self.min)
+        value = min(value, self.max)
         self.set_value(float(self.format % value) if self.format else 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)
-        for message, check in self.validation.items():
-            if not check(value):
-                self.props(f'error error-message="{message}"')
-                break
-        else:
-            self.props(remove='error')
-
     def _msg_to_value(self, msg: Dict) -> Any:
     def _msg_to_value(self, msg: Dict) -> Any:
         return float(msg['args']) if msg['args'] else None
         return float(msg['args']) if msg['args'] else None
 
 

+ 1 - 0
nicegui/elements/plotly.py

@@ -37,6 +37,7 @@ class Plotly(Element):
         self.update()
         self.update()
 
 
     def update(self) -> None:
     def update(self) -> None:
+        super().update()
         self._props['options'] = self._get_figure_json()
         self._props['options'] = self._get_figure_json()
         self.run_method('update', self._props['options'])
         self.run_method('update', self._props['options'])
 
 

+ 5 - 7
nicegui/elements/progress.py

@@ -2,11 +2,11 @@ from typing import Optional
 
 
 from nicegui import ui
 from nicegui import ui
 
 
-from ..colors import set_text_color
+from .mixins.color_elements import TextColorElement
 from .mixins.value_element import ValueElement
 from .mixins.value_element import ValueElement
 
 
 
 
-class LinearProgress(ValueElement):
+class LinearProgress(ValueElement, TextColorElement):
     VALUE_PROP = 'value'
     VALUE_PROP = 'value'
 
 
     def __init__(self,
     def __init__(self,
@@ -25,16 +25,15 @@ class LinearProgress(ValueElement):
         :param show_value: whether to show a value label in the center (default: `True`)
         :param show_value: whether to show a value label in the center (default: `True`)
         :param color: color (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         :param color: color (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         """
         """
-        super().__init__(tag='q-linear-progress', value=value, on_value_change=None)
+        super().__init__(tag='q-linear-progress', value=value, on_value_change=None, text_color=color)
         self._props['size'] = size if size is not None else '20px' if show_value else '4px'
         self._props['size'] = size if size is not None else '20px' if show_value else '4px'
-        set_text_color(self, color)
 
 
         if show_value:
         if show_value:
             with self:
             with self:
                 ui.label().classes('absolute-center text-sm text-white').bind_text_from(self, 'value')
                 ui.label().classes('absolute-center text-sm text-white').bind_text_from(self, 'value')
 
 
 
 
-class CircularProgress(ValueElement):
+class CircularProgress(ValueElement, TextColorElement):
     VALUE_PROP = 'value'
     VALUE_PROP = 'value'
 
 
     def __init__(self,
     def __init__(self,
@@ -57,12 +56,11 @@ class CircularProgress(ValueElement):
         :param show_value: whether to show a value label in the center (default: `True`)
         :param show_value: whether to show a value label in the center (default: `True`)
         :param color: color (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         :param color: color (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         """
         """
-        super().__init__(tag='q-circular-progress', value=value, on_value_change=None)
+        super().__init__(tag='q-circular-progress', value=value, on_value_change=None, text_color=color)
         self._props['min'] = min
         self._props['min'] = min
         self._props['max'] = max
         self._props['max'] = max
         self._props['size'] = size
         self._props['size'] = size
         self._props['show-value'] = show_value
         self._props['show-value'] = show_value
-        set_text_color(self, color)
         self._props['track-color'] = 'grey-4'
         self._props['track-color'] = 'grey-4'
 
 
         if show_value:
         if show_value:

+ 2 - 1
nicegui/elements/pyplot.py

@@ -1,5 +1,6 @@
 import asyncio
 import asyncio
 import io
 import io
+from typing import Any
 
 
 import matplotlib.pyplot as plt
 import matplotlib.pyplot as plt
 
 
@@ -9,7 +10,7 @@ from ..element import Element
 
 
 class Pyplot(Element):
 class Pyplot(Element):
 
 
-    def __init__(self, *, close: bool = True, **kwargs) -> None:
+    def __init__(self, *, close: bool = True, **kwargs: Any) -> None:
         """Pyplot Context
         """Pyplot Context
 
 
         Create a context to configure a `Matplotlib <https://matplotlib.org/>`_ plot.
         Create a context to configure a `Matplotlib <https://matplotlib.org/>`_ plot.

+ 5 - 1
nicegui/elements/radio.py

@@ -6,7 +6,11 @@ from .mixins.disableable_element import DisableableElement
 
 
 class Radio(ChoiceElement, DisableableElement):
 class Radio(ChoiceElement, DisableableElement):
 
 
-    def __init__(self, options: Union[List, Dict], *, value: Any = None, on_change: Optional[Callable] = None):
+    def __init__(self,
+                 options: Union[List, Dict], *,
+                 value: Any = None,
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Radio Selection
         """Radio Selection
 
 
         The options can be specified as a list of values, or as a dictionary mapping values to labels.
         The options can be specified as a list of values, or as a dictionary mapping values to labels.

+ 2 - 1
nicegui/elements/scene.py

@@ -57,7 +57,8 @@ class Scene(Element):
                  width: int = 400,
                  width: int = 400,
                  height: int = 300,
                  height: int = 300,
                  grid: bool = True,
                  grid: bool = True,
-                 on_click: Optional[Callable] = None) -> None:
+                 on_click: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """3D Scene
         """3D Scene
 
 
         Display a 3d scene using `three.js <https://threejs.org/>`_.
         Display a 3d scene using `three.js <https://threejs.org/>`_.

+ 4 - 4
nicegui/elements/scene_object3d.py

@@ -1,12 +1,12 @@
 import uuid
 import uuid
-from typing import TYPE_CHECKING, Any, List, Optional
+from typing import TYPE_CHECKING, Any, List, Optional, Union, cast
 
 
 import numpy as np
 import numpy as np
 
 
 from .. import globals
 from .. import globals
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
-    from .scene import Scene
+    from .scene import Scene, SceneObject
 
 
 
 
 class Object3D:
 class Object3D:
@@ -15,9 +15,9 @@ class Object3D:
         self.type = type
         self.type = type
         self.id = str(uuid.uuid4())
         self.id = str(uuid.uuid4())
         self.name: Optional[str] = None
         self.name: Optional[str] = None
-        self.scene: 'Scene' = globals.get_slot().parent
+        self.scene: 'Scene' = cast('Scene', globals.get_slot().parent)
         self.scene.objects[self.id] = self
         self.scene.objects[self.id] = self
-        self.parent: Object3D = self.scene.stack[-1]
+        self.parent: Union[Object3D, SceneObject] = self.scene.stack[-1]
         self.args: List = list(args)
         self.args: List = list(args)
         self.color: str = '#ffffff'
         self.color: str = '#ffffff'
         self.opacity: float = 1.0
         self.opacity: float = 1.0

+ 44 - 10
nicegui/elements/select.py

@@ -12,11 +12,15 @@ register_component('select', __file__, 'select.js')
 
 
 class Select(ChoiceElement, DisableableElement):
 class Select(ChoiceElement, DisableableElement):
 
 
-    def __init__(self, options: Union[List, Dict], *,
+    def __init__(self,
+                 options: Union[List, Dict], *,
                  label: Optional[str] = None,
                  label: Optional[str] = None,
                  value: Any = None,
                  value: Any = None,
-                 on_change: Optional[Callable] = None,
-                 with_input: bool = False) -> None:
+                 on_change: Optional[Callable[..., Any]] = None,
+                 with_input: bool = False,
+                 multiple: bool = False,
+                 clearable: bool = False,
+                 ) -> None:
         """Dropdown Selection
         """Dropdown Selection
 
 
         The options can be specified as a list of values, or as a dictionary mapping values to labels.
         The options can be specified as a list of values, or as a dictionary mapping values to labels.
@@ -26,16 +30,27 @@ class Select(ChoiceElement, DisableableElement):
         :param value: the initial value
         :param value: the initial value
         :param on_change: callback to execute when selection changes
         :param on_change: callback to execute when selection changes
         :param with_input: whether to show an input field to filter the options
         :param with_input: whether to show an input field to filter the options
+        :param multiple: whether to allow multiple selections
+        :param clearable: whether to add a button to clear the selection
         """
         """
+        self.multiple = multiple
+        if multiple:
+            self.EVENT_ARGS = None
+            if value is None:
+                value = []
+            elif not isinstance(value, list):
+                value = [value]
         super().__init__(tag='select', options=options, value=value, on_change=on_change)
         super().__init__(tag='select', options=options, value=value, on_change=on_change)
         if label is not None:
         if label is not None:
             self._props['label'] = label
             self._props['label'] = label
         if with_input:
         if with_input:
             self.original_options = deepcopy(options)
             self.original_options = deepcopy(options)
             self._props['use-input'] = True
             self._props['use-input'] = True
-            self._props['hide-selected'] = True
+            self._props['hide-selected'] = not multiple
             self._props['fill-input'] = True
             self._props['fill-input'] = True
             self._props['input-debounce'] = 0
             self._props['input-debounce'] = 0
+        self._props['multiple'] = multiple
+        self._props['clearable'] = clearable
 
 
     def on_filter(self, event: Dict) -> None:
     def on_filter(self, event: Dict) -> None:
         self.options = [
         self.options = [
@@ -46,11 +61,30 @@ class Select(ChoiceElement, DisableableElement):
         self.update()
         self.update()
 
 
     def _msg_to_value(self, msg: Dict) -> Any:
     def _msg_to_value(self, msg: Dict) -> Any:
-        return self._values[msg['args']['value']]
+        if self.multiple:
+            if msg['args'] is None:
+                return []
+            else:
+                return [self._values[arg['value']] for arg in msg['args']]
+        else:
+            if msg['args'] is None:
+                return None
+            else:
+                return self._values[msg['args']['value']]
 
 
     def _value_to_model_value(self, value: Any) -> Any:
     def _value_to_model_value(self, value: Any) -> Any:
-        try:
-            index = self._values.index(value)
-            return {'value': index, 'label': self._labels[index]}
-        except ValueError:
-            return None
+        if self.multiple:
+            result = []
+            for item in value or []:
+                try:
+                    index = self._values.index(item)
+                    result.append({'value': index, 'label': self._labels[index]})
+                except ValueError:
+                    pass
+            return result
+        else:
+            try:
+                index = self._values.index(value)
+                return {'value': index, 'label': self._labels[index]}
+            except ValueError:
+                return None

+ 1 - 0
nicegui/elements/separator.py

@@ -7,6 +7,7 @@ class Separator(Element):
         """Separator
         """Separator
 
 
         A separator for cards, menus and other component containers.
         A separator for cards, menus and other component containers.
+        Similar to HTML's <hr> tag.
         """
         """
         super().__init__('q-separator')
         super().__init__('q-separator')
         self._classes = ['nicegui-separator']
         self._classes = ['nicegui-separator']

+ 3 - 2
nicegui/elements/slider.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 from .mixins.value_element import ValueElement
@@ -11,7 +11,8 @@ class Slider(ValueElement, DisableableElement):
                  max: float,
                  max: float,
                  step: float = 1.0,
                  step: float = 1.0,
                  value: Optional[float] = None,
                  value: Optional[float] = None,
-                 on_change: Optional[Callable] = None) -> None:
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Slider
         """Slider
 
 
         :param min: lower bound of the slider
         :param min: lower bound of the slider

+ 3 - 5
nicegui/elements/spinner.py

@@ -2,8 +2,7 @@ from typing import Optional
 
 
 from typing_extensions import Literal
 from typing_extensions import Literal
 
 
-from ..colors import set_text_color
-from ..element import Element
+from .mixins.color_elements import TextColorElement
 
 
 SpinnerTypes = Literal[
 SpinnerTypes = Literal[
     'default',
     'default',
@@ -32,7 +31,7 @@ SpinnerTypes = Literal[
 ]
 ]
 
 
 
 
-class Spinner(Element):
+class Spinner(TextColorElement):
 
 
     def __init__(self,
     def __init__(self,
                  type: Optional[SpinnerTypes] = 'default', *,
                  type: Optional[SpinnerTypes] = 'default', *,
@@ -49,7 +48,6 @@ class Spinner(Element):
         :param color: color of the spinner (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         :param color: color of the spinner (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         :param thickness: thickness of the spinner (applies to the "default" spinner only, default: 5.0)
         :param thickness: thickness of the spinner (applies to the "default" spinner only, default: 5.0)
         """
         """
-        super().__init__('q-spinner' if type == 'default' else f'q-spinner-{type}')
+        super().__init__(tag='q-spinner' if type == 'default' else f'q-spinner-{type}', text_color=color)
         self._props['size'] = size
         self._props['size'] = size
-        set_text_color(self, color)
         self._props['thickness'] = thickness
         self._props['thickness'] = thickness

+ 3 - 2
nicegui/elements/splitter.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional, Tuple
+from typing import Any, Callable, Optional, Tuple
 
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 from .mixins.value_element import ValueElement
@@ -11,7 +11,8 @@ class Splitter(ValueElement, DisableableElement):
                  reverse: Optional[bool] = False,
                  reverse: Optional[bool] = False,
                  limits: Optional[Tuple[float, float]] = (0, 100),
                  limits: Optional[Tuple[float, float]] = (0, 100),
                  value: Optional[float] = 50,
                  value: Optional[float] = 50,
-                 on_change: Optional[Callable] = None) -> None:
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Splitter
         """Splitter
 
 
         The `ui.splitter` element divides the screen space into resizable sections, 
         The `ui.splitter` element divides the screen space into resizable sections, 

+ 69 - 0
nicegui/elements/stepper.py

@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+from typing import Any, Callable, Optional, Union, cast
+
+from .. import globals
+from ..element import Element
+from .mixins.disableable_element import DisableableElement
+from .mixins.value_element import ValueElement
+
+
+class Stepper(ValueElement):
+
+    def __init__(self, *,
+                 value: Union[str, Step, None] = None,
+                 on_value_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
+        """Stepper
+
+        This element represents `Quasar's QStepper <https://quasar.dev/vue-components/stepper#qstepper-api>`_ component.
+        It contains individual steps.
+
+        :param value: `ui.step` or name of the step to be initially selected (default: `None` meaning the first step)
+        :param on_value_change: callback to be executed when the selected step changes
+        """
+        super().__init__(tag='q-stepper', value=value, on_value_change=on_value_change)
+
+    def _value_to_model_value(self, value: Any) -> Any:
+        return value._props['name'] if isinstance(value, Step) else value
+
+    def on_value_change(self, value: Any) -> None:
+        super().on_value_change(value)
+        names = [step._props['name'] for step in self]
+        for i, step in enumerate(self):
+            done = i < names.index(value) if value in names else False
+            step.props(f':done={done}')
+
+    def next(self) -> None:
+        self.run_method('next')
+
+    def previous(self) -> None:
+        self.run_method('previous')
+
+
+class Step(DisableableElement):
+
+    def __init__(self, name: str, title: Optional[str] = None, icon: Optional[str] = None) -> None:
+        """Step
+
+        This element represents `Quasar's QStep <https://quasar.dev/vue-components/stepper#qstep-api>`_ component.
+        It is a child of a `ui.stepper` element.
+
+        :param name: name of the step (will be the value of the `ui.stepper` element)
+        :param title: title of the step (default: `None`, meaning the same as `name`)
+        :param icon: icon of the step (default: `None`)
+        """
+        super().__init__(tag='q-step')
+        self._props['name'] = name
+        self._props['title'] = title if title is not None else name
+        if icon:
+            self._props['icon'] = icon
+        self.stepper = cast(ValueElement, globals.get_slot().parent)
+        if self.stepper.value is None:
+            self.stepper.value = name
+
+
+class StepperNavigation(Element):
+
+    def __init__(self) -> None:
+        super().__init__('q-stepper-navigation')

+ 2 - 2
nicegui/elements/switch.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
 from .mixins.text_element import TextElement
@@ -7,7 +7,7 @@ from .mixins.value_element import ValueElement
 
 
 class Switch(TextElement, ValueElement, DisableableElement):
 class Switch(TextElement, ValueElement, DisableableElement):
 
 
-    def __init__(self, text: str = '', *, value: bool = False, on_change: Optional[Callable] = None) -> None:
+    def __init__(self, text: str = '', *, value: bool = False, on_change: Optional[Callable[..., Any]] = None) -> None:
         """Switch
         """Switch
 
 
         :param text: the label to display next to the switch
         :param text: the label to display next to the switch

+ 30 - 0
nicegui/elements/table.js

@@ -0,0 +1,30 @@
+export default {
+  template: `
+    <q-table v-bind="$attrs" :columns="convertedColumns">
+      <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+        <slot :name="slot" v-bind="slotProps || {}" />
+      </template>
+    </q-table>
+  `,
+  props: {
+    columns: Array,
+  },
+  computed: {
+    convertedColumns() {
+      return this.columns.map((column) => {
+        const convertedColumn = { ...column };
+        for (const attr in convertedColumn) {
+          if (attr.startsWith(":")) {
+            try {
+              convertedColumn[attr.slice(1)] = new Function("return " + convertedColumn[attr])();
+              delete convertedColumn[attr];
+            } catch (e) {
+              console.error(`Error while converting ${attr} attribute to function:`, e);
+            }
+          }
+        }
+        return convertedColumn;
+      });
+    },
+  },
+};

+ 7 - 4
nicegui/elements/table.py

@@ -1,11 +1,14 @@
-from typing import Callable, Dict, List, Optional
+from typing import Any, Callable, Dict, List, Optional
 
 
 from typing_extensions import Literal
 from typing_extensions import Literal
 
 
+from ..dependencies import register_component
 from ..element import Element
 from ..element import Element
 from ..events import TableSelectionEventArguments, handle_event
 from ..events import TableSelectionEventArguments, handle_event
 from .mixins.filter_element import FilterElement
 from .mixins.filter_element import FilterElement
 
 
+register_component('nicegui-table', __file__, 'table.js')
+
 
 
 class Table(FilterElement):
 class Table(FilterElement):
 
 
@@ -16,7 +19,7 @@ class Table(FilterElement):
                  title: Optional[str] = None,
                  title: Optional[str] = None,
                  selection: Optional[Literal['single', 'multiple']] = None,
                  selection: Optional[Literal['single', 'multiple']] = None,
                  pagination: Optional[int] = None,
                  pagination: Optional[int] = None,
-                 on_select: Optional[Callable] = None,
+                 on_select: Optional[Callable[..., Any]] = None,
                  ) -> None:
                  ) -> None:
         """Table
         """Table
 
 
@@ -30,9 +33,9 @@ class Table(FilterElement):
         :param pagination: number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`)
         :param pagination: number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`)
         :param on_select: callback which is invoked when the selection changes
         :param on_select: callback which is invoked when the selection changes
 
 
-        If selection is 'single' or 'multiple', then a `selection` property is accessible containing the selected rows.
+        If selection is 'single' or 'multiple', then a `selected` property is accessible containing the selected rows.
         """
         """
-        super().__init__(tag='q-table')
+        super().__init__(tag='nicegui-table')
 
 
         self.rows = rows
         self.rows = rows
         self.row_key = row_key
         self.row_key = row_key

+ 29 - 14
nicegui/elements/tabs.py

@@ -1,4 +1,6 @@
-from typing import Any, Callable, Optional
+from __future__ import annotations
+
+from typing import Any, Callable, Optional, Union
 
 
 from .. import globals
 from .. import globals
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
@@ -8,18 +10,21 @@ from .mixins.value_element import ValueElement
 class Tabs(ValueElement):
 class Tabs(ValueElement):
 
 
     def __init__(self, *,
     def __init__(self, *,
-                 value: Any = None,
-                 on_change: Optional[Callable] = None) -> None:
+                 value: Union[Tab, TabPanel, None] = None,
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Tabs
         """Tabs
 
 
         This element represents `Quasar's QTabs <https://quasar.dev/vue-components/tabs#qtabs-api>`_ component.
         This element represents `Quasar's QTabs <https://quasar.dev/vue-components/tabs#qtabs-api>`_ component.
         It contains individual tabs.
         It contains individual tabs.
 
 
-        :param value: name of the tab to be initially selected
+        :param value: `ui.tab`, `ui.tab_panel`, or name of the tab to be initially selected
         :param on_change: callback to be executed when the selected tab changes
         :param on_change: callback to be executed when the selected tab changes
         """
         """
         super().__init__(tag='q-tabs', value=value, on_value_change=on_change)
         super().__init__(tag='q-tabs', value=value, on_value_change=on_change)
-        self.panels: Optional[TabPanels] = None
+
+    def _value_to_model_value(self, value: Any) -> Any:
+        return value._props['name'] if isinstance(value, Tab) or isinstance(value, TabPanel) else value
 
 
 
 
 class Tab(DisableableElement):
 class Tab(DisableableElement):
@@ -28,9 +33,9 @@ class Tab(DisableableElement):
         """Tab
         """Tab
 
 
         This element represents `Quasar's QTab <https://quasar.dev/vue-components/tabs#qtab-api>`_ component.
         This element represents `Quasar's QTab <https://quasar.dev/vue-components/tabs#qtab-api>`_ component.
-        It is a child of a `Tabs` element.
+        It is a child of a `ui.tabs` element.
 
 
-        :param name: name of the tab (the value of the `Tabs` element)
+        :param name: name of the tab (will be the value of the `ui.tabs` element)
         :param label: label of the tab (default: `None`, meaning the same as `name`)
         :param label: label of the tab (default: `None`, meaning the same as `name`)
         :param icon: icon of the tab (default: `None`)
         :param icon: icon of the tab (default: `None`)
         """
         """
@@ -46,34 +51,44 @@ class TabPanels(ValueElement):
 
 
     def __init__(self,
     def __init__(self,
                  tabs: Tabs, *,
                  tabs: Tabs, *,
-                 value: Any = None,
-                 on_change: Optional[Callable] = None,
+                 value: Union[Tab, TabPanel, None] = None,
+                 on_change: Optional[Callable[..., Any]] = None,
                  animated: bool = True,
                  animated: bool = True,
+                 keep_alive: bool = True,
                  ) -> None:
                  ) -> None:
         """Tab Panels
         """Tab Panels
 
 
         This element represents `Quasar's QTabPanels <https://quasar.dev/vue-components/tab-panels#qtabpanels-api>`_ component.
         This element represents `Quasar's QTabPanels <https://quasar.dev/vue-components/tab-panels#qtabpanels-api>`_ component.
         It contains individual tab panels.
         It contains individual tab panels.
 
 
-        :param tabs: the `Tabs` element that controls this element
-        :param value: name of the tab panel to be initially visible
+        To avoid issues with dynamic elements when switching tabs,
+        this element uses Vue's `keep-alive <https://vuejs.org/guide/built-ins/keep-alive.html>`_ component.
+        If client-side performance is an issue, you can disable this feature.
+
+        :param tabs: the `ui.tabs` element that controls this element
+        :param value: `ui.tab`, `ui.tab_panel`, or name of the tab panel to be initially visible
         :param on_change: callback to be executed when the visible tab panel changes
         :param on_change: callback to be executed when the visible tab panel changes
         :param animated: whether the tab panels should be animated (default: `True`)
         :param animated: whether the tab panels should be animated (default: `True`)
+        :param keep_alive: whether to use Vue's keep-alive component on the content (default: `True`)
         """
         """
         super().__init__(tag='q-tab-panels', value=value, on_value_change=on_change)
         super().__init__(tag='q-tab-panels', value=value, on_value_change=on_change)
         tabs.bind_value(self, 'value')
         tabs.bind_value(self, 'value')
         self._props['animated'] = animated
         self._props['animated'] = animated
+        self._props['keep-alive'] = keep_alive
+
+    def _value_to_model_value(self, value: Any) -> Any:
+        return value._props['name'] if isinstance(value, Tab) or isinstance(value, TabPanel) else value
 
 
 
 
 class TabPanel(DisableableElement):
 class TabPanel(DisableableElement):
 
 
-    def __init__(self, name: str) -> None:
+    def __init__(self, name: Union[Tab, str]) -> None:
         """Tab Panel
         """Tab Panel
 
 
         This element represents `Quasar's QTabPanel <https://quasar.dev/vue-components/tab-panels#qtabpanel-api>`_ component.
         This element represents `Quasar's QTabPanel <https://quasar.dev/vue-components/tab-panels#qtabpanel-api>`_ component.
         It is a child of a `TabPanels` element.
         It is a child of a `TabPanels` element.
 
 
-        :param name: name of the tab panel (the value of the `TabPanels` element)
+        :param name: `ui.tab` or the name of a tab element
         """
         """
         super().__init__(tag='q-tab-panel')
         super().__init__(tag='q-tab-panel')
-        self._props['name'] = name
+        self._props['name'] = name._props['name'] if isinstance(name, Tab) else name

+ 8 - 4
nicegui/elements/textarea.py

@@ -1,4 +1,4 @@
-from typing import Callable, Dict, Optional
+from typing import Any, Callable, Dict, Optional
 
 
 from .input import Input
 from .input import Input
 
 
@@ -9,18 +9,22 @@ class Textarea(Input):
                  label: Optional[str] = None, *,
                  label: Optional[str] = None, *,
                  placeholder: Optional[str] = None,
                  placeholder: Optional[str] = None,
                  value: str = '',
                  value: str = '',
-                 on_change: Optional[Callable] = None,
-                 validation: Dict[str, Callable] = {}) -> None:
+                 on_change: Optional[Callable[..., Any]] = None,
+                 validation: Dict[str, Callable[..., bool]] = {},
+                 ) -> None:
         """Textarea
         """Textarea
 
 
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.
         The ``type`` is set to ``textarea`` to create a multi-line text input.
         The ``type`` is set to ``textarea`` to create a multi-line text input.
 
 
+        You can use the `validation` parameter to define a dictionary of validation rules.
+        The key of the first rule that fails will be displayed as an error message.
+
         :param label: displayed name for the textarea
         :param label: displayed name for the textarea
         :param placeholder: text to show if no value is entered
         :param placeholder: text to show if no value is entered
         :param value: the initial value of the field
         :param value: the initial value of the field
         :param on_change: callback to execute when the value changes
         :param on_change: callback to execute when the value changes
-        :param validation: dictionary of validation rules, e.g. ``{'Too short!': lambda value: len(value) < 3}``
+        :param validation: dictionary of validation rules, e.g. ``{'Too long!': lambda value: len(value) < 3}``
         """
         """
         super().__init__(label, placeholder=placeholder, value=value, on_change=on_change, validation=validation)
         super().__init__(label, placeholder=placeholder, value=value, on_change=on_change, validation=validation)
         self._props['type'] = 'textarea'
         self._props['type'] = 'textarea'

+ 4 - 4
nicegui/elements/time.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 from .mixins.value_element import ValueElement
@@ -7,10 +7,10 @@ from .mixins.value_element import ValueElement
 class Time(ValueElement, DisableableElement):
 class Time(ValueElement, DisableableElement):
 
 
     def __init__(self,
     def __init__(self,
-                 value: Optional[str] = None,
-                 *,
+                 value: Optional[str] = None, *,
                  mask: str = 'HH:mm',
                  mask: str = 'HH:mm',
-                 on_change: Optional[Callable] = None) -> None:
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Time Input
         """Time Input
 
 
         This element is based on Quasar's `QTime <https://quasar.dev/vue-components/date>`_ component.
         This element is based on Quasar's `QTime <https://quasar.dev/vue-components/date>`_ component.

+ 5 - 1
nicegui/elements/toggle.py

@@ -6,7 +6,11 @@ from .mixins.disableable_element import DisableableElement
 
 
 class Toggle(ChoiceElement, DisableableElement):
 class Toggle(ChoiceElement, DisableableElement):
 
 
-    def __init__(self, options: Union[List, Dict], *, value: Any = None, on_change: Optional[Callable] = None) -> None:
+    def __init__(self,
+                 options: Union[List, Dict], *,
+                 value: Any = None,
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Toggle
         """Toggle
 
 
         The options can be specified as a list of values, or as a dictionary mapping values to labels.
         The options can be specified as a list of values, or as a dictionary mapping values to labels.

+ 4 - 3
nicegui/elements/tree.py

@@ -11,9 +11,10 @@ class Tree(Element):
                  node_key: str = 'id',
                  node_key: str = 'id',
                  label_key: str = 'label',
                  label_key: str = 'label',
                  children_key: str = 'children',
                  children_key: str = 'children',
-                 on_select: Optional[Callable] = None,
-                 on_expand: Optional[Callable] = None,
-                 on_tick: Optional[Callable] = None) -> None:
+                 on_select: Optional[Callable[..., Any]] = None,
+                 on_expand: Optional[Callable[..., Any]] = None,
+                 on_tick: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Tree
         """Tree
 
 
         Display hierarchical data using Quasar's `QTree <https://quasar.dev/vue-components/tree>`_ component.
         Display hierarchical data using Quasar's `QTree <https://quasar.dev/vue-components/tree>`_ component.

+ 7 - 3
nicegui/elements/upload.js

@@ -7,11 +7,15 @@ export default {
     </q-uploader>
     </q-uploader>
   `,
   `,
   mounted() {
   mounted() {
-    setTimeout(() => {
-      this.computed_url = (window.path_prefix || "") + this.url;
-    }, 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+    setTimeout(() => this.compute_url(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_url();
   },
   },
   methods: {
   methods: {
+    compute_url() {
+      this.computed_url = (this.url.startsWith("/") ? window.path_prefix : "") + this.url;
+    },
     reset() {
     reset() {
       this.$refs.uploader.reset();
       this.$refs.uploader.reset();
     },
     },

+ 9 - 7
nicegui/elements/upload.py

@@ -1,6 +1,7 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Dict, Optional
 
 
-from fastapi import Request, Response
+from fastapi import Request
+from starlette.datastructures import UploadFile
 
 
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..events import EventArguments, UploadEventArguments, handle_event
 from ..events import EventArguments, UploadEventArguments, handle_event
@@ -17,8 +18,8 @@ class Upload(DisableableElement):
                  max_file_size: Optional[int] = None,
                  max_file_size: Optional[int] = None,
                  max_total_size: Optional[int] = None,
                  max_total_size: Optional[int] = None,
                  max_files: Optional[int] = None,
                  max_files: Optional[int] = None,
-                 on_upload: Optional[Callable] = None,
-                 on_rejected: Optional[Callable] = None,
+                 on_upload: Optional[Callable[..., Any]] = None,
+                 on_rejected: Optional[Callable[..., Any]] = None,
                  label: str = '',
                  label: str = '',
                  auto_upload: bool = False,
                  auto_upload: bool = False,
                  ) -> None:
                  ) -> None:
@@ -51,14 +52,15 @@ class Upload(DisableableElement):
             self._props['max-files'] = max_files
             self._props['max-files'] = max_files
 
 
         @app.post(self._props['url'])
         @app.post(self._props['url'])
-        async def upload_route(request: Request) -> Response:
+        async def upload_route(request: Request) -> Dict[str, str]:
             for data in (await request.form()).values():
             for data in (await request.form()).values():
+                assert isinstance(data, UploadFile)
                 args = UploadEventArguments(
                 args = UploadEventArguments(
                     sender=self,
                     sender=self,
                     client=self.client,
                     client=self.client,
                     content=data.file,
                     content=data.file,
-                    name=data.filename,
-                    type=data.content_type,
+                    name=data.filename or '',
+                    type=data.content_type or '',
                 )
                 )
                 handle_event(on_upload, args)
                 handle_event(on_upload, args)
             return {'upload': 'success'}
             return {'upload': 'success'}

+ 20 - 6
nicegui/elements/video.js

@@ -1,14 +1,28 @@
 export default {
 export default {
-  template: `<video :controls="controls" :autoplay="autoplay" :muted="muted" :src="src" />`,
-  methods: {
-    seek(seconds) {
-      this.$el.currentTime = seconds;
-    },
-  },
+  template: `<video :controls="controls" :autoplay="autoplay" :muted="muted" :src="computed_src" />`,
   props: {
   props: {
     controls: Boolean,
     controls: Boolean,
     autoplay: Boolean,
     autoplay: Boolean,
     muted: Boolean,
     muted: Boolean,
     src: String,
     src: String,
   },
   },
+  data: function () {
+    return {
+      computed_src: undefined,
+    };
+  },
+  mounted() {
+    setTimeout(() => this.compute_src(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_src();
+  },
+  methods: {
+    compute_src() {
+      this.computed_src = (this.src.startsWith("/") ? window.path_prefix : "") + this.src;
+    },
+    seek(seconds) {
+      this.$el.currentTime = seconds;
+    },
+  },
 };
 };

+ 7 - 2
nicegui/elements/video.py

@@ -1,5 +1,8 @@
 import warnings
 import warnings
+from pathlib import Path
+from typing import Union
 
 
+from .. import globals
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..element import Element
 from ..element import Element
 
 
@@ -8,7 +11,7 @@ register_component('video', __file__, 'video.js')
 
 
 class Video(Element):
 class Video(Element):
 
 
-    def __init__(self, src: str, *,
+    def __init__(self, src: Union[str, Path], *,
                  controls: bool = True,
                  controls: bool = True,
                  autoplay: bool = False,
                  autoplay: bool = False,
                  muted: bool = False,
                  muted: bool = False,
@@ -17,7 +20,7 @@ class Video(Element):
                  ) -> None:
                  ) -> None:
         """Video
         """Video
 
 
-        :param src: URL of the video source
+        :param src: URL or local file path of the video source
         :param controls: whether to show the video controls, like play, pause, and volume (default: `True`)
         :param controls: whether to show the video controls, like play, pause, and volume (default: `True`)
         :param autoplay: whether to start playing the video automatically (default: `False`)
         :param autoplay: whether to start playing the video automatically (default: `False`)
         :param muted: whether the video should be initially muted (default: `False`)
         :param muted: whether the video should be initially muted (default: `False`)
@@ -27,6 +30,8 @@ class Video(Element):
         for a list of events you can subscribe to using the generic event subscription `on()`.
         for a list of events you can subscribe to using the generic event subscription `on()`.
         """
         """
         super().__init__('video')
         super().__init__('video')
+        if Path(src).is_file():
+            src = globals.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['src'] = src
         self._props['controls'] = controls
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay
         self._props['autoplay'] = autoplay

+ 5 - 2
nicegui/event_listener.py

@@ -1,6 +1,8 @@
 import uuid
 import uuid
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
-from typing import Any, Callable, Dict, List
+from typing import Any, Callable, Dict, List, Optional
+
+from fastapi import Request
 
 
 from .helpers import KWONLY_SLOTS
 from .helpers import KWONLY_SLOTS
 
 
@@ -10,11 +12,12 @@ class EventListener:
     id: str = field(init=False)
     id: str = field(init=False)
     element_id: int
     element_id: int
     type: str
     type: str
-    args: List[str]
+    args: Optional[List[str]]
     handler: Callable
     handler: Callable
     throttle: float
     throttle: float
     leading_events: bool
     leading_events: bool
     trailing_events: bool
     trailing_events: bool
+    request: Optional[Request]
 
 
     def __post_init__(self) -> None:
     def __post_init__(self) -> None:
         self.id = str(uuid.uuid4())
         self.id = str(uuid.uuid4())

+ 10 - 8
nicegui/events.py

@@ -1,9 +1,9 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
-from inspect import signature
-from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, List, Optional, Union
+from inspect import Parameter, signature
+from typing import TYPE_CHECKING, Any, Awaitable, BinaryIO, Callable, Dict, List, Optional, Union
 
 
 from . import background_tasks, globals
 from . import background_tasks, globals
-from .helpers import KWONLY_SLOTS, is_coroutine
+from .helpers import KWONLY_SLOTS
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .client import Client
     from .client import Client
@@ -268,18 +268,20 @@ class KeyEventArguments(EventArguments):
     modifiers: KeyboardModifiers
     modifiers: KeyboardModifiers
 
 
 
 
-def handle_event(handler: Optional[Callable],
+def handle_event(handler: Optional[Callable[..., Any]],
                  arguments: Union[EventArguments, Dict], *,
                  arguments: Union[EventArguments, Dict], *,
                  sender: Optional['Element'] = None) -> None:
                  sender: Optional['Element'] = None) -> None:
+    if handler is None:
+        return
     try:
     try:
-        if handler is None:
-            return
-        no_arguments = not signature(handler).parameters
+        no_arguments = not any(p.default is Parameter.empty for p in signature(handler).parameters.values())
         sender = arguments.sender if isinstance(arguments, EventArguments) else sender
         sender = arguments.sender if isinstance(arguments, EventArguments) else sender
         assert sender is not None and sender.parent_slot is not None
         assert sender is not None and sender.parent_slot is not None
+        if sender.is_ignoring_events:
+            return
         with sender.parent_slot:
         with sender.parent_slot:
             result = handler() if no_arguments else handler(arguments)
             result = handler() if no_arguments else handler(arguments)
-        if is_coroutine(handler):
+        if isinstance(result, Awaitable):
             async def wait_for_result():
             async def wait_for_result():
                 with sender.parent_slot:
                 with sender.parent_slot:
                     await result
                     await result

+ 36 - 16
nicegui/favicon.py

@@ -1,42 +1,57 @@
+import base64
+import io
 import urllib.parse
 import urllib.parse
 from pathlib import Path
 from pathlib import Path
-from typing import TYPE_CHECKING, Optional
+from typing import TYPE_CHECKING, Optional, Tuple, Union
 
 
-from fastapi.responses import FileResponse
+from fastapi.responses import FileResponse, Response, StreamingResponse
 
 
 from . import __version__, globals
 from . import __version__, globals
+from .helpers import is_file
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .page import page
     from .page import page
 
 
 
 
-def create_favicon_route(path: str, favicon: Optional[str]) -> None:
-    if favicon and (is_remote_url(favicon) or is_char(favicon)):
-        return
-    fallback = Path(__file__).parent / 'static' / 'favicon.ico'
-    path = f'{"" if path == "/" else path}/favicon.ico'
-    globals.app.remove_route(path)
-    globals.app.add_route(path, lambda _: FileResponse(favicon or globals.favicon or fallback))
+def create_favicon_route(path: str, favicon: Optional[Union[str, Path]]) -> None:
+    if is_file(favicon):
+        globals.app.add_route(f'{path}/favicon.ico', lambda _: FileResponse(favicon))
 
 
 
 
 def get_favicon_url(page: 'page', prefix: str) -> str:
 def get_favicon_url(page: 'page', prefix: str) -> str:
     favicon = page.favicon or globals.favicon
     favicon = page.favicon or globals.favicon
-    if favicon and is_remote_url(favicon):
-        return favicon
-    elif not favicon:
+    if not favicon:
         return f'{prefix}/_nicegui/{__version__}/static/favicon.ico'
         return f'{prefix}/_nicegui/{__version__}/static/favicon.ico'
+    favicon = str(favicon).strip()
+    if is_remote_url(favicon):
+        return favicon
     elif is_data_url(favicon):
     elif is_data_url(favicon):
         return favicon
         return favicon
     elif is_svg(favicon):
     elif is_svg(favicon):
         return svg_to_data_url(favicon)
         return svg_to_data_url(favicon)
     elif is_char(favicon):
     elif is_char(favicon):
-        return char_to_data_url(favicon)
+        return svg_to_data_url(char_to_svg(favicon))
     elif page.path == '/':
     elif page.path == '/':
         return f'{prefix}/favicon.ico'
         return f'{prefix}/favicon.ico'
     else:
     else:
         return f'{prefix}{page.path}/favicon.ico'
         return f'{prefix}{page.path}/favicon.ico'
 
 
 
 
+def get_favicon_response() -> Response:
+    if not globals.favicon:
+        raise ValueError(f'invalid favicon: {globals.favicon}')
+    favicon = str(globals.favicon).strip()
+    if is_svg(favicon):
+        return Response(favicon, media_type='image/svg+xml')
+    elif is_data_url(favicon):
+        media_type, bytes = data_url_to_bytes(favicon)
+        return StreamingResponse(io.BytesIO(bytes), media_type=media_type)
+    elif is_char(favicon):
+        return Response(char_to_svg(favicon), media_type='image/svg+xml')
+    else:
+        raise ValueError(f'invalid favicon: {favicon}')
+
+
 def is_remote_url(favicon: str) -> bool:
 def is_remote_url(favicon: str) -> bool:
     return favicon.startswith('http://') or favicon.startswith('https://')
     return favicon.startswith('http://') or favicon.startswith('https://')
 
 
@@ -53,8 +68,8 @@ def is_data_url(favicon: str) -> bool:
     return favicon.startswith('data:')
     return favicon.startswith('data:')
 
 
 
 
-def char_to_data_url(char: str) -> str:
-    svg = f'''
+def char_to_svg(char: str) -> str:
+    return f'''
         <svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg" >
         <svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg" >
             <style>
             <style>
                 @supports (-moz-appearance:none) {{
                 @supports (-moz-appearance:none) {{
@@ -70,9 +85,14 @@ def char_to_data_url(char: str) -> str:
             <text y=".9em" font-size="128" font-family="Georgia, sans-serif">{char}</text>
             <text y=".9em" font-size="128" font-family="Georgia, sans-serif">{char}</text>
         </svg>
         </svg>
     '''
     '''
-    return svg_to_data_url(svg)
 
 
 
 
 def svg_to_data_url(svg: str) -> str:
 def svg_to_data_url(svg: str) -> str:
     svg_urlencoded = urllib.parse.quote(svg)
     svg_urlencoded = urllib.parse.quote(svg)
     return f'data:image/svg+xml,{svg_urlencoded}'
     return f'data:image/svg+xml,{svg_urlencoded}'
+
+
+def data_url_to_bytes(data_url: str) -> Tuple[str, bytes]:
+    media_type, base64_image = data_url.split(',', 1)
+    media_type = media_type.split(':')[1].split(';')[0]
+    return media_type, base64.b64decode(base64_image)

+ 2 - 2
nicegui/functions/javascript.py

@@ -1,10 +1,10 @@
-from typing import Optional
+from typing import Any, Optional
 
 
 from .. import globals
 from .. import globals
 
 
 
 
 async def run_javascript(code: str, *,
 async def run_javascript(code: str, *,
-                         respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
+                         respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[Any]:
     """Run JavaScript
     """Run JavaScript
 
 
     This function runs arbitrary JavaScript code on a page that is executed in the browser.
     This function runs arbitrary JavaScript code on a page that is executed in the browser.

+ 1 - 1
nicegui/functions/notify.py

@@ -16,7 +16,7 @@ def notify(message: Any, *,
            type: Optional[Literal['positive', 'negative', 'warning', 'info', 'ongoing']] = None,
            type: Optional[Literal['positive', 'negative', 'warning', 'info', 'ongoing']] = None,
            color: Optional[str] = None,
            color: Optional[str] = None,
            multi_line: bool = False,
            multi_line: bool = False,
-           **kwargs,
+           **kwargs: Any,
            ) -> None:
            ) -> None:
     """Notification
     """Notification
 
 

Some files were not shown because too many files changed in this diff