فهرست منبع

Merge branch 'main' into feature/dependencies

# Conflicts:
#	nicegui/elements/aggrid.py
#	nicegui/elements/audio.py
#	nicegui/elements/interactive_image.py
#	nicegui/elements/log.py
#	nicegui/elements/upload.py
#	nicegui/elements/video.py
#	nicegui/functions/refreshable.py
#	nicegui/templates/index.html
Falko Schindler 1 سال پیش
والد
کامیت
927e9340a8
100فایلهای تغییر یافته به همراه2374 افزوده شده و 661 حذف شده
  1. 2 1
      .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. 6 5
      examples/single_page_app/main.py
  17. 2 2
      examples/sqlite_database/main.py
  18. 1 1
      examples/table_and_slots/main.py
  19. 32 40
      examples/todo_list/main.py
  20. 5 3
      fetch_tailwind.py
  21. 1 1
      fly.toml
  22. 17 12
      main.py
  23. 2 0
      mypy.ini
  24. 2 1
      nicegui/__init__.py
  25. 44 0
      nicegui/api_router.py
  26. 90 7
      nicegui/app.py
  27. 3 1
      nicegui/background_tasks.py
  28. 22 12
      nicegui/binding.py
  29. 2 2
      nicegui/client.py
  30. 0 33
      nicegui/colors.py
  31. 5 2
      nicegui/element.py
  32. 3 2
      nicegui/elements/aggrid.py
  33. 6 2
      nicegui/elements/audio.py
  34. 4 7
      nicegui/elements/avatar.py
  35. 4 5
      nicegui/elements/badge.py
  36. 8 4
      nicegui/elements/button.py
  37. 2 2
      nicegui/elements/color_input.py
  38. 3 6
      nicegui/elements/icon.py
  39. 5 2
      nicegui/elements/image.py
  40. 2 1
      nicegui/elements/input.py
  41. 3 3
      nicegui/elements/interactive_image.py
  42. 3 4
      nicegui/elements/knob.py
  43. 12 3
      nicegui/elements/link.py
  44. 5 1
      nicegui/elements/log.js
  45. 5 2
      nicegui/elements/log.py
  46. 1 1
      nicegui/elements/mermaid.py
  47. 41 0
      nicegui/elements/mixins/color_elements.py
  48. 8 0
      nicegui/elements/mixins/disableable_element.py
  49. 9 4
      nicegui/elements/mixins/source_element.py
  50. 4 4
      nicegui/elements/mixins/value_element.py
  51. 9 2
      nicegui/elements/mixins/visibility.py
  52. 5 7
      nicegui/elements/progress.py
  53. 4 4
      nicegui/elements/scene_object3d.py
  54. 31 7
      nicegui/elements/select.py
  55. 3 5
      nicegui/elements/spinner.py
  56. 69 0
      nicegui/elements/stepper.py
  57. 1 1
      nicegui/elements/table.py
  58. 26 12
      nicegui/elements/tabs.py
  59. 7 5
      nicegui/elements/upload.py
  60. 6 2
      nicegui/elements/video.py
  61. 5 2
      nicegui/event_listener.py
  62. 9 7
      nicegui/events.py
  63. 36 16
      nicegui/favicon.py
  64. 2 2
      nicegui/functions/javascript.py
  65. 9 7
      nicegui/functions/refreshable.py
  66. 10 5
      nicegui/functions/timer.py
  67. 4 3
      nicegui/globals.py
  68. 72 2
      nicegui/helpers.py
  69. 130 1
      nicegui/native.py
  70. 66 9
      nicegui/native_mode.py
  71. 13 4
      nicegui/nicegui.py
  72. 212 0
      nicegui/observables.py
  73. 3 3
      nicegui/outbox.py
  74. 17 6
      nicegui/page.py
  75. 27 5
      nicegui/run.py
  76. 6 2
      nicegui/run_with.py
  77. 128 0
      nicegui/storage.py
  78. 5 3
      nicegui/tailwind.py
  79. 6 5
      nicegui/templates/index.html
  80. 6 0
      nicegui/ui.py
  81. 187 260
      poetry.lock
  82. 2 0
      pyproject.toml
  83. 4 0
      test_startup.sh
  84. 5 0
      tests/conftest.py
  85. 2 1
      tests/requirements.txt
  86. 5 3
      tests/screen.py
  87. 31 0
      tests/test_api_router.py
  88. 24 0
      tests/test_events.py
  89. 92 0
      tests/test_favicon.py
  90. 3 0
      tests/test_helpers.py
  91. 18 0
      tests/test_link.py
  92. 9 0
      tests/test_log.py
  93. 121 0
      tests/test_observables.py
  94. 14 0
      tests/test_select.py
  95. 83 0
      tests/test_serving_files.py
  96. 27 0
      tests/test_stepper.py
  97. 134 0
      tests/test_storage.py
  98. 20 1
      tests/test_tabs.py
  99. 3 3
      tests/test_time.py
  100. 153 0
      website/build_search_index.py

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

@@ -9,7 +9,7 @@ jobs:
         python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
       fail-fast: false
     runs-on: ubuntu-latest
-    timeout-minutes: 20
+    timeout-minutes: 40
     steps:
       - uses: actions/checkout@v3
       - name: set up Python
@@ -26,6 +26,7 @@ jobs:
           poetry install
           # install packages to run the examples
           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
           pip install importlib-resources
       - name: test startup

+ 2 - 2
.gitignore

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

+ 3 - 3
CITATION.cff

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

+ 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
 - notifications, dialogs and menus to provide state of the art user interaction
 - shared and individual web pages
+- easy-to-use per-user and general persistence
 - ability to add custom routes and data responses
 - capture keyboard input for global shortcuts etc.
 - customize look by defining primary, secondary and accent colors

+ 15 - 44
examples/authentication/main.py

@@ -1,71 +1,42 @@
 #!/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
-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.
-'''
-
-import os
-import uuid
-from typing import Dict
-
-from fastapi import Request
+"""
 from fastapi.responses import RedirectResponse
-from starlette.middleware.sessions import SessionMiddleware
 
 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('/')
-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')
-    session = session_info[request.session['id']]
     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')
-def login(request: Request) -> None:
+def login() -> None:
     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('/')
         else:
             ui.notify('Wrong username or password', color='negative')
 
-    if is_authenticated(request):
+    if app.storage.user.get('authenticated', False):
         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'):
         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').on('keydown.enter', try_login).props('type=password')
         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 nicegui import ui
+from nicegui import app, ui
 
 
-def init(app: FastAPI) -> None:
+def init(fastapi_app: FastAPI) -> None:
     @ui.page('/show')
     def show():
         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 typing import Dict, Optional
 
@@ -27,6 +28,7 @@ class local_file_picker(ui.dialog):
         self.show_hidden_files = show_hidden_files
 
         with self, ui.card():
+            self.add_drives_toggle()
             self.grid = ui.aggrid({
                 'columnDefs': [{'field': 'name', 'headerName': 'File'}],
                 'rowSelection': 'multiple' if multiple else 'single',
@@ -36,6 +38,16 @@ class local_file_picker(ui.dialog):
                 ui.button('Ok', on_click=self._handle_ok)
         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:
         paths = list(self.path.glob('*'))
         if not self.show_hidden_files:
@@ -58,7 +70,7 @@ class local_file_picker(ui.dialog):
             })
         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'])
         if self.path.is_dir():
             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)
     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()

+ 13 - 24
examples/menu_and_tabs/main.py

@@ -1,24 +1,12 @@
 #!/usr/bin/env python3
-from typing import Dict
-
 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:
     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:
     ui.label('Footer')
@@ -27,13 +15,14 @@ with ui.left_drawer().classes('bg-blue-100') as left_drawer:
     ui.label('Side menu')
 
 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()

+ 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:
 
     @ui.page('/a')
-    def example_page():
+    def example_page_a():
         with theme.frame('- Example A -'):
             ui.label('Example A').classes('text-h4 text-grey-8')
 
     @ui.page('/b')
-    def example_page():
+    def example_page_b():
         with theme.frame('- Example B -'):
             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
+import example_c
 import example_pages
 import home_page
 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
@@ -16,4 +17,7 @@ def index_page() -> None:
 # this call shows that you can also move the whole page creation into a separate file
 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')

+ 1 - 1
examples/modularization/theme.py

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

+ 3 - 3
examples/script_executor/main.py

@@ -4,11 +4,11 @@ import os.path
 import platform
 import shlex
 
-from nicegui import background_tasks, ui
+from nicegui import ui
 
 
 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()
     result.content = ''
     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']
 with ui.row():
     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)

+ 6 - 5
examples/single_page_app/main.py

@@ -4,21 +4,21 @@ from router import Router
 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
-async def main():
+def main():
     router = Router()
 
     @router.add('/')
-    async def show_one():
+    def show_one():
         ui.label('Content One').classes('text-2xl')
 
     @router.add('/two')
-    async def show_two():
+    def show_two():
         ui.label('Content Two').classes('text-2xl')
 
     @router.add('/three')
-    async def show_three():
+    def show_three():
         ui.label('Content Three').classes('text-2xl')
 
     # 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
     router.frame().classes('w-full p-4 bg-gray-100')
 
+
 ui.run()

+ 2 - 2
examples/sqlite_database/main.py

@@ -24,8 +24,8 @@ def users_ui() -> None:
                 ui.label(user['name'])
                 ui.label(user['age'])
             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:

+ 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}),
                     new_name.set_value(None),
                     new_age.set_value(None),
-                )).props('flat fab-mini icon=add')
+                ), icon='add').props('flat fab-mini')
             with table.cell():
                 new_name = ui.input('Name')
             with table.cell():

+ 32 - 40
examples/todo_list/main.py

@@ -1,6 +1,6 @@
 #!/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
 
@@ -11,55 +11,47 @@ class TodoItem:
     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
-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
-    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'):
-        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'):
-            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'):
-    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.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()

+ 5 - 3
fetch_tailwind.py

@@ -94,7 +94,7 @@ for property in properties:
 with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('from __future__ import annotations\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('if TYPE_CHECKING:\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('\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('    @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('    @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('    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('            args[0].apply(self.element)\n')
     f.write('        else:\n')

+ 1 - 1
fly.toml

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

+ 17 - 12
main.py

@@ -23,6 +23,7 @@ from nicegui import ui
 from website import documentation, example_card, svg
 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.search import Search
 from website.star import add_star
 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('/fonts', str(Path(__file__).parent / 'website' / 'fonts'))
+app.add_static_files('/static', str(Path(__file__).parent / 'website' / 'static'))
 
 
 @app.get('/logo.png')
@@ -76,25 +78,27 @@ def add_header(menu: Optional[ui.left_drawer] = None) -> None:
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
         if menu:
-            ui.button(on_click=menu.toggle).props('flat color=white icon=menu round').classes('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'):
-            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')
         with ui.row().classes('max-lg:hidden'):
             for title, target in menu_items.items():
                 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')
-        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')
         with ui.link(target='https://github.com/zauberzeug/nicegui/').tooltip('GitHub'):
             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.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'):
                     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('/')
@@ -205,7 +209,7 @@ async def index_page(client: Client) -> None:
             features('insights', 'Visualization', [
                 'charts, diagrams and tables',
                 '3D scenes',
-                'progress bars',
+                'straight-forward data binding',
                 'built-in timer for data refresh',
             ])
             features('brush', 'Styling', [
@@ -217,7 +221,7 @@ async def index_page(client: Client) -> None:
             features('source', 'Coding', [
                 'routing for multiple pages',
                 'auto-reload on code change',
-                'straight-forward data binding',
+                'persistent user sessions',
                 'Jupyter notebook compatibility',
             ])
             features('anchor', 'Foundation', [
@@ -279,6 +283,7 @@ async def index_page(client: Client) -> None:
             example_link('Chat with AI', 'a simple chat app with AI')
             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('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'):
         link_target('why')
@@ -335,8 +340,8 @@ def documentation_page() -> None:
 
 @ui.page('/documentation/{name}')
 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')
     more = getattr(module, 'more', None)
     if hasattr(ui, name):
@@ -351,7 +356,7 @@ async def documentation_page_more(name: str, client: Client) -> None:
     with side_menu() as menu:
         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'):
-        section_heading('Documentation', f'ui.*{name}*')
+        section_heading('Documentation', f'ui.*{name}*' if hasattr(ui, name) else f'*{name.replace("_", " ").title()}*')
         with menu:
             ui.markdown('**Demos**' if more else '**Demo**').classes('mt-4')
         element_demo(api)(getattr(module, 'main_demo'))

+ 2 - 0
mypy.ini

@@ -0,0 +1,2 @@
+[mypy]
+ignore_missing_imports = True

+ 2 - 1
nicegui/__init__.py

@@ -3,9 +3,10 @@ try:
 except ModuleNotFoundError:
     import importlib_metadata
 
-__version__ = importlib_metadata.version('nicegui')
+__version__: str = importlib_metadata.version('nicegui')
 
 from . import elements, globals, ui
+from .api_router import APIRouter
 from .client import Client
 from .nicegui import app
 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 . import globals
+from . import globals, helpers
 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):
@@ -12,6 +20,7 @@ class App(FastAPI):
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
         self.native = Native()
+        self.storage = Storage()
 
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
         """Called every time a new client connects to NiceGUI.
@@ -43,7 +52,7 @@ class App(FastAPI):
         """
         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.
 
         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')
         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'`.
         This is useful for providing local data like images to the frontend.
         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.
 
+        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 local_directory: local folder with files to serve as static content
         """
         if url_path == '/':
             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}')
+        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) -> None:
+        """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:
         """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.
     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)
     running_tasks.add(task)
     task.add_done_callback(running_tasks.discard)

+ 22 - 12
nicegui/binding.py

@@ -2,6 +2,7 @@ import asyncio
 import logging
 import time
 from collections import defaultdict
+from collections.abc import Mapping
 from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Type, Union
 
 from . import globals
@@ -11,30 +12,38 @@ bindable_properties: Dict[Tuple[int, str], Any] = {}
 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]
     else:
         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):
         obj[name] = value
     else:
         setattr(obj, name, value)
 
 
-async def loop():
+async def loop() -> None:
     while True:
         visited: Set[Tuple[int, str]] = set()
         t = time.time()
         for link in active_links:
             (source_obj, source_name, target_obj, target_name, transform) = link
-            value = transform(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
         if time.time() - t > 0.01:
             logging.warning(f'binding propagation for {len(active_links)} active links took {time.time() - t:.3f} s')
@@ -48,10 +57,11 @@ def propagate(source_obj: Any, source_name: str, visited: Optional[Set[Tuple[int
     for _, target_obj, target_name, transform in bindings.get((id(source_obj), source_name), []):
         if (id(target_obj), target_name) in visited:
             continue
-        target_value = transform(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[[Any], Any]) -> None:

+ 2 - 2
nicegui/client.py

@@ -40,7 +40,7 @@ class Client:
                 with Element('q-page'):
                     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.body_html = ''
@@ -109,7 +109,7 @@ class Client:
         self.is_waiting_for_disconnect = False
 
     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.
 
         The client connection must be established before this method is called.

+ 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

+ 5 - 2
nicegui/element.py

@@ -9,7 +9,7 @@ from typing_extensions import Self
 
 from nicegui import json
 
-from . import binding, events, globals, outbox
+from . import binding, events, globals, outbox, storage
 from .elements.mixins.visibility import Visibility
 from .event_listener import EventListener
 from .slot import Slot
@@ -82,7 +82,7 @@ class Element(Visibility):
             for child in slot:
                 yield child
 
-    def _collect_slot_dict(self) -> Dict[str, List[int]]:
+    def _collect_slot_dict(self) -> Dict[str, Any]:
         return {
             name: {'template': slot.template, 'ids': [child.id for child in slot]}
             for name, slot in self.slots.items()
@@ -234,6 +234,7 @@ class Element(Visibility):
                 throttle=throttle,
                 leading_events=leading_events,
                 trailing_events=trailing_events,
+                request=storage.request_contextvar.get(),
             )
             self._event_listeners[listener.id] = listener
             self.update()
@@ -241,6 +242,7 @@ class Element(Visibility):
 
     def _handle_event(self, msg: Dict) -> None:
         listener = self._event_listeners[msg['listener_id']]
+        storage.request_contextvar.set(listener.request)
         events.handle_event(listener.handler, msg, sender=self)
 
     def update(self) -> None:
@@ -280,6 +282,7 @@ class Element(Visibility):
         :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)
         """
+        assert self.parent_slot is not None
         self.parent_slot.children.remove(self)
         self.parent_slot.parent.update()
         target_container = target_container or self.parent_slot.parent

+ 3 - 2
nicegui/elements/aggrid.py

@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 from pathlib import Path
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, cast
 
 from ..dependencies import register_library, register_vue_component
 from ..element import Element
@@ -70,7 +70,8 @@ class AgGrid(Element):
 
         :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]:
         """Get the single currently selected row.

+ 6 - 2
nicegui/elements/audio.py

@@ -1,6 +1,8 @@
 import warnings
 from pathlib import Path
+from typing import Union
 
+from .. import globals
 from ..dependencies import register_vue_component
 from ..element import Element
 
@@ -9,7 +11,7 @@ register_vue_component(name='audio', path=Path(__file__).parent.joinpath('audio.
 
 class Audio(Element):
 
-    def __init__(self, src: str, *,
+    def __init__(self, src: Union[str, Path], *,
                  controls: bool = True,
                  autoplay: bool = False,
                  muted: bool = False,
@@ -18,7 +20,7 @@ class Audio(Element):
                  ) -> None:
         """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 autoplay: whether to start playing the audio automatically (default: `False`)
         :param muted: whether the audio should be initially muted (default: `False`)
@@ -28,6 +30,8 @@ class Audio(Element):
         for a list of events you can subscribe to using the generic event subscription `on()`.
         """
         super().__init__('audio')
+        if Path(src).is_file():
+            src = globals.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay

+ 4 - 7
nicegui/elements/avatar.py

@@ -1,10 +1,10 @@
 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,
                  icon: Optional[str] = None, *,
@@ -28,16 +28,13 @@ class Avatar(Element):
         :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)
         """
-        super().__init__('q-avatar')
+        super().__init__(tag='q-avatar', background_color=color, text_color=text_color)
 
         if icon is not None:
             self._props['icon'] = icon
         self._props['square'] = square
         self._props['rounded'] = rounded
 
-        set_background_color(self, color)
-        set_text_color(self, text_color, prop_name='text-color')
-
         if size is not None:
             self._props['size'] = size
 

+ 4 - 5
nicegui/elements/badge.py

@@ -1,10 +1,11 @@
 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
 
 
-class Badge(TextElement):
+class Badge(TextElement, BackgroundColorElement, TextColorElement):
+    TEXT_COLOR_PROP = 'text-color'
 
     def __init__(self,
                  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 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

+ 8 - 4
nicegui/elements/button.py

@@ -1,18 +1,19 @@
 import asyncio
 from typing import Any, Callable, Optional
 
-from ..colors import set_background_color
 from ..events import ClickEventArguments, handle_event
+from .mixins.color_elements import BackgroundColorElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
 
 
-class Button(TextElement, DisableableElement):
+class Button(TextElement, DisableableElement, BackgroundColorElement):
 
     def __init__(self,
                  text: str = '', *,
                  on_click: Optional[Callable[..., Any]] = None,
                  color: Optional[str] = 'primary',
+                 icon: Optional[str] = None,
                  ) -> None:
         """Button
 
@@ -26,9 +27,12 @@ class Button(TextElement, DisableableElement):
         :param text: the label of the button
         :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 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:
             self.on('click', lambda _: handle_event(on_click, ClickEventArguments(sender=self, client=self.client)))

+ 2 - 2
nicegui/elements/color_input.py

@@ -31,8 +31,8 @@ class ColorInput(ValueElement, DisableableElement):
 
         with self.add_slot('append'):
             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:
         """Open the color picker"""

+ 3 - 6
nicegui/elements/icon.py

@@ -1,10 +1,9 @@
 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,
                  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 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
 
         if size:
             self._props['size'] = size
-
-        set_text_color(self, color)

+ 5 - 2
nicegui/elements/image.py

@@ -1,13 +1,16 @@
+from pathlib import Path
+from typing import Union
+
 from .mixins.source_element import SourceElement
 
 
 class Image(SourceElement):
 
-    def __init__(self, source: str = '') -> None:
+    def __init__(self, source: Union[str, Path] = '') -> None:
         """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)

+ 2 - 1
nicegui/elements/input.py

@@ -58,9 +58,10 @@ class Input(ValueElement, DisableableElement):
             def find_autocompletion() -> Optional[str]:
                 if self.value:
                     needle = str(self.value).casefold()
-                    for item in autocomplete:
+                    for item in autocomplete or []:
                         if item.casefold().startswith(needle):
                             return item
+                return None  # required by mypy
 
             def autocomplete_input() -> None:
                 match = find_autocompletion() or ''

+ 3 - 3
nicegui/elements/interactive_image.py

@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 from pathlib import Path
-from typing import Any, Callable, Dict, List, Optional
+from typing import Any, Callable, Dict, List, Optional, Union
 
 from ..dependencies import register_vue_component
 from ..events import MouseEventArguments, handle_event
@@ -15,7 +15,7 @@ class InteractiveImage(SourceElement, ContentElement):
     CONTENT_PROP = 'content'
 
     def __init__(self,
-                 source: str = '', *,
+                 source: Union[str, Path] = '', *,
                  content: str = '',
                  on_mouse: Optional[Callable[..., Any]] = None,
                  events: List[str] = ['click'],
@@ -29,7 +29,7 @@ class InteractiveImage(SourceElement, ContentElement):
         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.
 
-        :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 on_mouse: callback for mouse events (yields `type`, `image_x` and `image_y`)
         :param events: list of JavaScript events to subscribe to (default: `['click']`)

+ 3 - 4
nicegui/elements/knob.py

@@ -1,12 +1,12 @@
 from typing import Optional
 
-from ..colors import set_text_color
 from .label import Label
+from .mixins.color_elements import TextColorElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Knob(ValueElement, DisableableElement):
+class Knob(ValueElement, DisableableElement, TextColorElement):
 
     def __init__(self,
                  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 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['max'] = max
         self._props['step'] = step
-        set_text_color(self, color)
         self._props['show-value'] = True  # NOTE: enable default slot, e.g. for nested icon
 
         if center_color:

+ 12 - 3
nicegui/elements/link.py

@@ -11,7 +11,11 @@ register_vue_component('link', Path(__file__).parent.joinpath('link.js'))
 
 class Link(TextElement):
 
-    def __init__(self, text: str = '', target: Union[Callable[..., Any], str] = '#', new_tab: bool = False) -> None:
+    def __init__(self,
+                 text: str = '',
+                 target: Union[Callable[..., Any], str, Element] = '#',
+                 new_tab: bool = False,
+                 ) -> None:
         """Link
 
         Create a hyperlink.
@@ -20,11 +24,16 @@ class Link(TextElement):
         and link to it with `ui.link(target="#name")`.
 
         :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)
         """
         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 isinstance(target, Callable):
+            self._props['href'] = globals.page_routes[target]
         self._props['target'] = '_blank' if new_tab else '_self'
         self._classes = ['nicegui-link']
         self.use_component('link')

+ 5 - 1
nicegui/elements/log.js

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

+ 5 - 2
nicegui/elements/log.py

@@ -24,11 +24,14 @@ class Log(Element):
         self._classes = ['nicegui-log']
         self.lines: deque[str] = deque(maxlen=max_lines)
         self.use_component('log')
+        self.total_count: int = 0
 
     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.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:
         """Clear the log"""

+ 1 - 1
nicegui/elements/mermaid.py

@@ -25,4 +25,4 @@ class Mermaid(ContentElement):
 
     def on_content_change(self, content: str) -> None:
         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 - 0
nicegui/elements/mixins/disableable_element.py

@@ -12,6 +12,14 @@ class DisableableElement(Element):
     def __init__(self, **kwargs: Any) -> None:
         super().__init__(**kwargs)
         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:
         """Enable the element."""

+ 9 - 4
nicegui/elements/mixins/source_element.py

@@ -1,16 +1,21 @@
-from typing import Any, Callable
+from pathlib import Path
+from typing import Any, Callable, Union
 
 from typing_extensions import Self
 
+from ... import globals
 from ...binding import BindableProperty, bind, bind_from, bind_to
 from ...element import Element
+from ...helpers import is_file
 
 
 class SourceElement(Element):
     source = BindableProperty(on_change=lambda sender, source: sender.on_source_change(source))
 
-    def __init__(self, *, source: str, **kwargs: Any) -> None:
+    def __init__(self, *, source: Union[str, Path], **kwargs: Any) -> None:
         super().__init__(**kwargs)
+        if is_file(source):
+            source = globals.app.add_static_file(local_file=source)
         self.source = source
         self._props['src'] = source
 
@@ -64,14 +69,14 @@ class SourceElement(Element):
         bind(self, 'source', target_object, target_name, forward=forward, backward=backward)
         return self
 
-    def set_source(self, source: str) -> None:
+    def set_source(self, source: Union[str, Path]) -> None:
         """Set the source of this element.
 
         :param source: The new 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.
 
         :param source: The new source.

+ 4 - 4
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
 
@@ -8,9 +8,9 @@ from ...events import ValueChangeEventArguments, handle_event
 
 
 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))
 
     def __init__(self, *,

+ 9 - 2
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
 
@@ -14,6 +14,12 @@ class Visibility:
     def __init__(self, **kwargs: Any) -> None:
         super().__init__(**kwargs)
         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,
                            target_object: Any,
@@ -79,11 +85,12 @@ class Visibility:
         """
         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.
 
         :param visible: Whether the element should be visible.
         """
+        self = cast('Element', self)
         if visible and 'hidden' in self._classes:
             self._classes.remove('hidden')
             self.update()

+ 5 - 7
nicegui/elements/progress.py

@@ -2,11 +2,11 @@ from typing import Optional
 
 from nicegui import ui
 
-from ..colors import set_text_color
+from .mixins.color_elements import TextColorElement
 from .mixins.value_element import ValueElement
 
 
-class LinearProgress(ValueElement):
+class LinearProgress(ValueElement, TextColorElement):
     VALUE_PROP = 'value'
 
     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 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'
-        set_text_color(self, color)
 
         if show_value:
             with self:
                 ui.label().classes('absolute-center text-sm text-white').bind_text_from(self, 'value')
 
 
-class CircularProgress(ValueElement):
+class CircularProgress(ValueElement, TextColorElement):
     VALUE_PROP = 'value'
 
     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 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['max'] = max
         self._props['size'] = size
         self._props['show-value'] = show_value
-        set_text_color(self, color)
         self._props['track-color'] = 'grey-4'
 
         if show_value:

+ 4 - 4
nicegui/elements/scene_object3d.py

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

+ 31 - 7
nicegui/elements/select.py

@@ -13,11 +13,13 @@ register_vue_component(name='select', path=Path(__file__).parent.joinpath('selec
 
 class Select(ChoiceElement, DisableableElement):
 
-    def __init__(self, options: Union[List, Dict], *,
+    def __init__(self,
+                 options: Union[List, Dict], *,
                  label: Optional[str] = None,
                  value: Any = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  with_input: bool = False,
+                 multiple: bool = False,
                  ) -> None:
         """Dropdown Selection
 
@@ -28,7 +30,15 @@ class Select(ChoiceElement, DisableableElement):
         :param value: the initial value
         :param on_change: callback to execute when selection changes
         :param with_input: whether to show an input field to filter the options
+        :param multiple: whether to allow multiple selections
         """
+        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)
         self.use_component('select')
         if label is not None:
@@ -39,6 +49,7 @@ class Select(ChoiceElement, DisableableElement):
             self._props['hide-selected'] = True
             self._props['fill-input'] = True
             self._props['input-debounce'] = 0
+        self._props['multiple'] = multiple
 
     def on_filter(self, event: Dict) -> None:
         self.options = [
@@ -49,11 +60,24 @@ class Select(ChoiceElement, DisableableElement):
         self.update()
 
     def _msg_to_value(self, msg: Dict) -> Any:
-        return self._values[msg['args']['value']]
+        if self.multiple:
+            return [self._values[arg['value']] for arg in msg['args']]
+        else:
+            return self._values[msg['args']['value']]
 
     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

+ 3 - 5
nicegui/elements/spinner.py

@@ -2,8 +2,7 @@ from typing import Optional
 
 from typing_extensions import Literal
 
-from ..colors import set_text_color
-from ..element import Element
+from .mixins.color_elements import TextColorElement
 
 SpinnerTypes = Literal[
     'default',
@@ -32,7 +31,7 @@ SpinnerTypes = Literal[
 ]
 
 
-class Spinner(Element):
+class Spinner(TextColorElement):
 
     def __init__(self,
                  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 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
-        set_text_color(self, color)
         self._props['thickness'] = thickness

+ 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')

+ 1 - 1
nicegui/elements/table.py

@@ -30,7 +30,7 @@ class Table(FilterElement):
         :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
 
-        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')
 

+ 26 - 12
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 .mixins.disableable_element import DisableableElement
@@ -8,7 +10,7 @@ from .mixins.value_element import ValueElement
 class Tabs(ValueElement):
 
     def __init__(self, *,
-                 value: Any = None,
+                 value: Union[Tab, TabPanel, None] = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  ) -> None:
         """Tabs
@@ -16,11 +18,13 @@ class Tabs(ValueElement):
         This element represents `Quasar's QTabs <https://quasar.dev/vue-components/tabs#qtabs-api>`_ component.
         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
         """
         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):
@@ -29,9 +33,9 @@ class Tab(DisableableElement):
         """Tab
 
         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 icon: icon of the tab (default: `None`)
         """
@@ -47,34 +51,44 @@ class TabPanels(ValueElement):
 
     def __init__(self,
                  tabs: Tabs, *,
-                 value: Any = None,
+                 value: Union[Tab, TabPanel, None] = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  animated: bool = True,
+                 keep_alive: bool = True,
                  ) -> None:
         """Tab Panels
 
         This element represents `Quasar's QTabPanels <https://quasar.dev/vue-components/tab-panels#qtabpanels-api>`_ component.
         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 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)
         tabs.bind_value(self, 'value')
         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):
 
-    def __init__(self, name: str) -> None:
+    def __init__(self, name: Union[Tab, str]) -> None:
         """Tab Panel
 
         This element represents `Quasar's QTabPanel <https://quasar.dev/vue-components/tab-panels#qtabpanel-api>`_ component.
         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')
-        self._props['name'] = name
+        self._props['name'] = name._props['name'] if isinstance(name, Tab) else name

+ 7 - 5
nicegui/elements/upload.py

@@ -1,7 +1,8 @@
 from pathlib import Path
-from typing import Any, 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_vue_component
 from ..events import EventArguments, UploadEventArguments, handle_event
@@ -53,14 +54,15 @@ class Upload(DisableableElement):
             self._props['max-files'] = max_files
 
         @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():
+                assert isinstance(data, UploadFile)
                 args = UploadEventArguments(
                     sender=self,
                     client=self.client,
                     content=data.file,
-                    name=data.filename,
-                    type=data.content_type,
+                    name=data.filename or '',
+                    type=data.content_type or '',
                 )
                 handle_event(on_upload, args)
             return {'upload': 'success'}

+ 6 - 2
nicegui/elements/video.py

@@ -1,6 +1,8 @@
 import warnings
 from pathlib import Path
+from typing import Union
 
+from .. import globals
 from ..dependencies import register_vue_component
 from ..element import Element
 
@@ -9,7 +11,7 @@ register_vue_component(name='video', path=Path(__file__).parent.joinpath('video.
 
 class Video(Element):
 
-    def __init__(self, src: str, *,
+    def __init__(self, src: Union[str, Path], *,
                  controls: bool = True,
                  autoplay: bool = False,
                  muted: bool = False,
@@ -18,7 +20,7 @@ class Video(Element):
                  ) -> None:
         """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 autoplay: whether to start playing the video automatically (default: `False`)
         :param muted: whether the video should be initially muted (default: `False`)
@@ -28,6 +30,8 @@ class Video(Element):
         for a list of events you can subscribe to using the generic event subscription `on()`.
         """
         super().__init__('video')
+        if Path(src).is_file():
+            src = globals.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay

+ 5 - 2
nicegui/event_listener.py

@@ -1,6 +1,8 @@
 import uuid
 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
 
@@ -10,11 +12,12 @@ class EventListener:
     id: str = field(init=False)
     element_id: int
     type: str
-    args: List[str]
+    args: Optional[List[str]]
     handler: Callable
     throttle: float
     leading_events: bool
     trailing_events: bool
+    request: Optional[Request]
 
     def __post_init__(self) -> None:
         self.id = str(uuid.uuid4())

+ 9 - 7
nicegui/events.py

@@ -1,9 +1,9 @@
 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 .helpers import KWONLY_SLOTS, is_coroutine
+from .helpers import KWONLY_SLOTS
 
 if TYPE_CHECKING:
     from .client import Client
@@ -271,15 +271,17 @@ class KeyEventArguments(EventArguments):
 def handle_event(handler: Optional[Callable[..., Any]],
                  arguments: Union[EventArguments, Dict], *,
                  sender: Optional['Element'] = None) -> None:
+    if handler is None:
+        return
     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
         assert sender is not None and sender.parent_slot is not None
+        if sender.is_ignoring_events:
+            return
         with sender.parent_slot:
             result = handler() if no_arguments else handler(arguments)
-        if is_coroutine(handler):
+        if isinstance(result, Awaitable):
             async def wait_for_result():
                 with sender.parent_slot:
                     await result

+ 36 - 16
nicegui/favicon.py

@@ -1,42 +1,57 @@
+import base64
+import io
 import urllib.parse
 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 .helpers import is_file
 
 if TYPE_CHECKING:
     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:
     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'
+    favicon = str(favicon).strip()
+    if is_remote_url(favicon):
+        return favicon
     elif is_data_url(favicon):
         return favicon
     elif is_svg(favicon):
         return svg_to_data_url(favicon)
     elif is_char(favicon):
-        return char_to_data_url(favicon)
+        return svg_to_data_url(char_to_svg(favicon))
     elif page.path == '/':
         return f'{prefix}/favicon.ico'
     else:
         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:
     return favicon.startswith('http://') or favicon.startswith('https://')
 
@@ -53,8 +68,8 @@ def is_data_url(favicon: str) -> bool:
     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" >
             <style>
                 @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>
         </svg>
     '''
-    return svg_to_data_url(svg)
 
 
 def svg_to_data_url(svg: str) -> str:
     svg_urlencoded = urllib.parse.quote(svg)
     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
 
 
 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
 
     This function runs arbitrary JavaScript code on a page that is executed in the browser.

+ 9 - 7
nicegui/functions/refreshable.py

@@ -1,13 +1,13 @@
 from dataclasses import dataclass
 from pathlib import Path
-from typing import Any, Callable, Dict, List, Tuple
+from typing import Any, Awaitable, Callable, Dict, List, Tuple, Union
 
 from typing_extensions import Self
 
 from .. import background_tasks, globals
 from ..dependencies import register_vue_component
 from ..element import Element
-from ..helpers import KWONLY_SLOTS, is_coroutine
+from ..helpers import KWONLY_SLOTS, is_coroutine_function
 
 register_vue_component(name='refreshable', path=Path(__file__).parent.joinpath('refreshable.js'))
 
@@ -16,11 +16,11 @@ register_vue_component(name='refreshable', path=Path(__file__).parent.joinpath('
 class RefreshableTarget:
     container: Element
     instance: Any
-    args: List[Any]
+    args: Tuple[Any, ...]
     kwargs: Dict[str, Any]
 
-    def run(self, func: Callable[..., Any]) -> None:
-        if is_coroutine(func):
+    def run(self, func: Callable[..., Any]) -> Union[None, Awaitable]:
+        if is_coroutine_function(func):
             async def wait_for_result() -> None:
                 with self.container:
                     if self.instance is None:
@@ -34,6 +34,7 @@ class RefreshableTarget:
                     func(*self.args, **self.kwargs)
                 else:
                     func(self.instance, *self.args, **self.kwargs)
+            return None  # required by mypy
 
 
 class refreshable:
@@ -52,7 +53,7 @@ class refreshable:
         self.instance = instance
         return self
 
-    def __call__(self, *args: Any, **kwargs: Any) -> None:
+    def __call__(self, *args: Any, **kwargs: Any) -> Union[None, Awaitable]:
         self.prune()
         container = Element('refreshable')
         container.use_component('refreshable')
@@ -67,7 +68,8 @@ class refreshable:
                 continue
             target.container.clear()
             result = target.run(self.func)
-            if is_coroutine(self.func):
+            if is_coroutine_function(self.func):
+                assert result is not None
                 if globals.loop and globals.loop.is_running():
                     background_tasks.create(result)
                 else:

+ 10 - 5
nicegui/functions/timer.py

@@ -1,10 +1,11 @@
 import asyncio
 import time
-from typing import Any, Callable
+from typing import Any, Callable, Optional
 
 from .. import background_tasks, globals
 from ..binding import BindableProperty
-from ..helpers import is_coroutine
+from ..helpers import is_coroutine_function
+from ..slot import Slot
 
 
 class Timer:
@@ -29,9 +30,9 @@ class Timer:
         :param once: whether the callback is only executed once after a delay specified by `interval` (default: `False`)
         """
         self.interval = interval
-        self.callback = callback
+        self.callback: Optional[Callable[..., Any]] = callback
         self.active = active
-        self.slot = globals.get_slot()
+        self.slot: Optional[Slot] = globals.get_slot()
 
         coroutine = self._run_once if once else self._run_in_loop
         if globals.state == globals.State.STARTED:
@@ -43,6 +44,7 @@ class Timer:
         try:
             if not await self._connected():
                 return
+            assert self.slot is not None
             with self.slot:
                 await asyncio.sleep(self.interval)
                 if globals.state not in {globals.State.STOPPING, globals.State.STOPPED}:
@@ -54,6 +56,7 @@ class Timer:
         try:
             if not await self._connected():
                 return
+            assert self.slot is not None
             with self.slot:
                 while True:
                     if self.slot.parent.client.id not in globals.clients:
@@ -76,8 +79,9 @@ class Timer:
 
     async def _invoke_callback(self) -> None:
         try:
+            assert self.callback is not None
             result = self.callback()
-            if is_coroutine(self.callback):
+            if is_coroutine_function(self.callback):
                 await result
         except Exception as e:
             globals.handle_exception(e)
@@ -88,6 +92,7 @@ class Timer:
         See https://github.com/zauberzeug/nicegui/issues/206 for details.
         Returns True if the client is connected, False if the client is not connected and the timer should be cancelled.
         """
+        assert self.slot is not None
         if self.slot.parent.client.shared:
             return True
         else:

+ 4 - 3
nicegui/globals.py

@@ -3,7 +3,8 @@ import inspect
 import logging
 from contextlib import contextmanager
 from enum import Enum
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Union
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterator, List, Optional, Union
 
 from socketio import AsyncServer
 from uvicorn import Server
@@ -35,7 +36,7 @@ ui_run_has_been_called: bool = False
 reload: bool
 title: str
 viewport: str
-favicon: Optional[str]
+favicon: Optional[Union[str, Path]]
 dark: Optional[bool]
 language: Language
 binding_refresh_interval: float
@@ -86,7 +87,7 @@ def get_client() -> 'Client':
 
 
 @contextmanager
-def socket_id(id: str) -> None:
+def socket_id(id: str) -> Iterator[None]:
     global _socket_id
     _socket_id = id
     yield

+ 72 - 2
nicegui/helpers.py

@@ -1,28 +1,52 @@
 import asyncio
 import functools
 import inspect
+import mimetypes
 import socket
 import sys
 import threading
 import time
 import webbrowser
 from contextlib import nullcontext
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Tuple, Union
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generator, Optional, Tuple, Union
+
+from fastapi import Request
+from fastapi.responses import StreamingResponse
+from starlette.middleware import Middleware
+from starlette.middleware.sessions import SessionMiddleware
 
 from . import background_tasks, globals
+from .storage import RequestTrackingMiddleware
 
 if TYPE_CHECKING:
     from .client import Client
 
+mimetypes.init()
+
 KWONLY_SLOTS = {'kw_only': True, 'slots': True} if sys.version_info >= (3, 10) else {}
 
 
-def is_coroutine(object: Any) -> bool:
+def is_coroutine_function(object: Any) -> bool:
+    """Check if the object is a coroutine function.
+
+    This function is needed because functools.partial is not a coroutine function, but its func attribute is.
+    Note: It will return false for coroutine objects.
+    """
     while isinstance(object, functools.partial):
         object = object.func
     return asyncio.iscoroutinefunction(object)
 
 
+def is_file(path: Optional[Union[str, Path]]) -> bool:
+    """Check if the path is a file that exists."""
+    if not path:
+        return False
+    elif isinstance(path, str) and path.strip().startswith('data:'):
+        return False  # NOTE: avoid passing data URLs to Path
+    return Path(path).is_file()
+
+
 def safe_invoke(func: Union[Callable[..., Any], Awaitable], client: Optional['Client'] = None) -> None:
     try:
         if isinstance(func, Awaitable):
@@ -84,3 +108,49 @@ def schedule_browser(host: str, port: int) -> Tuple[threading.Thread, threading.
     thread = threading.Thread(target=in_thread, args=(host, port), daemon=True)
     thread.start()
     return thread, cancel
+
+
+def set_storage_secret(storage_secret: Optional[str] = None) -> None:
+    """Set storage_secret and add request tracking middleware."""
+    if any(m.cls == SessionMiddleware for m in globals.app.user_middleware):
+        # NOTE not using "add_middleware" because it would be the wrong order
+        globals.app.user_middleware.append(Middleware(RequestTrackingMiddleware))
+    elif storage_secret is not None:
+        globals.app.add_middleware(RequestTrackingMiddleware)
+        globals.app.add_middleware(SessionMiddleware, secret_key=storage_secret)
+
+
+def get_streaming_response(file: Path, request: Request) -> StreamingResponse:
+    file_size = file.stat().st_size
+    start = 0
+    end = file_size - 1
+    range_header = request.headers.get('Range')
+    if range_header:
+        byte1, byte2 = range_header.split('=')[1].split('-')
+        start = int(byte1)
+        if byte2:
+            end = int(byte2)
+    content_length = end - start + 1
+    headers = {
+        'Content-Range': f'bytes {start}-{end}/{file_size}',
+        'Content-Length': str(content_length),
+        'Accept-Ranges': 'bytes',
+    }
+
+    def content_reader(file: Path, start: int, end: int, chunk_size: int = 8192) -> Generator[bytes, None, None]:
+        with open(file, 'rb') as data:
+            data.seek(start)
+            remaining_bytes = end - start + 1
+            while remaining_bytes > 0:
+                chunk = data.read(min(chunk_size, remaining_bytes))
+                if not chunk:
+                    break
+                yield chunk
+                remaining_bytes -= len(chunk)
+
+    return StreamingResponse(
+        content_reader(file, start, end),
+        media_type=mimetypes.guess_type(str(file))[0] or 'application/octet-stream',
+        headers=headers,
+        status_code=206,
+    )

+ 130 - 1
nicegui/native.py

@@ -1,10 +1,139 @@
+import asyncio
+import inspect
+import logging
+import warnings
 from dataclasses import dataclass, field
-from typing import Any, Dict
+from functools import partial
+from multiprocessing import Queue
+from typing import Any, Callable, Dict, Optional, Tuple
 
 from .helpers import KWONLY_SLOTS
 
+method_queue = Queue()
+response_queue = Queue()
+
+try:
+    with warnings.catch_warnings():
+        # webview depends on bottle which uses the deprecated CGI function (https://github.com/bottlepy/bottle/issues/1403)
+        warnings.filterwarnings('ignore', category=DeprecationWarning)
+        import webview
+        from webview.window import FixPoint
+
+    class WindowProxy(webview.Window):
+
+        def __init__(self) -> None:
+            pass  # NOTE we don't call super().__init__ here because this is just a proxy to the actual window
+
+        async def get_always_on_top(self) -> bool:
+            """Get whether the window is always on top."""
+            return await self._request()
+
+        def set_always_on_top(self, on_top: bool) -> None:
+            """Set whether the window is always on top."""
+            self._send(on_top)
+
+        async def get_size(self) -> Tuple[int, int]:
+            """Get the window size as tuple (width, height)."""
+            return await self._request()
+
+        async def get_position(self) -> Tuple[int, int]:
+            """Get the window position as tuple (x, y)."""
+            return await self._request()
+
+        def load_url(self, url: str) -> None:
+            self._send(url)
+
+        def load_html(self, content: str, base_uri: str = ...) -> None:
+            self._send(content, base_uri)
+
+        def load_css(self, stylesheet: str) -> None:
+            self._send(stylesheet)
+
+        def set_title(self, title: str) -> None:
+            self._send(title)
+
+        async def get_cookies(self) -> Any:
+            return await self._request()
+
+        async def get_current_url(self) -> str:
+            return await self._request()
+
+        def destroy(self) -> None:
+            self._send()
+
+        def show(self) -> None:
+            self._send()
+
+        def hide(self) -> None:
+            self._send()
+
+        def set_window_size(self, width: int, height: int) -> None:
+            self._send(width, height)
+
+        def resize(self, width: int, height: int, fix_point: FixPoint = FixPoint.NORTH | FixPoint.WEST) -> None:
+            self._send(width, height, fix_point)
+
+        def minimize(self) -> None:
+            self._send()
+
+        def restore(self) -> None:
+            self._send()
+
+        def toggle_fullscreen(self) -> None:
+            self._send()
+
+        def move(self, x: int, y: int) -> None:
+            self._send(x, y)
+
+        async def evaluate_js(self, script: str) -> str:
+            return await self._request(script)
+
+        async def create_confirmation_dialog(self, title: str, message: str) -> bool:
+            return await self._request(title, message)
+
+        async def create_file_dialog(
+            self,
+            dialog_type: int = webview.OPEN_DIALOG,
+            directory: str = '',
+            allow_multiple: bool = False,
+            save_filename: str = '',
+            file_types: Tuple[str, ...] = (),
+        ) -> Tuple[str, ...]:
+            return await self._request(
+                dialog_type=dialog_type,
+                directory=directory,
+                allow_multiple=allow_multiple,
+                save_filename=save_filename,
+                file_types=file_types,
+            )
+
+        def expose(self, function: Callable) -> None:
+            raise NotImplementedError(f'exposing "{function}" is not supported')
+
+        def _send(self, *args: Any, **kwargs: Any) -> None:
+            name = inspect.currentframe().f_back.f_code.co_name
+            method_queue.put((name, args, kwargs))
+
+        async def _request(self, *args: Any, **kwargs: Any) -> Any:
+            def wrapper(*args: Any, **kwargs: Any) -> Any:
+                try:
+                    method_queue.put((name, args, kwargs))
+                    return response_queue.get()  # wait for the method to be called and writing its result to the queue
+                except Exception:
+                    logging.exception(f'error in {name}')
+            name = inspect.currentframe().f_back.f_code.co_name
+            return await asyncio.get_event_loop().run_in_executor(None, partial(wrapper, *args, **kwargs))
+
+        def signal_server_shutdown(self) -> None:
+            self._send()
+
+except ModuleNotFoundError:
+    class WindowProxy():
+        pass  # just a dummy if webview is not installed
+
 
 @dataclass(**KWONLY_SLOTS)
 class Native:
     start_args: Dict[str, Any] = field(default_factory=dict)
     window_args: Dict[str, Any] = field(default_factory=dict)
+    main_window: Optional[WindowProxy] = None

+ 66 - 9
nicegui/native_mode.py

@@ -1,13 +1,16 @@
 import _thread
-import multiprocessing
+import logging
+import multiprocessing as mp
+import queue
 import socket
 import sys
 import tempfile
 import time
 import warnings
-from threading import Thread
+from threading import Event, Thread
+from typing import Any, Callable, Dict, List, Tuple
 
-from . import globals, helpers
+from . import globals, helpers, native
 
 try:
     with warnings.catch_warnings():
@@ -15,10 +18,16 @@ try:
         warnings.filterwarnings('ignore', category=DeprecationWarning)
         import webview
 except ModuleNotFoundError:
+    class webview:
+        class Window:
+            pass
     pass
 
 
-def open_window(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
+def open_window(
+    host: str, port: int, title: str, width: int, height: int, fullscreen: bool,
+    method_queue: mp.Queue, response_queue: mp.Queue,
+) -> None:
     while not helpers.is_port_open(host, port):
         time.sleep(0.1)
 
@@ -26,13 +35,60 @@ def open_window(host: str, port: int, title: str, width: int, height: int, fulls
     window_kwargs.update(globals.app.native.window_args)
 
     try:
-        webview.create_window(**window_kwargs)
+        window = webview.create_window(**window_kwargs)
+        closing = Event()
+        window.events.closing += closing.set
+        start_window_method_executor(window, method_queue, response_queue, closing)
         webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
     except NameError:
-        print('Native mode is not supported in this configuration. Please install pywebview to use it.')
+        logging.error('Native mode is not supported in this configuration. Please install pywebview to use it.')
         sys.exit(1)
 
 
+def start_window_method_executor(
+        window: webview.Window, method_queue: mp.Queue, response_queue: mp.Queue, closing: Event
+) -> None:
+    def execute(method: Callable, args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> None:
+        try:
+            response = method(*args, **kwargs)
+            if response is not None or 'dialog' in method.__name__:
+                response_queue.put(response)
+        except Exception:
+            logging.exception(f'error in window.{method.__name__}')
+
+    def window_method_executor() -> None:
+        pending_executions: List[Thread] = []
+        while not closing.is_set():
+            try:
+                method_name, args, kwargs = method_queue.get(block=False)
+                if method_name == 'signal_server_shutdown':
+                    if pending_executions:
+                        logging.warning('shutdown is possibly blocked by opened dialogs like a file picker')
+                        while pending_executions:
+                            pending_executions.pop().join()
+                elif method_name == 'get_always_on_top':
+                    response_queue.put(window.on_top)
+                elif method_name == 'set_always_on_top':
+                    window.on_top = args[0]
+                elif method_name == 'get_position':
+                    response_queue.put((int(window.x), int(window.y)))
+                elif method_name == 'get_size':
+                    response_queue.put((int(window.width), int(window.height)))
+                else:
+                    method = getattr(window, method_name)
+                    if callable(method):
+                        pending_executions.append(Thread(target=execute, args=(method, args, kwargs)))
+                        pending_executions[-1].start()
+                    else:
+                        logging.error(f'window.{method_name} is not callable')
+            except queue.Empty:
+                time.sleep(0.01)
+            except Exception:
+                logging.exception(f'error in window.{method_name}')
+
+    Thread(target=window_method_executor).start()
+
+
 def activate(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
     def check_shutdown() -> None:
         while process.is_alive():
@@ -42,10 +98,11 @@ def activate(host: str, port: int, title: str, width: int, height: int, fullscre
             time.sleep(0.1)
         _thread.interrupt_main()
 
-    multiprocessing.freeze_support()
-    process = multiprocessing.Process(target=open_window, args=(host, port, title, width, height, fullscreen),
-                                      daemon=False)
+    mp.freeze_support()
+    args = host, port, title, width, height, fullscreen, native.method_queue, native.response_queue
+    process = mp.Process(target=open_window, args=args, daemon=False)
     process.start()
+
     Thread(target=check_shutdown, daemon=True).start()
 
 

+ 13 - 4
nicegui/nicegui.py

@@ -15,19 +15,19 @@ from fastapi_socketio import SocketManager
 from nicegui import json
 from nicegui.json import NiceGUIJSONResponse
 
-from . import __version__, background_tasks, binding, globals, outbox
+from . import __version__, background_tasks, binding, favicon, globals, outbox
 from .app import App
 from .client import Client
 from .dependencies import js_components, libraries
 from .element import Element
 from .error import error_content
-from .helpers import safe_invoke
+from .helpers import is_file, safe_invoke
 from .page import page
 
 globals.app = app = App(default_response_class=NiceGUIJSONResponse)
 # NOTE we use custom json module which wraps orjson
 socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=json)
-globals.sio = sio = app.sio
+globals.sio = sio = socket_manager._sio
 
 app.add_middleware(GZipMiddleware)
 static_files = StaticFiles(
@@ -71,6 +71,13 @@ def handle_startup(with_welcome_message: bool = True) -> None:
                            'remove the guard or replace it with\n'
                            '   if __name__ in {"__main__", "__mp_main__"}:\n'
                            'to allow for multiprocessing.')
+    if globals.favicon:
+        if is_file(globals.favicon):
+            globals.app.add_route('/favicon.ico', lambda _: FileResponse(globals.favicon))
+        else:
+            globals.app.add_route('/favicon.ico', lambda _: favicon.get_favicon_response())
+    else:
+        globals.app.add_route('/favicon.ico', lambda _: FileResponse(Path(__file__).parent / 'static' / 'favicon.ico'))
     globals.state = globals.State.STARTING
     globals.loop = asyncio.get_running_loop()
     with globals.index_client:
@@ -102,7 +109,9 @@ def print_welcome_message():
 
 
 @app.on_event('shutdown')
-def handle_shutdown() -> None:
+async def handle_shutdown() -> None:
+    if app.native.main_window:
+        app.native.main_window.signal_server_shutdown()
     globals.state = globals.State.STOPPING
     with globals.index_client:
         for t in globals.shutdown_handlers:

+ 212 - 0
nicegui/observables.py

@@ -0,0 +1,212 @@
+from typing import Any, Callable, Dict, Iterable, List, Set, Union, overload
+
+from typing_extensions import SupportsIndex
+
+
+class ObservableDict(dict):
+
+    def __init__(self, data: Dict, on_change: Callable) -> None:
+        super().__init__(data)
+        for key, value in self.items():
+            super().__setitem__(key, make_observable(value, on_change))
+        self.on_change = on_change
+
+    def pop(self, k: Any, d: Any = None) -> Any:
+        item = super().pop(k, d)
+        self.on_change()
+        return item
+
+    def popitem(self) -> Any:
+        item = super().popitem()
+        self.on_change()
+        return item
+
+    def update(self, *args: Any, **kwargs: Any) -> None:
+        super().update(make_observable(dict(*args, **kwargs), self.on_change))
+        self.on_change()
+
+    def clear(self) -> None:
+        super().clear()
+        self.on_change()
+
+    def setdefault(self, __key: Any, __default: Any = None) -> Any:
+        item = super().setdefault(__key, make_observable(__default, self.on_change))
+        self.on_change()
+        return item
+
+    def __setitem__(self, __key: Any, __value: Any) -> None:
+        super().__setitem__(__key, make_observable(__value, self.on_change))
+        self.on_change()
+
+    def __delitem__(self, __key: Any) -> None:
+        super().__delitem__(__key)
+        self.on_change()
+
+    def __or__(self, other: Any) -> Any:
+        return super().__or__(other)
+
+    def __ior__(self, other: Any) -> Any:
+        super().__ior__(make_observable(dict(other), self.on_change))
+        self.on_change()
+        return self
+
+
+class ObservableList(list):
+
+    def __init__(self, data: List, on_change: Callable) -> None:
+        super().__init__(data)
+        for i, item in enumerate(self):
+            super().__setitem__(i, make_observable(item, on_change))
+        self.on_change = on_change
+
+    def append(self, item: Any) -> None:
+        super().append(make_observable(item, self.on_change))
+        self.on_change()
+
+    def extend(self, iterable: Iterable) -> None:
+        super().extend(make_observable(list(iterable), self.on_change))
+        self.on_change()
+
+    def insert(self, index: SupportsIndex, object: Any) -> None:
+        super().insert(index, make_observable(object, self.on_change))
+        self.on_change()
+
+    def remove(self, value: Any) -> None:
+        super().remove(value)
+        self.on_change()
+
+    def pop(self, index: SupportsIndex = -1) -> Any:
+        item = super().pop(index)
+        self.on_change()
+        return item
+
+    def clear(self) -> None:
+        super().clear()
+        self.on_change()
+
+    def sort(self, **kwargs: Any) -> None:
+        super().sort(**kwargs)
+        self.on_change()
+
+    def reverse(self) -> None:
+        super().reverse()
+        self.on_change()
+
+    def __delitem__(self, key: Union[SupportsIndex, slice]) -> None:
+        super().__delitem__(key)
+        self.on_change()
+
+    def __setitem__(self, key: Union[SupportsIndex, slice], value: Any) -> None:
+        super().__setitem__(key, make_observable(value, self.on_change))
+        self.on_change()
+
+    def __add__(self, other: Any) -> Any:
+        return super().__add__(other)
+
+    def __iadd__(self, other: Any) -> Any:
+        super().__iadd__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+
+class ObservableSet(set):
+
+    def __init__(self, data: set, on_change: Callable) -> None:
+        super().__init__(data)
+        for item in self:
+            super().add(make_observable(item, on_change))
+        self.on_change = on_change
+
+    def add(self, item: Any) -> None:
+        super().add(make_observable(item, self.on_change))
+        self.on_change()
+
+    def remove(self, item: Any) -> None:
+        super().remove(item)
+        self.on_change()
+
+    def discard(self, item: Any) -> None:
+        super().discard(item)
+        self.on_change()
+
+    def pop(self) -> Any:
+        item = super().pop()
+        self.on_change()
+        return item
+
+    def clear(self) -> None:
+        super().clear()
+        self.on_change()
+
+    def update(self, *s: Iterable[Any]) -> None:
+        super().update(make_observable(set(*s), self.on_change))
+        self.on_change()
+
+    def intersection_update(self, *s: Iterable[Any]) -> None:
+        super().intersection_update(*s)
+        self.on_change()
+
+    def difference_update(self, *s: Iterable[Any]) -> None:
+        super().difference_update(*s)
+        self.on_change()
+
+    def symmetric_difference_update(self, *s: Iterable[Any]) -> None:
+        super().symmetric_difference_update(*s)
+        self.on_change()
+
+    def __or__(self, other: Any) -> Any:
+        return super().__or__(other)
+
+    def __ior__(self, other: Any) -> Any:
+        super().__ior__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+    def __and__(self, other: Any) -> set:
+        return super().__and__(other)
+
+    def __iand__(self, other: Any) -> Any:
+        super().__iand__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+    def __sub__(self, other: Any) -> set:
+        return super().__sub__(other)
+
+    def __isub__(self, other: Any) -> Any:
+        super().__isub__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+    def __xor__(self, other: Any) -> set:
+        return super().__xor__(other)
+
+    def __ixor__(self, other: Any) -> Any:
+        super().__ixor__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+
+@overload
+def make_observable(data: Dict, on_change: Callable) -> ObservableDict:
+    ...
+
+
+@overload
+def make_observable(data: List, on_change: Callable) -> ObservableList:
+    ...
+
+
+@overload
+def make_observable(data: Set, on_change: Callable) -> ObservableSet:
+    ...
+
+
+def make_observable(data: Any, on_change: Callable) -> Any:
+    if isinstance(data, dict):
+        return ObservableDict(data, on_change)
+    if isinstance(data, list):
+        return ObservableList(data, on_change)
+    if isinstance(data, set):
+        return ObservableSet(data, on_change)
+    return data

+ 3 - 3
nicegui/outbox.py

@@ -7,7 +7,7 @@ from . import globals
 if TYPE_CHECKING:
     from .element import Element
 
-ClientId = int
+ClientId = str
 ElementId = int
 MessageType = str
 Message = Tuple[ClientId, MessageType, Any]
@@ -32,8 +32,8 @@ async def loop() -> None:
         coros = []
         try:
             for client_id, elements in update_queue.items():
-                elements = {element_id: element._to_dict() for element_id, element in elements.items()}
-                coros.append(globals.sio.emit('update', elements, room=client_id))
+                data = {element_id: element._to_dict() for element_id, element in elements.items()}
+                coros.append(globals.sio.emit('update', data, room=client_id))
             update_queue.clear()
             for client_id, message_type, data in message_queue:
                 coros.append(globals.sio.emit(message_type, data, room=client_id))

+ 17 - 6
nicegui/page.py

@@ -1,7 +1,8 @@
 import asyncio
 import inspect
 import time
-from typing import Any, Callable, Optional
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Callable, Optional, Union
 
 from fastapi import Request, Response
 
@@ -10,6 +11,9 @@ from .client import Client
 from .favicon import create_favicon_route
 from .language import Language
 
+if TYPE_CHECKING:
+    from .api_router import APIRouter
+
 
 class page:
 
@@ -17,16 +21,17 @@ class page:
                  path: str, *,
                  title: Optional[str] = None,
                  viewport: Optional[str] = None,
-                 favicon: Optional[str] = None,
+                 favicon: Optional[Union[str, Path]] = None,
                  dark: Optional[bool] = ...,
                  language: Language = ...,
                  response_timeout: float = 3.0,
+                 api_router: Optional['APIRouter'] = None,
                  **kwargs: Any,
                  ) -> None:
         """Page
 
-        Creates a new page at the given route.
-        Each user will see a new instance of the page.
+        This decorator marks a function to be a page builder.
+        Each user accessing the given route 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>`_).
 
@@ -37,9 +42,10 @@ class page:
         :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
         :param language: language of the page (defaults to `language` argument of `run` command)
         :param response_timeout: maximum time for the decorated function to build the page (default: 3.0)
+        :param api_router: APIRouter instance to use, can be left `None` to use the default
         :param kwargs: additional keyword arguments passed to FastAPI's @app.get method
         """
-        self.path = path
+        self._path = path
         self.title = title
         self.viewport = viewport
         self.favicon = favicon
@@ -47,9 +53,14 @@ class page:
         self.language = language
         self.response_timeout = response_timeout
         self.kwargs = kwargs
+        self.api_router = api_router or globals.app.router
 
         create_favicon_route(self.path, favicon)
 
+    @property
+    def path(self) -> str:
+        return self.api_router.prefix + self._path
+
     def resolve_title(self) -> str:
         return self.title if self.title is not None else globals.title
 
@@ -96,6 +107,6 @@ class page:
             parameters.insert(0, request)
         decorated.__signature__ = inspect.Signature(parameters)
 
-        globals.app.get(self.path, **self.kwargs)(decorated)
+        self.api_router.get(self._path, **self.kwargs)(decorated)
         globals.page_routes[func] = self.path
         return func

+ 27 - 5
nicegui/run.py

@@ -1,24 +1,41 @@
 import logging
 import multiprocessing
 import os
+import socket
 import sys
-from typing import Any, List, Optional, Tuple
+from pathlib import Path
+from typing import Any, List, Optional, Tuple, Union
 
 import __main__
 import uvicorn
 from uvicorn.main import STARTUP_FAILURE
 from uvicorn.supervisors import ChangeReload, Multiprocess
 
-from . import globals, helpers, native_mode
+from . import globals, helpers
+from . import native as native_module
+from . import native_mode
 from .language import Language
 
 
+class Server(uvicorn.Server):
+
+    def run(self, sockets: Optional[List[socket.socket]] = None) -> None:
+        globals.server = self
+        native_module.method_queue = self.config.method_queue
+        native_module.response_queue = self.config.response_queue
+        if native_module.method_queue is not None:
+            globals.app.native.main_window = native_module.WindowProxy()
+
+        helpers.set_storage_secret(self.config.storage_secret)
+        super().run(sockets=sockets)
+
+
 def run(*,
         host: Optional[str] = None,
         port: int = 8080,
         title: str = 'NiceGUI',
         viewport: str = 'width=device-width, initial-scale=1',
-        favicon: Optional[str] = None,
+        favicon: Optional[Union[str, Path]] = None,
         dark: Optional[bool] = False,
         language: Language = 'en-US',
         binding_refresh_interval: float = 0.1,
@@ -33,6 +50,7 @@ def run(*,
         uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*',
         exclude: str = '',
         tailwind: bool = True,
+        storage_secret: Optional[str] = None,
         **kwargs: Any,
         ) -> None:
     '''ui.run
@@ -59,7 +77,8 @@ def run(*,
     :param exclude: comma-separated string to exclude elements (with corresponding JavaScript libraries) to save bandwidth
       (possible entries: aggrid, audio, chart, colors, interactive_image, joystick, keyboard, log, markdown, mermaid, plotly, scene, video)
     :param tailwind: whether to use Tailwind (experimental, default: `True`)
-    :param kwargs: additional keyword arguments are passed to `uvicorn.run`
+    :param storage_secret: secret key for browser based storage (default: `None`, a value is required to enable ui.storage.individual and ui.storage.browser)
+    :param kwargs: additional keyword arguments are passed to `uvicorn.run`    
     '''
     globals.ui_run_has_been_called = True
     globals.reload = reload
@@ -115,7 +134,10 @@ def run(*,
         log_level=uvicorn_logging_level,
         **kwargs,
     )
-    globals.server = uvicorn.Server(config=config)
+    config.storage_secret = storage_secret
+    config.method_queue = native_module.method_queue if native else None
+    config.response_queue = native_module.response_queue if native else None
+    globals.server = Server(config=config)
 
     if (reload or config.workers > 1) and not isinstance(config.app, str):
         logging.warning('You must pass the application as an import string to enable "reload" or "workers".')

+ 6 - 2
nicegui/run_with.py

@@ -1,8 +1,10 @@
-from typing import Optional
+from pathlib import Path
+from typing import Optional, Union
 
 from fastapi import FastAPI
 
 from nicegui import globals
+from nicegui.helpers import set_storage_secret
 from nicegui.language import Language
 from nicegui.nicegui import handle_shutdown, handle_startup
 
@@ -11,12 +13,13 @@ def run_with(
     app: FastAPI, *,
     title: str = 'NiceGUI',
     viewport: str = 'width=device-width, initial-scale=1',
-    favicon: Optional[str] = None,
+    favicon: Optional[Union[str, Path]] = None,
     dark: Optional[bool] = False,
     language: Language = 'en-US',
     binding_refresh_interval: float = 0.1,
     exclude: str = '',
     mount_path: str = '/',
+    storage_secret: Optional[str] = None,
 ) -> None:
     globals.ui_run_has_been_called = True
     globals.title = title
@@ -28,6 +31,7 @@ def run_with(
     globals.excludes = [e.strip() for e in exclude.split(',')]
     globals.tailwind = True
 
+    set_storage_secret(storage_secret)
     app.on_event('startup')(lambda: handle_startup(with_welcome_message=False))
     app.on_event('shutdown')(lambda: handle_shutdown())
 

+ 128 - 0
nicegui/storage.py

@@ -0,0 +1,128 @@
+import contextvars
+import json
+import uuid
+from collections.abc import MutableMapping
+from pathlib import Path
+from typing import Any, Dict, Iterator, Optional, Union
+
+import aiofiles
+from fastapi import Request
+from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
+from starlette.responses import Response
+
+from . import background_tasks, globals, observables
+
+request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
+
+
+class ReadOnlyDict(MutableMapping):
+
+    def __init__(self, data: Dict[Any, Any], write_error_message: str = 'Read-only dict') -> None:
+        self._data: Dict[Any, Any] = data
+        self._write_error_message: str = write_error_message
+
+    def __getitem__(self, item: Any) -> Any:
+        return self._data[item]
+
+    def __setitem__(self, key: Any, value: Any) -> None:
+        raise TypeError(self._write_error_message)
+
+    def __delitem__(self, key: Any) -> None:
+        raise TypeError(self._write_error_message)
+
+    def __iter__(self) -> Iterator:
+        return iter(self._data)
+
+    def __len__(self) -> int:
+        return len(self._data)
+
+
+class PersistentDict(observables.ObservableDict):
+
+    def __init__(self, filepath: Path) -> None:
+        self.filepath = filepath
+        data = json.loads(filepath.read_text()) if filepath.exists() else {}
+        super().__init__(data, self.backup)
+
+    def backup(self) -> None:
+        async def backup() -> None:
+            async with aiofiles.open(self.filepath, 'w') as f:
+                await f.write(json.dumps(self))
+        if globals.loop:
+            background_tasks.create_lazy(backup(), name=self.filepath.stem)
+        else:
+            globals.app.on_startup(backup())
+
+
+class RequestTrackingMiddleware(BaseHTTPMiddleware):
+
+    async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
+        request_contextvar.set(request)
+        if 'id' not in request.session:
+            request.session['id'] = str(uuid.uuid4())
+        request.state.responded = False
+        response = await call_next(request)
+        request.state.responded = True
+        return response
+
+
+class Storage:
+
+    def __init__(self) -> None:
+        self.storage_dir = Path('.nicegui')
+        self.storage_dir.mkdir(exist_ok=True)
+        self._general = PersistentDict(self.storage_dir / 'storage_general.json')
+        self._users: Dict[str, PersistentDict] = {}
+
+    @property
+    def browser(self) -> Union[ReadOnlyDict, Dict]:
+        """Small storage that is saved directly within the user's browser (encrypted cookie).
+
+        The data is shared between all browser tabs and can only be modified before the initial request has been submitted.
+        Therefore it is normally better to use `app.storage.user` instead,
+        which can be modified anytime, reduces overall payload, improves security and has larger storage capacity.
+        """
+        request: Optional[Request] = request_contextvar.get()
+        if request is None:
+            if globals.get_client() == globals.index_client:
+                raise RuntimeError('app.storage.browser can only be used with page builder functions '
+                                   '(https://nicegui.io/documentation/page)')
+            else:
+                raise RuntimeError('app.storage.browser needs a storage_secret passed in ui.run()')
+        if request.state.responded:
+            return ReadOnlyDict(
+                request.session,
+                'the response to the browser has already been built, so modifications cannot be sent back anymore'
+            )
+        return request.session
+
+    @property
+    def user(self) -> Dict:
+        """Individual user storage that is persisted on the server (where NiceGUI is executed).
+
+        The data is stored in a file on the server.
+        It is shared between all browser tabs by identifying the user via session cookie ID.
+        """
+        request: Optional[Request] = request_contextvar.get()
+        if request is None:
+            if globals.get_client() == globals.index_client:
+                raise RuntimeError('app.storage.user can only be used with page builder functions '
+                                   '(https://nicegui.io/documentation/page)')
+            else:
+                raise RuntimeError('app.storage.user needs a storage_secret passed in ui.run()')
+        id = request.session['id']
+        if id not in self._users:
+            self._users[id] = PersistentDict(self.storage_dir / f'storage_user_{id}.json')
+        return self._users[id]
+
+    @property
+    def general(self) -> Dict:
+        """General storage shared between all users that is persisted on the server (where NiceGUI is executed)."""
+        return self._general
+
+    def clear(self) -> None:
+        """Clears all storage."""
+        self._general.clear()
+        self._users.clear()
+        for filepath in self.storage_dir.glob('storage_*.json'):
+            filepath.unlink()

+ 5 - 3
nicegui/tailwind.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, List, Optional, overload
+from typing import TYPE_CHECKING, List, Optional, Union, overload
 
 if TYPE_CHECKING:
     from .element import Element
@@ -177,10 +177,10 @@ class PseudoElement:
 class Tailwind:
 
     def __init__(self, _element: Optional['Element'] = None) -> None:
-        self.element = PseudoElement() if _element is None else _element
+        self.element: Union[PseudoElement, Element] = PseudoElement() if _element is None else _element
 
     @overload
-    def __call__(self, Tailwind) -> Tailwind:
+    def __call__(self, tailwind: Tailwind) -> Tailwind:
         ...
 
     @overload
@@ -188,6 +188,8 @@ class Tailwind:
         ...
 
     def __call__(self, *args) -> Tailwind:
+        if not args:
+            return self
         if isinstance(args[0], Tailwind):
             args[0].apply(self.element)
         else:

+ 6 - 5
nicegui/templates/index.html

@@ -29,6 +29,12 @@
       <span>Connection lost.</span>
       <span>Trying to reconnect...</span>
     </div>
+    <script>
+      function getElement(id) {
+        const _id = id instanceof HTMLElement ? id.id : id;
+        return window.app.$refs["r" + _id];
+      }
+    </script>
     <script type="module">
       const True = true;
       const False = false;
@@ -138,11 +144,6 @@
         return Vue.h(Vue.resolveComponent(element.tag), props, slots);
       }
 
-      function getElement(id) {
-        const _id = (id instanceof HTMLElement) ? id.id : id;
-        return window.app.$refs['r' + _id];
-      }
-
       function runJavascript(code, request_id) {
         (new Promise((resolve) =>resolve(eval(code)))).catch((reason) => {
           if(reason instanceof SyntaxError)

+ 6 - 0
nicegui/ui.py

@@ -52,6 +52,9 @@ __all__ = [
     'slider',
     'spinner',
     'splitter',
+    'step',
+    'stepper',
+    'stepper_navigation',
     'switch',
     'table',
     'tab',
@@ -136,6 +139,9 @@ from .elements.separator import Separator as separator
 from .elements.slider import Slider as slider
 from .elements.spinner import Spinner as spinner
 from .elements.splitter import Splitter as splitter
+from .elements.stepper import Step as step
+from .elements.stepper import Stepper as stepper
+from .elements.stepper import StepperNavigation as stepper_navigation
 from .elements.switch import Switch as switch
 from .elements.table import Table as table
 from .elements.tabs import Tab as tab

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 187 - 260
poetry.lock


+ 2 - 0
pyproject.toml

@@ -28,6 +28,8 @@ plotly = "^5.13.0"
 orjson = {version = "^3.8.6", markers = "platform_machine != 'i386' and platform_machine != 'i686'"} # orjson does not support 32bit
 pywebview = "^4.0.2"
 importlib_metadata = { version = "^6.0.0", markers = "python_version ~= '3.7'" } # Python 3.7 has no importlib.metadata
+itsdangerous = "^2.1.2"
+aiofiles = "^23.1.0"
 
 [tool.poetry.group.dev.dependencies]
 icecream = "^2.1.0"

+ 4 - 0
test_startup.sh

@@ -36,6 +36,10 @@ do
         # NOTE: chat_with_ai example is not working with python 3.7
         continue
     fi
+    if [[ $path == *"ai_interface"* ]] && [[ $(python3 --version) == *"3.7"* ]]; then
+        # NOTE: ai_interface example is not working with python 3.7
+        continue
+    fi
     if test -f $path/start.sh; then
         check $path/start.sh dev || error=1 
     elif test -f $path/main.py; then

+ 5 - 0
tests/conftest.py

@@ -39,7 +39,12 @@ def selenium(selenium: webdriver.Chrome) -> webdriver.Chrome:
 def reset_globals() -> Generator[None, None, None]:
     for path in {'/'}.union(globals.page_routes.values()):
         globals.app.remove_route(path)
+    globals.app.middleware_stack = None
+    globals.app.user_middleware.clear()
+    # NOTE favicon routes must be removed separately because they are not "pages"
+    [globals.app.routes.remove(r) for r in globals.app.routes if r.path.endswith('/favicon.ico')]
     importlib.reload(globals)
+    globals.app.storage.clear()
     globals.index_client = Client(page('/'), shared=True).__enter__()
     globals.app.get('/')(globals.index_client.build_response)
 

+ 2 - 1
tests/requirements.txt

@@ -3,4 +3,5 @@ pytest-selenium
 pytest-asyncio
 selenium
 autopep8
-icecream
+icecream
+beautifulsoup4

+ 5 - 3
tests/screen.py

@@ -14,23 +14,25 @@ from selenium.webdriver.remote.webelement import WebElement
 
 from nicegui import globals, ui
 
+from .test_helpers import TEST_DIR
+
 PORT = 3392
 IGNORED_CLASSES = ['row', 'column', 'q-card', 'q-field', 'q-field__label', 'q-input']
 
 
 class Screen:
     IMPLICIT_WAIT = 4
-    SCREENSHOT_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'screenshots')
-    UI_RUN_KWARGS = {'port': PORT, 'show': False, 'reload': False}
+    SCREENSHOT_DIR = TEST_DIR / 'screenshots'
 
     def __init__(self, selenium: webdriver.Chrome, caplog: pytest.LogCaptureFixture) -> None:
         self.selenium = selenium
         self.caplog = caplog
         self.server_thread = None
+        self.ui_run_kwargs = {'port': PORT, 'show': False, 'reload': False}
 
     def start_server(self) -> None:
         '''Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script.'''
-        self.server_thread = threading.Thread(target=ui.run, kwargs=self.UI_RUN_KWARGS)
+        self.server_thread = threading.Thread(target=ui.run, kwargs=self.ui_run_kwargs)
         self.server_thread.start()
 
     @property

+ 31 - 0
tests/test_api_router.py

@@ -0,0 +1,31 @@
+
+from nicegui import APIRouter, app, ui
+
+from .screen import Screen
+
+
+def test_prefix(screen: Screen):
+    router = APIRouter(prefix='/some-prefix')
+
+    @router.page('/')
+    def page():
+        ui.label('Hello, world!')
+
+    app.include_router(router)
+
+    screen.open('/some-prefix')
+    screen.should_contain('NiceGUI')
+    screen.should_contain('Hello, world!')
+
+
+def test_passing_page_parameters(screen: Screen):
+    router = APIRouter()
+
+    @router.page('/', title='My Custom Title')
+    def page():
+        ui.label('Hello, world!')
+
+    app.include_router(router)
+
+    screen.open('/')
+    screen.should_contain('My Custom Title')

+ 24 - 0
tests/test_events.py

@@ -1,6 +1,8 @@
 import asyncio
 
+import pytest
 from selenium.webdriver.common.by import By
+from typing_extensions import Literal
 
 from nicegui import ui
 from nicegui.events import ClickEventArguments
@@ -26,21 +28,29 @@ async def click_async_with_args(_: ClickEventArguments):
     ui.label('click_async_with_args')
 
 
+async def click_lambda_with_async_and_parameters(msg: str):
+    await asyncio.sleep(0.1)
+    ui.label(f'click_lambda_with_async_and_parameters: {msg}')
+
+
 def test_click_events(screen: Screen):
     ui.button('click_sync_no_args', on_click=click_sync_no_args)
     ui.button('click_sync_with_args', on_click=click_sync_with_args)
     ui.button('click_async_no_args', on_click=click_async_no_args)
     ui.button('click_async_with_args', on_click=click_async_with_args)
+    ui.button('click_lambda_with_async_and_parameters', on_click=lambda: click_lambda_with_async_and_parameters('works'))
 
     screen.open('/')
     screen.click('click_sync_no_args')
     screen.click('click_sync_with_args')
     screen.click('click_async_no_args')
     screen.click('click_async_with_args')
+    screen.click('click_lambda_with_async_and_parameters')
     screen.should_contain('click_sync_no_args')
     screen.should_contain('click_sync_with_args')
     screen.should_contain('click_async_no_args')
     screen.should_contain('click_async_with_args')
+    screen.should_contain('click_lambda_with_async_and_parameters: works')
 
 
 def test_generic_events(screen: Screen):
@@ -150,3 +160,17 @@ def test_throttling_variants(screen: Screen):
     assert events == []
     screen.wait(1.1)
     assert events == [3]
+
+
+@pytest.mark.parametrize('attribute', ['disabled', 'hidden'])
+def test_server_side_validation(screen: Screen, attribute: Literal['disabled', 'hidden']):
+    b = ui.button('Button', on_click=lambda: ui.label('Success'))
+    b.disable() if attribute == 'disabled' else b.set_visibility(False)
+    ui.button('Hack', on_click=lambda: ui.run_javascript(f'''
+        getElement({b.id}).$emit("click", {{"id": {b.id}, "listener_id": "{list(b._event_listeners.keys())[0]}"}});
+    ''', respond=False))
+
+    screen.open('/')
+    screen.click('Hack')
+    screen.wait(0.5)
+    screen.should_not_contain('Success')

+ 92 - 0
tests/test_favicon.py

@@ -0,0 +1,92 @@
+from pathlib import Path
+from typing import Union
+
+import requests
+from bs4 import BeautifulSoup
+
+from nicegui import favicon, ui
+
+from .screen import PORT, Screen
+
+DEFAULT_FAVICON_PATH = Path(__file__).parent.parent / 'nicegui' / 'static' / 'favicon.ico'
+LOGO_FAVICON_PATH = Path(__file__).parent.parent / 'website' / 'static' / 'logo_square.png'
+
+
+def assert_favicon_url_starts_with(screen: Screen, content: str):
+    soup = BeautifulSoup(screen.selenium.page_source, 'html.parser')
+    icon_link = soup.find("link", rel="icon")
+    assert icon_link['href'].startswith(content)
+
+
+def assert_favicon(content: Union[Path, str, bytes], url_path: str = '/favicon.ico'):
+    response = requests.get(f'http://localhost:{PORT}{url_path}')
+    assert response.status_code == 200
+    if isinstance(content, Path):
+        assert content.read_bytes() == response.content
+    elif isinstance(content, str):
+        assert content == response.text
+    elif isinstance(content, bytes):
+        assert content == response.content
+    else:
+        raise TypeError(f'Unexpected type: {type(content)}')
+
+
+def test_default(screen: Screen):
+    ui.label('Hello, world')
+
+    screen.open('/')
+    assert_favicon(DEFAULT_FAVICON_PATH)
+
+
+def test_emoji(screen: Screen):
+    ui.label('Hello, world')
+
+    screen.ui_run_kwargs['favicon'] = '👋'
+    screen.open('/')
+    assert_favicon_url_starts_with(screen, 'data:image/svg+xml')
+    assert_favicon(favicon.char_to_svg('👋'))
+
+
+def test_data_url(screen: Screen):
+    ui.label('Hello, world')
+
+    icon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=='
+    screen.ui_run_kwargs['favicon'] = icon
+    screen.open('/')
+    assert_favicon_url_starts_with(screen, 'data:image/png;base64')
+    _, bytes = favicon.data_url_to_bytes(icon)
+    assert_favicon(bytes)
+
+
+def test_custom_file(screen: Screen):
+    ui.label('Hello, world')
+
+    screen.ui_run_kwargs['favicon'] = LOGO_FAVICON_PATH
+    screen.open('/')
+    assert_favicon_url_starts_with(screen, '/favicon.ico')
+    assert_favicon(screen.ui_run_kwargs['favicon'])
+
+
+def test_page_specific_icon(screen: Screen):
+    @ui.page('/subpage', favicon=LOGO_FAVICON_PATH)
+    def sub():
+        ui.label('Subpage')
+
+    ui.label('Main')
+
+    screen.open('/subpage')
+    assert_favicon(LOGO_FAVICON_PATH, url_path='/subpage/favicon.ico')
+    screen.open('/')
+
+
+def test_page_specific_emoji(screen: Screen):
+    @ui.page('/subpage', favicon='👋')
+    def sub():
+        ui.label('Subpage')
+
+    ui.label('Main')
+
+    screen.open('/subpage')
+    assert_favicon_url_starts_with(screen, 'data:image/svg+xml')
+    screen.open('/')
+    assert_favicon(DEFAULT_FAVICON_PATH)

+ 3 - 0
tests/test_helpers.py

@@ -2,9 +2,12 @@ import contextlib
 import socket
 import time
 import webbrowser
+from pathlib import Path
 
 from nicegui import helpers
 
+TEST_DIR = Path(__file__).parent
+
 
 def test_is_port_open():
     with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:

+ 18 - 0
tests/test_link.py

@@ -60,3 +60,21 @@ def test_updating_href_prop(screen: Screen):
     assert screen.find('nicegui.io').get_attribute('href') == 'https://nicegui.io/'
     screen.click('change href')
     assert screen.find('nicegui.io').get_attribute('href') == 'https://github.com/zauberzeug/nicegui'
+
+
+def test_link_to_elements(screen: Screen):
+    navigation = ui.row()
+    for i in range(100):
+        ui.label(f'label {i}')
+    link = ui.link('goto top', navigation)
+    with navigation:
+        ui.link('goto bottom', link)
+
+    screen.open('/')
+    assert screen.selenium.execute_script('return window.scrollY') == 0
+    screen.click('goto bottom')
+    screen.wait(0.5)
+    assert screen.selenium.execute_script('return window.scrollY') > 100
+    screen.click('goto top')
+    screen.wait(0.5)
+    assert screen.selenium.execute_script('return window.scrollY') < 100

+ 9 - 0
tests/test_log.py

@@ -54,3 +54,12 @@ def test_special_characters(screen: Screen):
     screen.should_contain('50%')
     screen.click('push')
     screen.should_contain('100%')
+
+
+def test_line_duplication_bug_906(screen: Screen):
+    ui.button('Log', on_click=lambda: ui.log().push('Hi!'))
+
+    screen.open('/')
+    screen.click('Log')
+    screen.should_contain('Hi!')
+    screen.should_not_contain('Hi!\nHi!')

+ 121 - 0
tests/test_observables.py

@@ -0,0 +1,121 @@
+import sys
+
+from nicegui.observables import make_observable
+
+count = 0
+
+
+def reset_counter():
+    global count
+    count = 0
+
+
+def increment_counter():
+    global count
+    count += 1
+
+
+def test_observable_dict():
+    reset_counter()
+    data = make_observable({}, increment_counter)
+    data['a'] = 1
+    assert count == 1
+    del data['a']
+    assert count == 2
+    data.update({'b': 2, 'c': 3})
+    assert count == 3
+    data.pop('b')
+    assert count == 4
+    data.popitem()
+    assert count == 5
+    data.clear()
+    assert count == 6
+    data.setdefault('a', 1)
+    assert count == 7
+    if sys.version_info >= (3, 9):
+        data |= {'b': 2}
+        assert count == 8
+
+
+def test_observable_list():
+    reset_counter()
+    data = make_observable([], increment_counter)
+    data.append(1)
+    assert count == 1
+    data.extend([2, 3, 4])
+    assert count == 2
+    data.insert(0, 0)
+    assert count == 3
+    data.remove(1)
+    assert count == 4
+    data.pop()
+    assert count == 5
+    data.sort()
+    assert count == 6
+    data.reverse()
+    assert count == 7
+    data[0] = 1
+    assert count == 8
+    data[0:2] = [1, 2, 3]
+    assert count == 9
+    del data[0]
+    assert count == 10
+    del data[0:1]
+    assert count == 11
+    data.clear()
+    assert count == 12
+    data += [1, 2, 3]
+    assert count == 13
+
+
+def test_observable_set():
+    reset_counter()
+    data = make_observable({1, 2, 3, 4, 5}, increment_counter)
+    data.add(1)
+    assert count == 1
+    data.remove(1)
+    assert count == 2
+    data.discard(2)
+    assert count == 3
+    data.pop()
+    assert count == 4
+    data.clear()
+    assert count == 5
+    data.update({1, 2, 3})
+    assert count == 6
+    data.intersection_update({1, 2})
+    assert count == 7
+    data.difference_update({1})
+    assert count == 8
+    data.symmetric_difference_update({1, 2})
+    assert count == 9
+    data |= {1, 2, 3}
+    assert count == 10
+    data &= {1, 2}
+    assert count == 11
+    data -= {1}
+    assert count == 12
+    data ^= {1, 2}
+    assert count == 13
+
+
+def test_nested_observables():
+    reset_counter()
+    data = make_observable({
+        'a': 1,
+        'b': [1, 2, 3, {'x': 1, 'y': 2, 'z': 3}],
+        'c': {'x': 1, 'y': 2, 'z': 3, 't': [1, 2, 3]},
+        'd': {1, 2, 3},
+    }, increment_counter)
+    data['a'] = 42
+    assert count == 1
+    data['b'].append(4)
+    assert count == 2
+    data['b'][3].update(t=4)
+    assert count == 3
+    data['c']['x'] = 2
+    assert count == 4
+    data['c']['t'].append(4)
+    assert count == 5
+    data['d'].add(4)
+    assert count == 6

+ 14 - 0
tests/test_select.py

@@ -48,3 +48,17 @@ def test_replace_select(screen: Screen):
     screen.click('Replace')
     screen.should_contain('B')
     screen.should_not_contain('A')
+
+
+def test_multi_select(screen: Screen):
+    s = ui.select(['Alice', 'Bob', 'Carol'], value='Alice', multiple=True).props('use-chips')
+    ui.label().bind_text_from(s, 'value', backward=str)
+
+    screen.open('/')
+    screen.should_contain("['Alice']")
+    screen.click('Alice')
+    screen.click('Bob')
+    screen.should_contain("['Alice', 'Bob']")
+
+    screen.click('cancel')  # remove icon
+    screen.should_contain("['Bob']")

+ 83 - 0
tests/test_serving_files.py

@@ -0,0 +1,83 @@
+
+from pathlib import Path
+
+import httpx
+import pytest
+
+from nicegui import app, ui
+
+from .screen import PORT, Screen
+from .test_helpers import TEST_DIR
+
+IMAGE_FILE = Path(TEST_DIR).parent / 'examples' / 'slideshow' / 'slides' / 'slide1.jpg'
+VIDEO_FILE = Path(TEST_DIR) / 'media' / 'test.mp4'
+
+
+@pytest.fixture(autouse=True)
+def provide_media_files():
+    if not VIDEO_FILE.exists():
+        VIDEO_FILE.parent.mkdir(exist_ok=True)
+        url = 'https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4'
+        with httpx.stream('GET', url) as response:
+            with open(VIDEO_FILE, 'wb') as file:
+                for chunk in response.iter_raw():
+                    file.write(chunk)
+
+
+def assert_video_file_streaming(path: str) -> None:
+    with httpx.Client() as http_client:
+        r = http_client.get(
+            path if 'http' in path else f'http://localhost:{PORT}{path}',
+            headers={'Range': 'bytes=0-1000'},
+        )
+        assert r.status_code == 206
+        assert r.headers['Accept-Ranges'] == 'bytes'
+        assert r.headers['Content-Range'].startswith('bytes 0-1000/')
+        assert r.headers['Content-Length'] == '1001'
+        assert r.headers['Content-Type'] == 'video/mp4'
+
+
+def test_media_files_can_be_streamed(screen: Screen):
+    app.add_media_files('/media', Path(TEST_DIR) / 'media')
+
+    screen.open('/')
+    assert_video_file_streaming('/media/test.mp4')
+
+
+def test_adding_single_media_file(screen: Screen):
+    url_path = app.add_media_file(local_file=VIDEO_FILE)
+
+    screen.open('/')
+    assert_video_file_streaming(url_path)
+
+
+def test_adding_single_static_file(screen: Screen):
+    url_path = app.add_static_file(local_file=IMAGE_FILE)
+
+    screen.open('/')
+    with httpx.Client() as http_client:
+        r = http_client.get(f'http://localhost:{PORT}{url_path}')
+        assert r.status_code == 200
+        assert 'max-age=' in r.headers['Cache-Control']
+
+
+def test_auto_serving_file_from_image_source(screen: Screen):
+    ui.image(IMAGE_FILE)
+
+    screen.open('/')
+    img = screen.find_by_tag('img')
+    assert '/_nicegui/auto/static/' in img.get_attribute('src')
+    assert screen.selenium.execute_script("""
+    return arguments[0].complete && 
+        typeof arguments[0].naturalWidth != "undefined" && 
+        arguments[0].naturalWidth > 0
+    """, img), 'image should load successfully'
+
+
+def test_auto_serving_file_from_video_source(screen: Screen):
+    ui.video(VIDEO_FILE)
+
+    screen.open('/')
+    video = screen.find_by_tag('video')
+    assert '/_nicegui/auto/media/' in video.get_attribute('src')
+    assert_video_file_streaming(video.get_attribute('src'))

+ 27 - 0
tests/test_stepper.py

@@ -0,0 +1,27 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_stepper(screen: Screen):
+    with ui.stepper() as stepper:
+        with ui.step('One'):
+            ui.label('First step')
+            with ui.stepper_navigation():
+                ui.button('Next', on_click=stepper.next)
+                ui.button('Back', on_click=stepper.previous)
+        with ui.step('Two'):
+            ui.label('Second step')
+            with ui.stepper_navigation():
+                ui.button('Next', on_click=stepper.next)
+                ui.button('Back', on_click=stepper.previous)
+
+    screen.open('/')
+    screen.should_contain('First step')
+    screen.should_not_contain('Second step')
+    screen.click('Next')
+    screen.should_contain('Second step')
+    screen.should_not_contain('First step')
+    screen.click('Back')
+    screen.should_contain('First step')
+    screen.should_not_contain('Second step')

+ 134 - 0
tests/test_storage.py

@@ -0,0 +1,134 @@
+import asyncio
+from pathlib import Path
+
+from nicegui import Client, app, background_tasks, ui
+
+from .screen import Screen
+
+
+def test_browser_data_is_stored_in_the_browser(screen: Screen):
+    @ui.page('/')
+    def page():
+        app.storage.browser['count'] = app.storage.browser.get('count', 0) + 1
+        ui.label().bind_text_from(app.storage.browser, 'count')
+
+    @app.get('/count')
+    def count():
+        return 'count = ' + str(app.storage.browser['count'])
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.should_contain('1')
+    screen.open('/')
+    screen.should_contain('2')
+    screen.open('/')
+    screen.should_contain('3')
+    screen.open('/count')
+    screen.should_contain('count = 3')  # also works with FastAPI endpoints
+
+
+def test_browser_storage_supports_asyncio(screen: Screen):
+    @ui.page('/')
+    async def page():
+        app.storage.browser['count'] = app.storage.browser.get('count', 0) + 1
+        await asyncio.sleep(0.5)
+        ui.label(app.storage.browser['count'])
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.switch_to(1)
+    screen.open('/')
+    screen.should_contain('2')
+    screen.switch_to(0)
+    screen.open('/')
+    screen.should_contain('3')
+
+
+def test_browser_storage_modifications_after_page_load_are_forbidden(screen: Screen):
+    @ui.page('/')
+    async def page(client: Client):
+        await client.connected()
+        try:
+            app.storage.browser['test'] = 'data'
+        except TypeError as e:
+            ui.label(str(e))
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.should_contain('response to the browser has already been built')
+
+
+def test_user_storage_modifications(screen: Screen):
+    @ui.page('/')
+    async def page(client: Client, delayed: bool = False):
+        if delayed:
+            await client.connected()
+        app.storage.user['count'] = app.storage.user.get('count', 0) + 1
+        ui.label().bind_text_from(app.storage.user, 'count')
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.should_contain('1')
+    screen.open('/?delayed=True')
+    screen.should_contain('2')
+    screen.open('/')
+    screen.should_contain('3')
+
+
+async def test_access_user_storage_on_interaction(screen: Screen):
+    @ui.page('/')
+    async def page():
+        if 'test_switch' not in app.storage.user:
+            app.storage.user['test_switch'] = False
+        ui.switch('switch').bind_value(app.storage.user, 'test_switch')
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.click('switch')
+    screen.wait(0.5)
+    assert '{"test_switch": true}' in next(Path('.nicegui').glob('storage_user_*.json')).read_text()
+
+
+def test_access_user_storage_from_button_click_handler(screen: Screen):
+    @ui.page('/')
+    async def page():
+        ui.button('test', on_click=app.storage.user.update(inner_function='works'))
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.click('test')
+    screen.wait(1)
+    assert '{"inner_function": "works"}' in next(Path('.nicegui').glob('storage_user_*.json')).read_text()
+
+
+async def test_access_user_storage_from_background_task(screen: Screen):
+    @ui.page('/')
+    def page():
+        async def subtask():
+            await asyncio.sleep(0.1)
+            app.storage.user['subtask'] = 'works'
+        background_tasks.create(subtask())
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    assert '{"subtask": "works"}' in next(Path('.nicegui').glob('storage_user_*.json')).read_text()
+
+
+def test_user_and_general_storage_is_persisted(screen: Screen):
+    @ui.page('/')
+    def page():
+        app.storage.user['count'] = app.storage.user.get('count', 0) + 1
+        app.storage.general['count'] = app.storage.general.get('count', 0) + 1
+        ui.label(f'user: {app.storage.user["count"]}')
+        ui.label(f'general: {app.storage.general["count"]}')
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.open('/')
+    screen.open('/')
+    screen.should_contain('user: 3')
+    screen.should_contain('general: 3')
+    screen.selenium.delete_all_cookies()
+    screen.open('/')
+    screen.should_contain('user: 1')
+    screen.should_contain('general: 4')

+ 20 - 1
tests/test_tabs.py

@@ -3,7 +3,7 @@ from nicegui import ui
 from .screen import Screen
 
 
-def test_tabs(screen: Screen):
+def test_with_strings(screen: Screen):
     with ui.tabs() as tabs:
         ui.tab('One')
         ui.tab('Two')
@@ -18,3 +18,22 @@ def test_tabs(screen: Screen):
     screen.should_contain('First tab')
     screen.click('Two')
     screen.should_contain('Second tab')
+
+
+def test_with_tab_objects(screen: Screen):
+    with ui.tabs() as tabs:
+        tab1 = ui.tab('One')
+        tab2 = ui.tab('Two')
+
+    with ui.tab_panels(tabs, value=tab2):
+        with ui.tab_panel(tab1):
+            ui.label('First tab')
+        with ui.tab_panel(tab2):
+            ui.label('Second tab')
+
+    screen.open('/')
+    screen.should_contain('One')
+    screen.should_contain('Two')
+    screen.should_contain('Second tab')
+    screen.click('One')
+    screen.should_contain('First tab')

+ 3 - 3
tests/test_time.py

@@ -9,12 +9,12 @@ def test_time(screen: Screen):
 
     screen.open('/')
     screen.should_contain('01:23')
-
+    screen.wait(0.2)
     screen.click('8')
     screen.should_contain('08:23')
-
+    screen.wait(0.2)
     screen.click('45')
     screen.should_contain('08:45')
-
+    screen.wait(0.2)
     screen.click('PM')
     screen.should_contain('20:45')

+ 153 - 0
website/build_search_index.py

@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+import ast
+import json
+import os
+import re
+from _ast import AsyncFunctionDef
+from pathlib import Path
+from typing import List, Optional, Union
+
+from nicegui import app, ui
+
+dir_path = Path(__file__).parent
+os.chdir(dir_path)
+
+
+def ast_string_node_to_string(node):
+    if isinstance(node, ast.Str):
+        return node.s
+    elif isinstance(node, ast.JoinedStr):
+        return ''.join(ast_string_node_to_string(part) for part in node.values)
+    else:
+        return str(ast.unparse(node))
+
+
+def cleanup(markdown_string: str) -> str:
+    # Remove link URLs but keep the description
+    markdown_string = re.sub(r'\[([^\[]+)\]\([^\)]+\)', r'\1', markdown_string)
+    # Remove inline code ticks
+    markdown_string = re.sub(r'`([^`]+)`', r'\1', markdown_string)
+    # Remove code blocks
+    markdown_string = re.sub(r'```([^`]+)```', r'\1', markdown_string)
+    markdown_string = re.sub(r'``([^`]+)``', r'\1', markdown_string)
+    # Remove braces
+    markdown_string = re.sub(r'\{([^\}]+)\}', r'\1', markdown_string)
+    return markdown_string
+
+
+class DocVisitor(ast.NodeVisitor):
+
+    def __init__(self, topic: Optional[str] = None) -> None:
+        super().__init__()
+        self.topic = topic
+        self.current_title = None
+        self.current_content: List[str] = []
+
+    def visit_Call(self, node: ast.Call):
+        if isinstance(node.func, ast.Name):
+            function_name = node.func.id
+        elif isinstance(node.func, ast.Attribute):
+            function_name = node.func.attr
+        else:
+            raise NotImplementedError(f'Unknown function type: {node.func}')
+        if function_name in ['heading', 'subheading']:
+            self.on_new_heading()
+            self.current_title = node.args[0].s
+        elif function_name == 'markdown':
+            if node.args:
+                raw = ast_string_node_to_string(node.args[0]).splitlines()
+                raw = ' '.join(l.strip() for l in raw).strip()
+                self.current_content.append(cleanup(raw))
+        self.generic_visit(node)
+
+    def on_new_heading(self) -> None:
+        if self.current_title:
+            self.add_to_search_index(self.current_title, self.current_content if self.current_content else 'Overview')
+            self.current_content = []
+
+    def visit_AsyncFunctionDef(self, node: AsyncFunctionDef) -> None:
+        self.visit_FunctionDef(node)
+
+    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
+        if node.name == 'main_demo':
+            docstring = ast.get_docstring(node)
+            if docstring is None:
+                api = getattr(ui, self.topic) if hasattr(ui, self.topic) else getattr(app, self.topic)
+                docstring = api.__doc__ or api.__init__.__doc__
+            lines = cleanup(docstring).splitlines()
+            self.add_to_search_index(lines[0], lines[1:], main=True)
+
+        for decorator in node.decorator_list:
+            if isinstance(decorator, ast.Call):
+                function = decorator.func
+                if isinstance(function, ast.Name) and function.id == 'text_demo':
+                    title = decorator.args[0].s
+                    content = cleanup(decorator.args[1].s).splitlines()
+                    self.add_to_search_index(title, content)
+                if isinstance(function, ast.Name) and function.id == 'element_demo':
+                    attr_name = decorator.args[0].attr
+                    obj_name = decorator.args[0].value.id
+                    if obj_name == 'app':
+                        docstring: str = getattr(app, attr_name).__doc__
+                        docstring = ' '.join(l.strip() for l in docstring.splitlines()).strip()
+                        self.current_content.append(cleanup(docstring))
+                    else:
+                        print(f'Unknown object: {obj_name} for element_demo', flush=True)
+        self.generic_visit(node)
+
+    def add_to_search_index(self, title: str, content: Union[str, list], main: bool = False) -> None:
+        if isinstance(content, list):
+            content_str = ' '.join(l.strip() for l in content).strip()
+        else:
+            content_str = content
+
+        anchor = title.lower().replace(' ', '_')
+        url = f'/documentation/{self.topic or ""}'
+        if not main:
+            url += f'#{anchor}'
+            if self.topic:
+                title = f'{self.topic.replace("_", " ").title()}: {title}'
+        documents.append({
+            'title': title,
+            'content': content_str,
+            'url': url,
+        })
+
+
+class MainVisitor(ast.NodeVisitor):
+
+    def visit_Call(self, node: ast.Call):
+        if isinstance(node.func, ast.Name):
+            function_name = node.func.id
+        elif isinstance(node.func, ast.Attribute):
+            function_name = node.func.attr
+        else:
+            return
+        if function_name == 'example_link':
+            title = ast_string_node_to_string(node.args[0])
+            name = name = title.lower().replace(' ', '_')
+            documents.append({
+                'title': 'Example: ' + title,
+                'content': ast_string_node_to_string(node.args[1]),
+                'url': f'https://github.com/zauberzeug/nicegui/tree/main/examples/{name}/main.py',
+            })
+
+
+def generate_for(file: Path, topic: Optional[str] = None) -> None:
+    tree = ast.parse(file.read_text())
+    doc_visitor = DocVisitor(topic)
+    doc_visitor.visit(tree)
+    if doc_visitor.current_title:
+        doc_visitor.on_new_heading()  # to finalize the last heading
+
+
+documents = []
+tree = ast.parse(Path('../main.py').read_text())
+MainVisitor().visit(tree)
+
+generate_for(Path('./documentation.py'))
+for file in Path('./more_documentation').glob('*.py'):
+    generate_for(file, file.stem.removesuffix('_documentation'))
+
+with open('static/search_index.json', 'w') as f:
+    json.dump(documents, f, indent=2)

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است